《UNIX网络编程 卷1》 笔记:TCP 客户/服务器程序示例

时间:2021-10-02 17:51:30

本节我们实现一个简单的TCP回射服务器和客户程序,来说明一个典型的TCP服务器和客户程序如何工作以及它们之间的交互。

TCP回射服务器的功能很简单,就是将客户发送过来的数据再送回给客户。

TCP回射客户的功能是从标准输入读取一行数据,发送给服务器,再把服务器发回的数据输出到标准输出。

TCP回射客户的代码如下:

#include "unp.h"

void str_cli(FILE *fp, int sockfd);

int main(int argc, char **argv)
{
int sockfd;
struct sockaddr_in servaddr;

if (argc != 2)
err_quit("usage: tcpcli <IPaddress>");

sockfd = Socket(AF_INET, SOCK_STREAM, 0); /*创建一个TCP套接字*/
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT); /*指定服务器的端口号*/
Inet_pton(AF_INET, argv[1], &servaddr.sin_addr); /*指定服务器的IP地址*/
Connect(sockfd, (SA *)&servaddr, sizeof(servaddr)); /*连接到服务器*/
str_cli(stdin, sockfd); /*与服务器交互*/

exit(0);
}


void str_cli(FILE *fp, int sockfd)
{
char sendline[MAXLINE], recvline[MAXLINE];

while (Fgets(sendline, MAXLINE, fp) != NULL) { /*从标准输入读取一行文本*/
Writen(sockfd, sendline, strlen(sendline)); /*发送到服务器*/
if (Readline(sockfd, recvline, MAXLINE) == 0) /*从服务器读取一行文本*/
err_quit("str_cli: server terminated prematurely");
Fputs(recvline, stdout); /*输出到标准输出*/
}
}

TCP回射服务器的代码如下:

#include "unp.h"

void str_echo(int sockfd);
void sig_chld(int signo);

int main(int argc, char **argv)
{
int listenfd, connfd;
pid_t childpid;
socklen_t clilen;
struct sockaddr_in cliaddr, servaddr;

listenfd = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); /*监听所有的本地IP地址*/
servaddr.sin_port = htons(SERV_PORT); /*监听端口9877*/

Bind(listenfd, (SA *)&servaddr, sizeof(servaddr)); /*socket绑定IP地址和端口号*/

Listen(listenfd, LISTENQ); /*监听该socket*/

signal(SIGCHLD, sig_chld); /*注册子进程退出处理函数*/

for ( ; ; ) {
clilen = sizeof(cliaddr);
/*接收客户的请求*/
if ((connfd = accept(listenfd, (SA *)&cliaddr, &clilen)) < 0) {
if (errno == EINTR)
continue;
else
err_sys("accept error");
}
if ((childpid = Fork()) == 0) { /*为每个客户派生一个子进程处理*/
Close(listenfd);
str_echo(connfd);
exit(0);
}
Close(connfd);
}
}

void str_echo(int sockfd)
{
ssize_t n;
char buf[MAXLINE];

again:
while ((n = read(sockfd, buf, MAXLINE)) > 0)
Writen(sockfd, buf, n);

if (n < 0 && errno == EINTR)
goto again;
else if (n < 0)
err_sys("str_echo: read error");
}

void sig_chld(int signo)
{
pid_t pid;
int stat;

/*等待所有子进程终止*/
while ((pid = waitpid(-1, &stat, WNOHANG)) > 0)
printf("child %d terminated\n", pid);
return;
}

服务器程序注意的几点如下:

    1. 慢速系统调用accept可能会被信号中断,所以出现这种情况时需要重新调用accept。

    2. fork()子进程时要捕捉SIGCHLD信号,并正确处理使所有的子进程都能正常终止。

在后台启动服务器程序,查看处于LISTEN状态的socket:

《UNIX网络编程 卷1》 笔记:TCP 客户/服务器程序示例

第四行就是我们的服务器进程创建的socket,正在监听本地所有IP地址(0.0.0.0)和9877端口。

然后启动客户程序连接到服务器,查看处于ESTABLISHED状态的socket:

《UNIX网络编程 卷1》 笔记:TCP 客户/服务器程序示例

第一行是我们的客户socket,第四行是服务器socket,这个socket由服务器接收到客户的连接请求后创建,并创建了一个子进程,经由这个socket和客户进行交互。

还可以明显观察到客户socket的本地地址就是服务器socket的外部地址,客户socket的外部地址就是服务器socket的本地地址。

然后我们按CTRL+D中止客户进程。

《UNIX网络编程 卷1》 笔记:TCP 客户/服务器程序示例

发现服务客户的子进程已经终止。客户的socket进入了TIME_WAIT状态。

整个过程是这样的:

    1. 当我们在客户终端按CTRL+D后会产生EOF字符时,fgets返回一个空指针,于是str_cli函数返回。
    2. 当str_cli返回到main函数时,调用exit终止。
    3. 进程终止处理的部分工作是关闭所有打开的描述符,因此客户打开的socket由内核关闭。这导致客户TCP发送一个FIN给服务器,服务器TCP则以ACK回应,至此,服务器socket处于CLOSE_WAIT状态,客户socket则处于FIN_WAIT_2状态。
    4. 当服务器收到FIN时,readline返回0,导致str_echo函数返回到main函数。
    5. 服务器子进程通过调用exit来终止。
    6. 服务器子进程中所有打开的描述符被关闭。由子进程来关闭已连接套接字会引发TCP连接终止序列的最后两个分组,一个由服务器到客户的FIN和一个客户到服务器的ACK。至此,连接完全终止。客户套接字进入TIME_WAIT状态。


再让我们看看服务器子进程终止会发生什么情况。

再次启动客户进程,ps -ef查看服务器子进程的进程号,然后终止服务器子进程。

《UNIX网络编程 卷1》 笔记:TCP 客户/服务器程序示例

服务器子进程正常终止,而客户程序没有一点反应,直到我们敲下回车后才有反应。

《UNIX网络编程 卷1》 笔记:TCP 客户/服务器程序示例

为什么会这样呢?先看下整个过程发生了什么:

    1. 用kill命令杀死服务器子进程。作为进程终止处理的部分工作,子进程中所有打开着的描述符都被关闭。这就导致向客户发送一个FIN,而客户TCP则响应以一个ACK。
    2. SIGCHLD信号被递交给服务器父进程,并得到正确处理。
    3. 客户TCP接收来自服务器TCP的FIN并响应以一个ACK,然而问题是客户进程阻塞在fgets调用上,等待从终端接收一行文本。
    4. 可以在客户终端再键入一行文本或者敲下回车,客户TCP接着把数据发送给服务器。TCP允许这么做,因为客户TCP接收到FIN只是表示服务器进程已关闭了连接的发送端,从而不再发送任何数据而已,FIN的接收并没有告知客户TCP服务器进程已经终止(本例中它确实是终止了)。当服务器TCP接收到来自客户的数据时,既然先前打开的那个socket已经不存在,于是响应以一个RST。
    5. 然而客户进程看不到这个RST,它阻塞在readline调用中,由于第3步中接收的FIN,所调用的readline立即返回0。客户此时并未预期收到EOF,于是以出错退出。

问题原因在于客户实际在应对两个输入---套接字输入和标准输入,它不能单纯地阻塞在这两个输入中的一个,而是应该同时阻塞在这两个输入上。事实上这正是selectpoll这两个函数的目的之一。同时监听多个描述符的技术也称作I/O复用技术,后面我们将会用这种技术解决这个问题。