UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例

本章将编写一个完整的TCP客户/服务器程序,这个简单例子是执行以下步骤的一个回射服务器:
1.客户从标准输入读入一行文本,并写给服务器;
2.服务器从网络输入读入这行文本,并回射给客户;
3.客户从网络输入读入这行回射文本,并显示在标准输出上。
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第1张图片
如上图,我们在客户与服务器之间画了两个单向箭头,但实际上它们构成一个全双工的TCP连接。fets和fputs函数来自标准IO函数库,writen和readline这两个函数是我们编写的。

大多数TCP/IP实现已经提供了以上这种回射服务器,有使用UDP的,也有使用TCP的。

回射输入行这样的客户/服务器程序是一个简单而有效的网络应用程序的例子,实现任何客户/服务器网络应用所需的所有基本步骤可通过本例阐明,若想把本例扩充成自己的应用程序,只需修改服务器对来自客户的输入的处理过程。

TCP回射服务器的main函数:

#include "unp.h"

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);    // 捆绑通配地址,告诉系统,如果系统是多宿主机,我们接受目的地址为任何本地接口的地址
    servaddr.sin_port = htons(SERV_PORT);    // 在头文件unp.h中,SERV_PORT的值定义为9877

    Bind(listenfd, (SA *)&servaddr, sizeof(servaddr));
    
    Listen(listenfd, LISTENQ);
    
    for (; ; ) {
        clilen = sizeof(cliaddr);
        connfd = Accept(listenfd, (SA *)&cliaddr, &clilen);    // 服务器阻塞于此,等待客户连接的完成
        
        if ((childpid = Fork()) == 0) {    /* child process */
            Close(listenfd);    /* close listening socket */
            str_echo(connfd);    /* process the request */
            exit(0);
        }
        Close(connfd);    /* parent closes connected socket */
    }
}

以上程序中,SERV_PORT应该比1023大,因为我们不需要一个保留端口;要比5000大,以免与许多源自Berkeley的实现分配临时端口的范围冲突;要比49152小,以免与临时端口号的正确范围冲突;还应该不和任何已注册的端口冲突。

上例中,fork函数返回后,子进程关闭监听套接字,父进程关闭已连接套接字,之后子进程调用str_echo处理客户请求。

以下是TCP回射服务器的str_echo函数,它从客户读入数据,并把它们回射给客户:

#include "unp.h"

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\n");
    }
}

以上代码中,read函数从套接字读入数据,writen函数把读到的内容回射给客户。如果客户关闭连接,那么接收到客户的FIN将导致服务器子进程的read函数返回0,这又导致str_echo函数的返回,从而在main函数中终止子进程。

以下是TCP回射客户程序的main函数:

#include "unp.h"

int main(int argc, char **argv) {
    int sockfd;
    struct sockaddr_in servaddr;
    
    if (argc != 2) {
        err_quit("usage: tcpcli ");
    }
    sockfd = Socket(AF_INET, SOCK_STREAM, 0);
    
    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(SERV_PORT);
    Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
    
    Connect(sockfd, (SA *)&servaddr, sizeof(servaddr));
    
    str_cli(stdin, sockfd);    /* do it all,完成客户剩余部分的处理工作 */
    
    exit(0);
}

TCP回射客户程序str_cli函数:

#include "unp.h"

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);
    }
}

以上代码中的fgets函数读入一行文本,它在以下情况会返回:读入了MAXLINE-1字节、遇到EOF、读到换行符,之后writen函数把读到的该行发给服务器。

readline函数从服务器读入回射行,fputs函数把它写到标准输出。

当遇到文件结束符或错误时,fgets函数返回一个空指针,客户的循环于是被终止。包裹函数Fgets会检查是否发生错误,如果发生则终止进程,因此Fgets函数只会在遇到文件结束符时才返回一个空指针。

首先在linux主机上后台启动服务器:
在这里插入图片描述
服务器启动后,会阻塞到accept调用,在启动客户前,运行netstat检查服务器监听套接字的状态:
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第2张图片
上图中只给出了第一行的标题和我们最关心的那行,该命令列出系统中所有套接字的状态,可能会有大量输出,我们必须指定-a(-all)选项查看所有套接字,包括监听套接字和非监听套接字,否则不会列出监听套接字。

以上输出是我们所期望的,它处于LISTEN状态,有通配的本地地址,本地端口为9877。netstat用星号表示一个为0的IP地址(INADDR_ANY)或为0的端口号。

之后在同一主机上启动客户,并指定服务器的主机IP为127.0.0.1(环回地址),当然我们也可指定该地址为主机的非环回IP地址:
在这里插入图片描述
客户调用connect会引起TCP的三路握手过程,当三路握手完成后,客户中的connect函数和服务器的accept函数均返回,连接于是建立。接着会发生如下步骤:
1.客户调用str_cli函数,该函数阻塞于fgets调用,因为我们还未曾键入过一行文本。
2.服务器中的accept函数返回时,服务器调用fork,再由子进程调用str_echo,该函数调用readline,readline函数调用read,而read函数在等待客户送入一行文本期间阻塞。
3.服务器父进程再次调用accept并阻塞,等待下一个客户连接。

至此,我们有3个正在睡眠的进程:客户进程、服务器父进程、服务器子进程。

以上步骤中,我们先列出客户的步骤,原因在于客户收到三路握手的第2个分节时,connect函数就返回了,而服务器要直到收到三路握手的第3个分节才返回,即在connect函数返回后再过RTT的一半才返回。

既然是在同一台主机上运行客户和服务器,netstat会给出对应所建立TCP连接的两行额外输出:
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第3张图片
上图中第一个ESTABLISHED行对应服务器子进程的套接字,因为它的本地端口号是9877;第二个ESTABLISHED行对应客户进程的套接字,因为它的本地端口号是42758。如果我们在不同主机上运行客户和服务器,那么客户主机就只输出客户进程的套接字,服务器主机也只输出两个父子服务器进程的套接字。

我们也可以用ps命令检查这些进程的状态和关系:
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第4张图片
上图中使用了ps命令的特定的命令行参数限定了它只输出与本讨论相关的信息。从上图可见,客户和服务器运行在同一个窗口中(即pts/6,表示伪终端号6)。PID和PPID列给出了进程间的父子关系,子进程的PPID是父进程的PID,因此,第一个tcpserv01是父进程,第二个tcpserv01是子进程,而父进程的PPID是shell(bash)。

上图中3个网络进程的STAT列都是S,表明进程在为等待某些资源而睡眠,WCHAN列给出了睡眠状态的进程的情况,Linux在进程阻塞于accept或connect函数时,输出wait_for_connect;进程阻塞于套接字输入或输出时,输出tcp_data_wait;进程阻塞于终端IO时,输出read_chan。

至此连接已建立,不论我们在客户的标准输入中键入什么,都会回射到它的标准输出中:
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第5张图片
如上图,我们键入的两行都得到了回射,接着我们键入终端EOF字符(Control-D)以终止客户。然后如果立即执行netstat命令,会看到如下结果:
在这里插入图片描述
如上图,当前连接的客户端(它的本地端口为42758)进入了TIME_WAIT状态,而监听服务器仍在等待另一个客户连接。上图中我们让命令netstat的输出通过管道作为grep程序的输入,从而只输出与服务器的众所周知端口相关的文本行,这样做也删掉了标题行。

如果我们把客户的标准输入重定向到一个二进制文件,则可能不能正常工作。如果二进制文件的前3个字节为二进制数1、二进制数0、换行符,由于fgets函数最多读入MAXLINE-1字节,除非碰到换行符或到达文件尾而提前返回,因此fgets函数会读入3个字节,然而在计算要发送的串的长度时,strlen函数会返回1,因为第2个字节就是空字节了,客户于是只把第一个字节发送给服务器,导致服务器阻塞在readline函数上,等待一个换行符,而客户也阻塞在等待服务器的应答上,这就是所谓的死锁,两个进程都阻塞在等待因对方原因而永远不会到达的事件上。此处的问题在于,fgets函数以一个空字节表示所返回数据的结尾,因此它读入的数据不能含有空字节。

如果我们将Telnet作为客户与我们的回射服务器通信,会发生什么?Telnet把输入行转换成NVT ASCII,意味着以CR后跟LF的双字节序列终止每一行,而我们自己的客户程序只用换行符终止每一行。我们仍然可以使用Telnet客户与我们的服务器通信,因为我们的服务器回射每个字符,包括换行符前的回车符。

正常终止客户和服务器的步骤:
1.当我们键入EOF字符时,fgets函数返回一个空指针,于是str_cli函数返回。
2.当str_cli函数返回到客户的main函数后,main调用exit终止进程。
3.进程终止的部分工作是关闭所有打开的描述符,因此客户打开的套接字由内核关闭。这导致TCP发送一个FIN给服务器,服务器TCP则以ACK响应,这是TCP连接终止序列的前半部分。至此,服务器套接字处于CLOSE_WAIT状态,客户套接字处于FIN_WAIT_2状态。
4.当服务器TCP接收到FIN时,服务器子进程阻塞于readline调用,于是readline函数返回0,这会导致str_echo函数返回服务器子进程的main函数。
5.服务器子进程通过调用exit来终止。
6.服务器子进程中所有打开描述符随之关闭,子进程关闭已连接套接字会引发TCP连接终止序列的最后两个分节:一个从服务器到客户的FIN和一个从客户到服务器的ACK。至此,连接完全终止,客户套接字进入TIME_WAIT状态。
7.在服务器子进程终止时,给父进程发送一个SIGCHLD信号,但本例中我们没有在代码中捕获该信号,而该信号的默认行为是被忽略。既然父进程未加处理,子进程于是进入僵死状态:
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第6张图片
如上图,子进程的状态是Z(僵死)。

信号是告知某个进程发生了某个事件的通知,有时也称为软件中断。信号通常是异步发生的,进程预先不知道信号的准确发生时刻。

信号可以:
1.由一个进程发给另一个进程(或自身);
2.由内核发给某个进程。

SIGCHLD信号就是内核在任何一个进程终止时发给它的父进程的一个信号。

每个信号都有一个与之相关的处置(disposition),也称为行为(action),我们通过调用sigaction来设定一个信号的处置,有三种选择:
1.我们可以提供一个函数,只要有特定信号发生它就被调用,这样的函数称为信号处理函数,这种行为称为捕获信号。有两个信号不能捕获,它们是SIGKILL和SIGSTOP,信号处理函数的参数只有一个,且是信号值,它没有返回值,其函数原型如下:

void handler(int signo);

对大多信号来说,调用sigaction并指定信号发生时要调用的函数就是捕获信号所需的全部工作,但SIGIO、SIGPOLL、SIGURG信号还要求捕获它的进程做额外工作。
2.我们可以把某信号的处置设定为SIG_IGN来忽略它,SIGKILL和SIGSTOP不能被忽略。
3.我们可以把某个信号的处置设定为SIG_DFL来使用它的默认处置。默认处置通常是在收到信号后终止进程,其中某些信号还在当前工作目录产生一个核心映像(core image,也称内存影像)。有个别信号的默认处置是忽略,SIGCHLD和SIGURG(带外数据到达)就是默认处置为忽略的其中两个信号。

POSIX建立信号处置的方法是调用sigaction函数,但该函数有点复杂,简单的方法是使用signal函数,它第一个参数是信号名,第二个参数为指向函数的指针或常值SIG_IGN或常值SIG_DFL。但signal函数是早于POSIX出现的历史悠久的函数,调用它时,不同的实现提供不同的信号语义以达成向后兼容,而POSIX明确规定了调用sigaction时的信号语义。我们的解决方法是定义自己的signal函数,它只是调用POSIX的sigaction函数,这就以期望的POSIX语义提供了一个简单的接口,我们把该函数与早先出现的err_XXX函数和包裹函数等一起包含在自己的函数库中。我们的signal函数如下:

#include "unp.h"

Sigfunc *signal(int signo, Sigfunc *func) {
    struct sigaction act, oact;
    
    act.sa_handler = func;    // 设置信号处理函数
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;
    if (signo == SIGALRM) {
#ifdef SA_INTERRUPT
        act.sa_flags |= SA_INTERRUPT;    /* SunOS 4.x */
#endif
    } else {
#ifdef SA_RESTART
        act.sa_flags |= SA_RESTART;    /* SVR4, 4.4BSD */
#endif    
    }
    if (sigaction(signo, &act, &oact) < 0) {
        return SIG_ERR;
    }
    return oact.sa_handler;
}

以上程序中,函数signal的正常函数原型因层次太多显得很复杂:

void (*signal(int signo, void (*func)(int)))(int);

为简化它,我们在头文件unp.h中定义了如下Sigfunc类型:

typedef void Sigfunc(int);

Sigfunc类型为仅有一个整数参数且不返回值的函数类型,signal函数的原型于是变为:

Sigfunc *signal(int signo, Sigfunc *func);

signal函数的第二个参数和返回值都是指向信号处理函数的指针。

POSIX允许我们指定一组信号,它们在信号处理函数被调用时阻塞,任何阻塞的信号都不能递交给进程。以上程序中,我们把sa_mask成员设为空集,意味着在该信号处理函数运行期间,不阻塞额外的信号。POSIX保证被捕获的信号在其信号处理函数运行期间总是阻塞的。

上例程序中,SA_RESTART标志是可选的,如果设置,被相应信号中断的系统调用将由内核自动重启。如果被捕获的信号不是SIGALRM且SA_RESTART有定义,我们就设置该标志,对SIGALRM进行处理的原因在于,产生SIGALRM信号的目的通常是为IO操作设置超时,此时我们希望受阻塞的IO系统调用被该信号中断掉。一些早期的系统(如SunOS 4.x)默认会自动重启被中断的系统调用,而SA_INTERRUPT标志使内核不再自动重启被中断的系统调用,如果定义了SA_INTERRUPT标志,我们就在被捕获的信号是SIGALRM时设置它。

以上代码中,我们的返回值像旧的signal函数的行为一样,返回信号处理函数或SIG_ERR。

POSIX信号语义:
1.一旦安装了信号处理函数,它就一直安装着,不像较早期的系统一样每执行一次就将其拆除。
2.在一个信号处理函数运行期间,正被递送的信号是阻塞的,且安装处理函数时传递给sigaction函数的sigaction结构的成员sa_mask信号集中指定的额外信号也被阻塞。
3.如果一个信号在被阻塞期间产生了一次或多次,那么该信号被解阻塞后通常只递交一次,即Unix信号默认是不排队的。POSIX实时标准1003.1b定义了一些排队的可靠信号,但我们本书不使用。
4.sigprocmask函数可选择性地阻塞或解阻塞一组信号,使得我们可以在一段临界区代码执行期间,防止捕获某些信号,以此保护这段代码(防止在临界区中进入信号处理函数,导致其他线程等待过久)。

设置僵死状态的目的是维护子进程信息,以便父进程在以后某个时间获取,这些信息包括子进程的进程ID、终止状态、资源利用信息(CPU时间、内存使用量等)。如果一个进程终止,而该进程有子进程处于僵死状态,那么它的所有僵死子进程的父进程ID将被重置为1(init进程),继承这些子进程的init进程将清理它们(即init进程将wait它们,从而去除它们的僵死状态)。有些Unix系统在ps命令的输出的COMMAND栏以指明僵死进程。

我们不愿意留存僵死进程,因为它们会占用内核空间,最终可能导致我们耗尽进程资源。无论何时我们fork子进程后都得wait它们,以防它们变成僵死进程,为此我们建立一个俘获SIGCHLD信号的信号处理函数,在函数中我们调用wait,我们可以在服务器的listen调用后增加以下函数调用建立SIGCHLD信号的信号处理函数:

Signal(SIGCHLD, sig_chld);

名为sig_chld的信号处理函数代码如下:

#include "unp.h"

void sig_chld(int signo) {
    pid_t pid;
    int stat;
    
    pid = wait(&stat);
    printf("child %d terminated\n", pid);
    return;
}

上例代码中,在信号处理函数中调用如printf这样的标准IO函数是不合适的,我们这里调用printf只为查看子进程何时终止。

System V和Unix 98标准下,如果一个进程把SIGCHLD的处置设为SIG_IGN,则它的子进程不会变为僵死进程,但POSIX没有明确表示要这样做,处理僵死进程的可移植方法就是捕获SIGCHLD,并调用wait或waitpid。

在回射服务器中加入信号处理函数后(使用的signal函数来自系统自带的函数库,而非我们自己编写的版本),在Solaris 9下编译该回射服务器,有如下结果:
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第7张图片
上图经过的步骤如下:
1.我们键入EOF终止客户进程后,客户TCP发送一个FIN给服务器,服务器响应一个ACK。
2.收到客户的FIN导致服务器TCP递送一个EOF给子进程阻塞中的readline函数,从而子进程终止。
3.当SIGCHLD信号递交时,父进程阻塞于accept调用,sig_chld函数被执行,其wait函数取到子进程的PID和终止状态,然后是printf调用,最后从sig_chld函数返回。
4.服务器的SIGCHLD信号是在父进程阻塞于慢系统调用accept时由父进程捕获的,内核会使accept函数返回一个EINTR错误,而父进程没有处理该错误,于是终止。

在以上运行于Solaris 9环境下的例子中,其标准C函数库提供的signal函数不会使内核自动重启被中断的系统调用,即SA_RESTART标志在系统函数库的signal函数中没有设置。有些系统会自动重启被中断的系统调用,如果我们在4.4 BSD环境下使用系统函数库版本的signal函数运行以上例子,内核将重启被中断的系统调用,于是accept函数不会返回错误。我们定义自己的signal函数的理由之一就是应对不同操作系统之间的这个潜在问题。

我们在本书中总是在信号处理函数中显式给出return语句,即使对于返回值类型为void的函数而言,从结尾处掉出和执行return语句效果是一样的,我们也还是使用return语句,这样在读代码时,可以提醒我们返回可能会中断一个系统调用。

术语慢系统调用适用于可能永远阻塞的系统调用,如多数网络支持函数都属于这类,例如,如果客户连接到服务器上,那么服务器的accept函数就不会返回;如果回射客户进程永远不发那行要求服务器回射的文本,那么服务器的read调用将永不返回。其他慢系统调用的例子是对管道和终端设备的读、写。磁盘IO不属于慢系统调用,它们一般都会返回到调用者(假设没有灾难性硬件故障)。

适用于慢系统调用的规则是:当阻塞于某个慢系统调用的一个进程捕获某个信号且相应信号处理函数返回时,该系统调用可能返回一个EINTR错误。有些内核自动重启某些被中断的系统调用,不过为了便于移植,我们编写捕获信号的程序时(如大多数的并发服务器都捕获SIGCHLD),我们需要对慢系统调用返回EINTR有所准备。可移植性问题是由POSIX的SA_RESTART标志是可选的有关。有些实现尽管支持SA_RESTART标志,也不是所有被中断的系统调用都会自动重启,例如大多源自伯克利的实现都不会自动重启select函数,其中的一些实现从不会自动重启accept或recvfrom函数。

为了处理被中断的accept函数,对其做以下修改:

for (; ; ) {
    clilen = sizeof(cliaddr);
    if ((connfd = accept(listenfd, (SA *)&cliaddr, &clilen)) < 0) {
        if (errno == EINTR) {
            continue;    /* back to for() */
        } else {
            err_sys("accept error");
        }
    }
}

以上代码中,我们调用的是accept函数本身而非其包裹函数Accept,因为我们需要自己处理该函数失败的情况。

以上代码所做的事情就是自己重启被中断的系统调用。对于accept、read、write、select、open之类的函数来说,以上做法是合适的,但对于connect函数返回EINTR时,我们不能再次调用它,否则将立即返回一个错误(若connect失败,则该套接字不再可用,必须关闭)。当connect函数被捕获的信号中断且没有自动重启时,我们必须调用select函数来等待连接建立成功。

以下函数用来处理已终止的子进程:
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第8张图片
wait和waitpid函数均返回两个值:返回值是已终止子进程的进程ID;statloc指针返回一个表示子进程终止状态的整数。我们可以调用3个宏来检查终止状态,来辨别子进程是正常终止的,还是由某个信号杀死的,还是仅仅由作业控制停止而已。还有一些宏用于接着获取子进程的退出状态、杀死子进程的信号值、停止子进程的作业控制信号的值。

调用wait的进程如果没有已终止的子进程,但有一个或多个子进程仍在执行,则wait函数将阻塞到现有子进程第一个终止为止。

waitpid函数就等待哪个子进程以及是否阻塞给了我们更多的控制。waitpid函数的pid参数指明我们想等待的子进程ID,可以传-1表示等待第一个终止的子进程(还有一些处理进程组ID的可选值,但本书中用不到);options参数允许我们指定选项,常用选项是WNOHANG,它告知内核在没有已终止子进程时不要阻塞。

我们现在说明函数wait和waitpid在用来清理已终止子进程时的区别,为此,我们将修改客户程序,使客户建立5个与服务器的连接,随后调用str_cli函数时仅用第一个连接发送数据,建立多个连接的目的是从并发服务器上派生多个子进程:
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第9张图片
以下是与服务器建立了5个连接的TCP客户程序:

#include "unp.h"

int main(int argc, char **argv) {
    int i, sockfd[5];
    struct sockaddr_in servaddr;
    
    if (argc != 2) {
        err_quit("usage: tcpcli ");
    }
    for (i = 0; i < 5; ++i) {
        sockfd[i] = Socket(AF_INET, SOCK_STREAM, 0);
        
        bzero(&servaddr, sizeof(servaddr));
        servaddr.sin_family = AF_INET;
        servaddr.sin_port = htons(SERV_PORT);
        Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
        
        Connect(sockfd[i], (SA *)&servaddr, sizeof(servaddr));
    }
    str_cli(stdin, sockfd[0]);    /* do it all */
    
    exit(0);
}

当以上客户进程终止时,所有打开的描述符由内核关闭(我们没有调用close,只调用了exit),且所有5个连接基本在同一时刻终止,这引发了5个FIN,每个连接一个,它们使服务器的5个子进程基本在同一时刻终止,这又导致差不多在同一时刻有5个SIGCHLD信号递交给父进程:
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第10张图片
这种同一信号多个实例的递交会造成问题。我们首先在后台运行服务器(已安装SIGCHLD的信号处理函数),接着运行新客户:
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第11张图片
我们注意到服务器只有一个输出,而我们预期所有5个子进程都终止了,运行ps命令我们将发现,其他4个子进程仍作为僵死进程存在着:
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第12张图片
建立一个信号处理函数并在其中调用wait并不足以防止出现僵死进程,问题在于,所有5个信号都在信号处理函数执行之前产生,而信号处理函数只执行一次,因为Unix信号一般是不排队的。不过有时候,依赖于FIN到达服务器主机的时机,信号处理函数可能会执行2~5次。

以上问题的解决方法是调用waitpid而不是wait,以下是sig_chld函数的最终正确版本:

#include "unp.h"

void sig_chld(int signo) {
    pid_t pid;
    int stat;
    
    while ((pid = waitpid(-1, &stat, WNOHANG)) > 0) {
        printf("child %d terminated\n", pid);
    }
    
    return;
}

以上代码中,我们在一个循环内调用waitpid,以获取所有已终止子进程的状态。我们必须指定WNOHANG选项,它告知waitpid函数在有尚未终止的子进程在运行时不要阻塞。我们不能再循环内调用wait,因为wait函数会在正运行的子进程尚未终止时阻塞。

以下是服务器程序的最终正确版本,它正确处理accept返回的EINTR,并建立一个调用waitpid的SIGCHLD信号处理函数:

#include "unp.h"

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\n");
    }
}

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);    // 捆绑通配地址,告诉系统,如果系统是多宿主机,我们接受目的地址为任何本地接口的地址
    servaddr.sin_port = htons(SERV_PORT);    // 在头文件unp.h中,SERV_PORT的值定义为9877

    Bind(listenfd, (SA *)&servaddr, sizeof(servaddr));
    
    Listen(listenfd, LISTENQ);
 
    Signal(SIGCHLD, sig_chld);
   
    for (; ; ) {
        clilen = sizeof(cliaddr);
        if ((connfd = accept(listenfd, (SA *)&cliaddr, &clilen)) < 0) {
            if (errno = EINTR) {
                continue;    /* back to for() */
            } else {
                err_sys("accept error");
            }
        }
        
        if ((childpid = Fork()) == 0) {    /* child process */
            Close(listenfd);    /* close listening socket */
            str_echo(connfd);    /* process the request */
            exit(0);
        }
        Close(connfd);    /* parent closes connected socket */
    }
}

以上是为了展示我们在网络编程时可能遇到的3种情况:
1.当fork子进程时,必须捕获SIGCHLD信号。
2.当捕获信号时,必须处理被中断的系统调用。
3.SIGCHLD的信号处理函数必须正确编写,应使用waitpid函数以免留下僵死进程。

还有一种情形会导致accept函数返回一个非致命错误,此时只需再次调用accept,以下分组序列在较忙的服务器上会出现:
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第13张图片
如上图,三路握手完成从而连接建立后,客户TCP却发送了一个RST,在服务器看来,该连接已由TCP排队,之后在服务器进程调用accept之前,RST到达。

模拟这种情况的一个简单方法是,启动服务器,调用sock、bind、listen,然后在调用accept前睡眠一小段时间。在服务器进程睡眠时,启动客户进程,调用socket、connect,一旦connect函数返回,就设置SO_LINGER套接字选项产生这个RST,然后终止。

如何处理上述这种中止的连接依赖于不同的实现。源自Berkeley的实现完全在内核中处理中止的连接,服务器进程根本看不到。大多SVR 4实现返回一个错误给服务器进程,作为accept函数的返回结果,这些SVR 4实现返回一个EPROTO(protocol error,协议错误)作为errno值,而POSIX指出返回的errno值必须是ECONNABORTED(software caused connection abort,软件引起的连接中止)。POSIX作出修改的理由在于,流子系统中发生某些致命的协议相关事件时,也会返回EPROTO,要是对于由客户引起的一个已建立连接的非致命终止也返回同样的错误,服务器就不知道该不该再次调用accept,换成ECONNABORTED错误后,服务器就可以忽略它,再次调用accept就行。

源自Berkeley的内核不把该错误传递给进程的做法的步骤在TCPv2中有阐述。

现在我们启动客户和服务器,然后杀死服务器子进程,这是在模拟服务器进程崩溃的情形,然后查看客户将发生什么,步骤如下:
1.在同一主机上启动客户和服务器,并在客户上键入一行文本,验证一切正常。
2.找到服务器子进程的进程ID,执行kill命令杀死它。作为进程终止处理的部分工作,子进程中所有打开着的描述符都被关闭,这导致向客户发送一个FIN,而客户TCP会响应一个ACK。这是TCP连接终止工作的前半部分。
3.SIGCHLD信号被发送给服务器父进程,并得到处理。
4.客户TCP接收来自服务器TCP的FIN并响应一个ACK,但客户进程还阻塞在fgets调用上,等待从终端接收一行文本。
5.此时,在另一个窗口上运行stat命令,观察套接字状态,我们会发现TCP连接终止序列的前半部分已经完成:
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第14张图片
6.在客户上再键入一行文本:
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第15张图片
如上图,我们键入another line时,str_cli函数调用writen,客户TCP接着把数据发送给服务器,TCP允许这么做,因为客户TCP接收到FIN只是表示服务器进程不再发送任何数据,FIN的接收并没有告知客户TCP服务器进程已终止(但本例中,它确实是终止了)。当服务器TCP接收到来自客户的数据时,既然先前打开那个套接字的进程已经终止,于是响应一个RST,该RST可通过tcpdump观察到。此时,连接被终止,连接终止序列的最后两个分节不再发送,并且该RST使得服务器端(主动执行连接关闭的一端)不再经历TIME_WAIT状态。
7.但客户进程看不到这个RST,因为它在调用writen后立即调用了readline,并且由于第2步中接收到的FIN,readline函数返回0(表示EOF),我们的客户未预期收到EOF,于是打印出错信息server terminated prematurely(服务器过早终止)并退出。
8.当客户终止时,它所有打开着的描述符都被关闭。

上述讨论还取决于客户调用readline既可能发生在服务器的RST被客户收到之前,也可能发生在收到之后,如果readline调用发生在收到RST之前(如上所述),那么结果是客户得到一个预期外的EOF,否则结果是由readline函数返回一个ECONNRESET(connection reset by peer,对方复位连接错误)。

上例的问题在于,当FIN到达套接字时,客户正阻塞在fgets调用上,客户实际上在应对两个描述符,即套接字和用户输入,它不能单纯阻塞在这两个源中某个特定源的输入上,而是应该阻塞在任何一个源的输入上,这正是select和poll函数的目的之一。

如果客户不理会readline函数返回的错误,继续写更多数据到服务器上,这种情况是可能的,如客户可能在读回数据前执行两次对服务器的写操作,而RST由第一次写操作引发时。当一个进程向某个已收到RST的套接字执行写操作时,内核向该进程发送一个SIGPIPE信号,该信号的默认行为是终止进程,因此进程必须捕获它以免被终止。不论该进程是捕获了该信号并从其信号处理函数返回,还是简单地忽略该信号,写操作都将返回EPIPE错误。

一个在usenet上的FAQ(Frequently Asked Question,经常问及的问题)是如何在第一次写操作时而非第二次写操作时捕获SIGPIPE信号,这是不可能的,按上述讨论,第一次写操作引发RST,第二次写引发SIGPIPE信号。写一个已接收了FIN的套接字没问题,但写一个已接收了RST的套接字则是一个错误。

为了看有了SIGPIPE信号会发生什么,我们修改客户程序:

void str_cli(FILE *fp, int sockfd) {
    char readline[MAXLINE], recvline[MAXLINE];
    while (Fgets(sendline, MAXLINE, fp) != NULL) {
        Writen(sockfd, sendline, 1);
        sleep(1);
        Writen(sockfd, sendline + 1, strlen(sendline) - 1);
        if (Readline(sockfd, recvline, MAXLINE) == 0) {
            err_quit("str_cli: server terminated prematurely");
        }
        Fputs(recvline, strout);
    }
}

我们做的修改就是调用writen两次,第一次把文本行数据的第1个字节写入套接字,暂停1秒后,第二次把同一文本行中剩余字节写入套接字,目的是让第一次writen调用引发一个RST,再让第二个writen调用产生SIGPIPE。
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第16张图片
我们启动客户,键入一行文本,看到它被正确回射后,在服务器主机上终止服务器子进程,接着键入另一行文本bye,结果是没有任何回射,而shell告诉我们客户进程因为SIGPIPE信号死亡了,当前台进程未执行core dump就死亡时,有些shell不显示任何信息。处理SIGPIPE的建议方法取决于它发生时应用进程想做什么,如果没有特殊的事情要做,则将信号处理办法直接设置为SIG_IGN,并且在写失败后查看errno是否是EPIPE错误,如果是则停止写入;如果信号出现时需采取特殊措施(可能需要在日志中登记),则要捕获该信号,以便在信号处理函数中执行所期望的动作,但如果使用了多个套接字,该信号的递交无法告诉我们哪个套接字出了错,如果我们确实需要知道哪个write出了错,需要在write函数失败后查看errno是否是EPIPE。

为了查看当服务器主机崩溃后会发生什么,我们必须在不同主机上运行客户和服务器,我们先启动服务器,再启动客户,接着在客户上键入一行文本以确认连接工作正常,然后从网络上断开服务器主机,然后在客户上键入另一行文本,这样同时也模拟了当客户发送数据时服务器主机不可达情形(即建立连接后某些中间路由器又不工作了)。步骤如下:
1.当服务器主机崩溃时,服务器进程不会在已有的网络连接上发出任何东西。这里我们假设的是主机崩溃,而不是执行命令关机。
2.在客户上键入一行文本,它由writen函数写入内核,再由客户TCP作为一个数据分节送出。客户随后阻塞于readline调用,等待回射应答。
3.用tcpdump观察网络会发现,客户TCP持续重传数据分节,试图从服务器上接收一个ACK。TCPv2给出了TCP重传的一个典型模式:源自Berkeley的实现重传该分节12次,共等待约9分钟才放弃重传。当客户TCP最后终于放弃时(假设客户TCP持续重传期间,服务器主机没有重新启动;或服务器主机没有崩溃过,且中间路由在客户TCP持续重传期间一直不可用),给客户进程返回一个错误,既然客户阻塞在readline调用上,该调用将返回一个错误。假设服务器主机已崩溃,从而对客户的数据分节没有响应,那么readline函数返回的错误是ETIMEDOUT;如果某个中间路由器判定服务器主机已不可达,该路由器从而响应一个destination unreachable(目的不可达)ICMP消息,那么readline函数返回的错误是EHOSTUNREACH或ENETUNREACH。

尽管客户最终会发现对端主机已崩溃或不可达,但有时我们需要比9分钟更快地检测出这种情况,此时可对readline调用设置一个超时。

以上讨论的情形只有在我们向服务器主机发送数据时才能检测出它已崩溃,如果我们想不主动发送数据就检测出服务器主机的崩溃,需要SO_KEEPALIVE套接字选项。

在以上讨论中,我们发送数据时,服务器主机仍处于崩溃状态,我们将模拟服务器主机在客户TCP持续重传期间重启。模拟这种情形的最简单方法是:先建立连接,再从网络上断开服务器主机,将它关机后再重启,最后把它重新连接到网络中(我们不想客户知道服务器主机的关机)。

假设没有使用SO_KEEPALIVE选项,所发生的的步骤如下:
1.启动服务器和客户,并在客户键入一行文本以确认连接已建立。
2.服务器主机崩溃并重启。
3.在客户上键入一行文本,它将作为一个TCP数据分节发送到服务器主机。
4.当服务器主机崩溃后重启时,它的TCP丢失了崩溃前所有连接信息,因此服务器TCP对所收到的来自客户的数据分节响应一个RST。
5.当客户TCP收到该RST时,客户正阻塞于readline调用,导致该调用返回ECONNRESET错误。

以上过程中,如果服务器进程在服务器主机重启后又启动了,那么服务器TCP在收到消息后还是会返回一个RST。

如果对客户而言检测服务器主机是否崩溃很重要,即使客户不主动发送数据也要能检测出来,就需要使用其他技术(如SO_KEEPALIVE套接字选项或某些客户/服务器心博函数)。

当服务器进程正在运行时,如果服务器被关机,如果是Unix系统被关机,init进程通常先给所有进程发送SIGTERM信号(该信号可被捕获),等待一段固定时间(通常在5到20秒之间),然后给所有仍在运行的进程发送SIGKILL信号(该信号不可被捕获)。这样给所有运行的进程一小段时间来清除和终止,如果我们捕获SIGTERM信号后没有终止,我们的服务器将由SIGKILL信号终止。当服务器子进程终止时,它的所有打开着的描述符都被关闭,随后发送FIN给客户。对于客户,需要使用select或poll函数,使服务器进程的终止一经发生(收到服务器的FIN),客户机就能检测到。

在TCP客户和服务器可以彼此通信前,每一端都得指定连接的套接字对:本地IP地址、本地端口、外地IP地址、外地端口,下图中使用粗圆点标出了这4个值:
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第17张图片
上图处于客户的角度,因为只调用了socket和connect,外地IP地址和外地端口在客户调用connect时指定,两个本地值通常由内核作为connect的一部分来选定。客户也可在调用connect前,通过调用bind来指定其中一个或全部两个本地值,但这种做法并不常见。

上图中客户的IP地址是基于路由选定的,它的前提是套接字尚未绑定某本地IP地址,当发送第一个SYN建立连接时,IP路由功能会查看目的IP地址(服务器的IP地址),搜索路由表来确定外出接口和下一跳,这个外出接口的主IP地址用作源IP地址。

客户建立连接后可用getsockname函数获取由内核指定的两个本地值。

下图是服务器角度的上图:
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第18张图片
本地端口(服务器的众所周知端口)由bind函数指定,bing函数中指定的服务器本地IP地址通常是通配IP地址,如果服务器在一个多宿主机上绑定通配IP地址,那么它可以在连接建立后通过getsockname函数获取本地IP地址。另外两个外地值由accept函数返回给服务器。如果服务器调用accept后又调用了exec,则可通过getpeername函数获取客户IP地址和端口号。

如上图,如果客户在connect调用中指定的服务器主机IP地址是与服务器右侧的数据链路关联的IP地址,会发生什么?假设服务器主机支持弱端系统模型,那么一切正常,目的IP地址是右端数据链路的IP地址的数据报,到达左端数据链路时也能被服务器主机接受。

修改服务器程序,它仍从客户读入一行文本,但新服务器期望该文本行包含由空格分开的两个整数,服务器将返回两个整数的和,只需要修改服务器的str_echo函数:

void str_echo(int sockfd) {
    long arg1, arg2;
    ssize_t n;
    char line[MAXLINE];

    for (; ; ) {
        if ((n = Readline(sockfd, line, MAXLINE)) == 0) {
            return;    /* connection closed by other end */
        }
        // 读取以如下内容为开头的串:由一个或多个空格、tab分隔的两个数字
        if (sscanf(line, "%ld%ld", &arg1, &arg2) == 2) {
            snprintf(line, sizeof(line), "%ld\n", arg1 + arg2);
        } else {
            snprintf(line, sizeof(line), "input error\n");
        }
        n = strlen(line);
        Writen(sockfd, line, n);
    }
}

不论客户和服务器的主机字节序如何,以上程序都正常工作。

现在将客户和服务器程序修改为通过套接字传递二进制结构,当这样的客户和服务器运行在主机字节序不同或所支持的长整数大小不一致的两个主机上时,不能正常工作。

还是求两数之和,如果我们将两个数字放在一个结构中,将结果放在另一个结构中:

struct args {
    long arg1;
    long arg2;
};

struct result {
    long sum;
};

为此我们需要修改客户的str_cli函数:

void str_cli(FILE *fp, int sockfd) {
    char sendlline[MAXLINE];
    struct args args;
    struct result result;
    
    while (Fgets(sendline, MAXLINE, fp) != NULL) {
        if (sscanf(sendline, "%ld%ld", &args.arg1, &args.arg2) != 2) {
            printf("invalid input: %s", sendline);
            continue;
        }
        Writen(sockfd, &args, sizeof(args));
        if (Readn(sockfd, &result, sizeof(result)) == 0) {
            err_quit("str_cli: server terminated prematurely");
        }
        printf("%ld\n", result.sum);
    }
}

同时还要修改服务器的str_echo函数:

void str_echo(int sockfd) {
    ssize_t n;
    struct args args;
    struct result result;
    for (; ; ) {
        if ((n = Readn(sockfd, &args, sizeof(args)) == 0) {
            return;    /* connection closed by other end */
        }
        result.sum = args.arg1 + args.arg2;
        Writen(sockfd, &result, sizeof(result));
    }
}

在具有相同体系结构的两个主机上运行客户和服务器程序,可以正常工作:
在这里插入图片描述
但如果在具有不同体系结构的两个主机上运行同样的客户和服务器程序,如服务器运行在SPARC架构的freebsd系统上(大端字节序),客户运行在Intel的linux系统上(小端字节序),则不能正常工作:
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第19张图片
在这里插入图片描述
问题在于客户以小端字节序格式通过套接字送出两个二进制整数,却被服务器解释成了大端字节序整数。我们看到这对客户和服务器对于正整数,看起来工作正常,但对于负整数则工作失常了。由于客户运行在小端字节序的主机上,32位的整数1按下图格式存放:
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第20张图片
上图4个字节按A、A+1、A+2、A+3的顺序通过套接字发送,大端字节序的服务器这样存放这4个字节:
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第21张图片
对于服务器来说,值0x01000000是16777216。类似地,由客户发送的整数2将被服务器解释成0x02000000,即33554432,这两个整数的和为50331648,即0x03000000,服务器把这个和发给客户后,客户将它解释为了3。

而32位的整数-22在客户的小端字节序主机上如下图所示,采用的是负数的二进制补码表示:
UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例_第22张图片
它在大端字节序的服务器上被解释成0xeaffffff,即-352321537;类似地,-77在大端字节序上被表示成0xb3ffffff,即-1275068417。服务器上这两个整数相加的结果为0x9efffffe,即-1627389954。这个大端字节序的和发送给客户后以小端字节序解释的值是0xfeffff9e,即-16777314。

如果我们在把两个数字传给服务器前对每个参数调用htonl,再让服务器做加法前对每个参数调用ntohl,则在32位机器上可以正常工作。虽然这两个函数中的l曾经表意long(长整数),但它们只能操作32位整数,在64位系统上一个长整数可能占64位,这两个函数就不能工作了。

如果客户运行在以32位存储长整数的主机上,而服务器在以64位存储长整数的主机上,则在上例中,服务器会永远阻塞在readn函数,因为客户发送的是2个32位值,而服务器等待的是2个64位值(通过sizeof函数获取args结构的大小为64位)。如果客户和服务器所在主机对调,则客户会发送两个64位值,但服务器只读入第一个64位值,并把它解释为2个32位值,第二个64位值仍在服务器套接字的接收缓存中,之后服务器向客户发送1个32位值,但客户会等待读入1个64位值,因此客户会永远阻塞在readn函数。

上例中存在3个问题:
1.不同实现以不同格式存储二进制数,最常见的格式是大端字节序和小端字节序。
2.不同实现在存储相同的C数据类型时可能存在差异,大多32位UNIX系统使用32位表示长整型,而64位系统使用64位表示同样的数据类型。对于short、int、long等整数类型,它们的大小不确定。
3.不同实现给结构打包的方式存在差异,取决于各种数据类型所用位数以及机器对齐限制。因此,通过套接字传二进制结构不明智。

解决数据格式问题的常用方法:
1.把所有数值数据作为文本串传递,这里假设客户和服务器具有相同字符集。
2.显示定义所支持数据类型的二进制格式(位数、大端或小端字节序),并以这样的格式在客户与服务器间传递所有数据。RPC软件包通常使用这种技术。

验证向收到RST的套接字继续写会产生SIGPIPE信号:编写一个SIGPIPE信号处理函数,它显示一条消息后就返回,调用connect前建立该信号处理函数,把服务器的端口号改为13,即daytime服务器,连接建立后,调用sleep睡眠2秒,然后调用write向套接字中写若干字节,之后再sleep2秒,再往套接字中write若干字节。第一次睡眠2秒用于让daytime服务器发送应答并关闭它的连接所在端,第一次write导致发送一个数据分节到服务器,服务器则响应以RST(因为daytime服务器已关闭了它的套接字),第二个sleep函数让客户收到服务器的RST,于是第二个write函数引发SIGPIPE信号,由于信号处理函数返回主控制流,write函数于是返回EPIPE错误。以下是引发SIGPIPE信号的客户进程代码:

#include "unp.h"

void sig_pipe(int signo) {
    printf("SIGPIPE received\n");
    return;
}

int main(int argc, char **argv) {
    int sockfd; 
    struct sockaddr_in servaddr;
    
    if (argc != 2) {
        err_quit("usage: tsigpipe ");
    }

    sockfd = Socket(AF_INET, SOCK_STREAM, 0);

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(13);    /* daytime server */
    Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);

    Signal(SIGPIPE, sig_pipe);

    Connect(sockfd, (SA *)&servaddr, sizeof(servaddr));

    sleep(2);
    Write(sockfd, "hello", 5);
    sleep(2);
    Write(sockfd, "world", 5);

    exit(0);
}

运行它:
在这里插入图片描述

你可能感兴趣的:(UNIX网络编程卷一(第三版),网络,tcp/ip,unix)