【unix网络编程第三版】阅读笔记(四):TCP客户/服务器实例

时间:2022-09-17 19:59:20

本篇博客主要记录一个完整的TCP客户/服务器实例的编写,以及从这个实例中引发的对僵死进程的处理等问题。

1. TCP客户/服务器功能需求

本实例完成以下功能:

(1) 客户从标准输入读入一行文本,并写给服务器

(2) 服务器从网络输入中读入这行文本,并回射给客户

(3) 客户从网路输入读入这行回射文本,并显示在标准输入

需要用到的函数:

(1) 套接字编程基本函数(socket,bind,listen,accept,connect,close等),完成套接字编程

(2) 标准I/O库函数fputs和fgets,完成输入和输出

(3) read,writen,readline函数,完成数据的传输

(4) fork函数,完成并行服务器的编写

2. TCP回射服务器程序清单

#include <unp.h>
#include <stdio.h>
void str_echo(int sockfd);//回射函数

int main(int argc , char **argv)
{
    int listenfd,connfd;
    pid_t pid;
    socklen_t clilen;
    struct sockaddr_in cliaddr,servaddr;
    //创建套接字
    listenfd = Socket(AF_INET,SOCK_STREAM,0);
    //初始化套接字地址
    bzero(&servaddr,sizeof(servaddr));
    servaddr.sin_port = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);//通配地址
    servaddr.sin_port = htons(SERV_PORT);
    //将本地协议地址赋值给套接字
    Bind(listenfd,(SA *) &servaddr,sizeof(servaddr));
    //监听
    Listen(listenfd,LISTENQ);
    while(1)
    {
        clilen = sizeof(cliaddr);//客户端套接字地址长度
        connfd = Accept(listenfd,(SA *)&cliaddr,&clilen);//接受客户端的信息
        if((pid = Fork())==0)//调用子进程去处理与客户的通信
        {
            Close(listenfd);//listenfd套接字的引用技术减1
            str_echo(connfd);//消息回射函数
            exit(0);
        }
        Close(connfd);//connfd客户套接字引用计数减1
    }
}
void str_echo(int sockfd)
{
    ssize_t n;
    char buf[MAXLINE];
again:
    while((n=read(sockfd,buf,MAXLINE))>0)//读取客户传来的消息
    {
        Write(sockfd,buf,n);//回传给客户
    }
    if(n<0 && errno == EINTR)
        goto again;
    else if(n<0)
        err_sys("str_echo:read error");
}

3. 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);
    //初始化套接地地址
    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);//客户处理函数
    exit(0);
}
void str_cli(FILE *fp,int sockfd)
{
    char sendline[MAXLINE], recvline[MAXLINE];
    //获取客户端标准输入的字节
    while(Fgets(sendline,MAXLINE,fp)!=NULL)
    {
        //将标准输入读入的字节传给服务器
        Write(sockfd,sendline,strlen(sendline));
        //读取服务器回射的字节
        if(Readline(sockfd,recvline,MAXLINE)==0)
        {
            err_quit("strcli:server terminated prematurely");
        }
        //将获取的服务器回射字节打印到标准输出
        Fputs(recvline,stdout);
    }
}

4. 正常启动

写完上述代码后,首先运行服务器端程序,正常启动后,它调用socket,bind,listen和accept,并阻塞于accept调用。让我们运行netstat程序来检查服务器监听套接字的状态。

~$netstat -a
Proto Recv-Q Send-Q Local Address           Foreign Address         State
tcp        0      0 *:9877                  *:*                     LISTEN 

这个正是该服务器建立起的套接字,*:9877表示一个为0的IP地址(INADDR_ANY,通配地址),然后内核分配的9877端口号。此套接字处于LISTEN状态,即阻塞于accept调用,等待客户连接。

接着,我们启动客户端程序,并指定服务器主机的IP地址为127.0.0.1,

# ./tcpcli 127.0.0.1

客户端依次调用socket,connect,后者引起TCP的三次握手过程,当三次握手完成后,客户中的connect和服务器的accept均返回,连接于是建立。接着发生以下步骤:

(1) 客户调用str_cli函数,该函数阻塞于fgets调用,然后等待客户输入一行文本

(2) 当服务器的accept返回时,服务器接着调用fork函数,再由子进程调用str_echo函数。该函数调用readline,readline调用read,而read在等待客户送入一行文本期间阻塞

(3) 另一方面,服务器父进程再次调用accept并阻塞,等待下一个客户连接。

此时的网络状态如下:

Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address           Foreign Address         State
tcp        0      0 localhost:56490         localhost:9877          ESTABLISHED
tcp        0      0 localhost:9877          localhost:56490         ESTABLISHED
tcp        0      0 *:9877                  *:*                     LISTEN 

如上,列出了三个套接字状态,第一个对应于客户进程的套接字,它的本地端口号为56490;第二个对应于服务器子进程的套接字,本地端口号为9877;第三个为服务器父进程则继续监听客户。

5. 正常终止

至此,连接已经建立,在客户端键入一行文本后,都会被服务器回射回来,然后用标准输出。如下:

root@zc-Inspiron-N4010:/home/zc/Documents/unp# ./tcpcli 127.0.0.1
hello,world
hello,world
goodbye
goodbye
nice to meet you
nice to meet you

接着我们键入ctrl+D,代表终端EOF字符,以终止客户。此刻调用netstat会得到如下一行:

tcp        0      0 localhost:56490         localhost:9877          TIME_WAIT
//监听套接字
tcp        0      0 *:9877                  *:*                     LISTEN

之前连接的客户进入TIME_WAIT状态,而服务器还在等待下一个客户的连接。

下面为正常终止客户和服务器的步骤:

(1) 当键入EOF字符时,fgets返回一个空指针,于是str_cli函数返回

(2) 当str_cli返回到客户的main函数,main通过exit终止

(3) 进程终止处理的部分工作时关闭所有打开的描述符,因此客户打开的套接字由内核关闭。这会导致客户TCP发送一个FIN给服务器,服务器TCP则以ACP响应,至此,服务器套接字进入CLOSE_WAIT状态,客户套接字处于FIN_WAIT_2状态

(4) 当服务器接受FIN时,服务器子进程阻塞于readline调用,于是readline返回0,str_echo函数返回服务器子进程的main函数

(5) 服务器子进程通过调用exit来终止

(6) 服务器打开的所有描述符随之关闭,由子进程来关闭已连接套接字会引发TCP连接终止序列的最后两个分节:一个从服务器到客户的FIN,一个从客户到服务器的ACK。至此,连接完全终止,客户套接字进入TIME_WAIT状态。

(7) 进程终止处理的另一部分内容:在服务器子进程终止时,给父进程发送一个SIGCHLD信号。本例中父进程未对此信号进程处理,子进程于是进入僵死状态。如下,所示

# ps -t pts/3 -o pid,ppid,tty,stat,args,wchan
 6057  6055 pts/3    Z   [tcpsrv] <defunct>          exit

6. POSIX信号处理

信号是告知某个进程发生了某个事件的通知,有时也叫做软件中断。信号通常时异步发生的。

信号可以:(1) 由一个进程发给另一个进程 (2)由内核发给某个进程

每个信号都有一个与之关联的处置(disposition)。通常有如下三种选择:

(1) 通过调用信号处理函数,用来捕获特定的信号。有两个信号SIGKILL和SIGSTOP。

(2) 通过设置某个信号为SIG_IGN来忽略它。SIGKILL和SIGSTOP不能被忽略

(3) 通过设置某个信号为SIG_DFL来启用它的默认处置。

6.1 signal函数

建立信号处置的POSIX方法就是调用sigaction函数,不过这有点复杂,因为该函数的参数之一是我们必须分配并填写结构。简单的方法时调用signal函数,其第一个参数是信号名,第二个参数或为指向函数的指针,或为常值SIG_IGN或SIG_DFL。调用如下:

signal(SIGCHLD,SIG_DFL);//代表捕捉SIGCHLD信号并启用默认处置。

6.2 POSIX信号语义

符合POSIX的系统上的信号处理总结为一下几点:

(1) 一旦安装了信号处理函数,它便一直安装着。

(2) 在一个信号处理函数运行期间,正被递交的信号时阻塞的,而且,安装处理函数时在传递给sigaction函数的sa_mask信号集中指定的任何额外信号也被阻塞。

(3) 如果一个信号在被阻塞期间产生了一次或者多次,那么该信号被解阻塞之后通常只递交一次。Unix信号默认时不排队的

7. 处理SIGCHLD信号

设置僵死(zombie)状态的目的是维护子进程的信息,以便父进程在以后某个时候获取,这些信息包括子进程的进程ID,终止状态以及资源利用信息(CPU时间,内存是用量等等)。

如果一个进程终止,而该进程有子进程处于僵死状态,那么它的所有僵死子进程的父进程ID都被重置为1(init进程),继承这些子进程的init进程将清理他们。

7.1 处理僵死进程

留存僵死进程会占用内核中的空间,最终可能导致我们耗尽进程资源。

无论何时,我们fork子进程都得wait它们!

于是我们必须调用信号处理函数(在LISTEN之后添加如下函数),并在函数体内调用wait。

Signal(SIGCHLD,sig_child); //第二个参数为我们定义的信号处理函数的函数指针
void sig_child(int signo)
{
    pid_t pid ;
    int stat;
    pid = wait(&stat);//调用wait函数
    printf("child %d terminated\n", pid);
    return;
}

运行以上程序得到输出:

child 6625 terminated

具体步骤如下:

(1) 当键入EOF字符来终止客户,客户TCP发送一个FIN给服务器,服务器响应以一个ACK

(2) 收到客户的FIN导致服务器TCP递送一个EOF给子进程阻塞中的readline,从而子进程终止

(3) 当SIGCHLD信号递交时,父进程阻塞于accept函数,sig_chld函数执行,其wait调用取到子进程的PID和终止状态,随后printf调用,最后返回

(4) 该信号在父进程阻塞于慢系统调用accept时由父进程捕获的,内核就会使accept返回一个EINTR错误(被中断的系统调用),而父进程不出理该错误(源代码中未处理该错误),于是中止!

7.2 处理被中断的系统调用

慢系统调用适用于那些可能永远阻塞的系统调用。永远阻塞的系统调用是指调用永远无法返回,多数网络支持函数都属于这一类。如accept函数,如果没有客户连接到服务器,那么服务器的accept调用就没有返回;再比如read函数,如果客户从未发送过一行要求服务器回射的文本,那么服务器的read调用将永远不会返回,等等

慢系统调用可以被永久阻塞,包括一下几个类别(此处摘选至APUE):

(1)读写‘慢’设备(包括pipe,终端设备,网络连接等)。读时,数据不存在,需要等待;写时,缓冲区满或其他原因,需要等待。读写磁盘文件一般不会阻塞。

(2)当打开某些特殊文件时,需要等待某些条件,才能打开。例如:打开中断设备时,需要等到连接设备的modem响应才能完成。

(3)pause和wait函数。pause函数使调用进程睡眠,直到捕获到一个信号。wait等待子进程终止。

(4)某些ioctl操作。

(5)某些IPC操作。

适用于慢系统调用的基本规则:当阻塞于某个慢系统调用的一个进程捕获某个信号且相应信号处理函数返回时,该系统调用可能返回一个EINTR错误。

例如:

for(;;){
    clilen  = sizeof(cliaddr);
    if((connfd = accept(listenfd,(SA*)&addcli,&clilen))<0){
        if(errno == EINTR){//返回一个EINTR错误,重启被中断的系统调用
            continue;//继续for循环
        }
        else
            err_sys("accepted error");
    }
}

对于accept以及诸如read,write,select和open之类的函数来说,可以重启被中断的系统调用。

不过,connect函数不能重启,如果该函数返回EINTR,我们不能再次调用它,否则将立即返回一个错误。当connect呗一个捕获的信号中断而且不自动重启时,我们必须调用select来等待连接完成。

8. wait和waitpid函数

调用wait函数来处理已终止的子进程,函数原型如下:

#include <sys/wait.h>
//此函数均返回两个值:已终止子进程的进程ID号,以及通过指针statloc返回的子进程中止状态。
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid , int *statloc , int options);//若成功则返回子进程ID,若出错则返回0或-1

需要说明一点:如果调用wait的进程没有已终止的子进程,不过有一个或者多个子进程仍在执行,那么wait将阻塞到现有子进程的第一个终止为止。

8.1 wait和waitpid的区别

由于Unix信号一般时不排队的,所以当多个客户同时终止连接时,就会产生多个僵死进程,而wait函数只能处理其中一个。

这时候,就需要调用waitpid函数,我们采用在一个循环中调用waitpid,以获取所有已终止子进程的状态。如下代码:

void sig_child(int signo)
{
    pid_t pid ;
    int stat;
    //第一个参数为-1:表示等到第一个终止的子进程
    //第三个参数指定为WHOCHANG,它告知waitpid在有尚未终止的子进程在运行时不要阻塞
    while((pid = waitpid(-1, &stat , WHOHANG))>0)  {//调用waitpid函数
        printf("child %d terminated\n", pid);
    }
    return;
}

8.2 三种情况的总结

(1) 当fork子进程时,必须捕获SIGCHLD信号

(2) 当捕获信号时,必须处理中断的系统调用

(3) SIGCHLD的信号处理函数必须正确编写,应使用waitpid函数以免留下僵死进程。

到目前为止,完整的客户端程序如下:

#include <unp.h>
#include <stdio.h>
void str_echo(int sockfd);//回射函数
void sig_chld(int signo);
int main(int argc , char **argv)
{
    int listenfd,connfd;
    pid_t pid;
    socklen_t clilen;
    struct sockaddr_in cliaddr,servaddr;
    //创建套接字
    listenfd = Socket(AF_INET,SOCK_STREAM,0);
    //初始化套接字地址
    bzero(&servaddr,sizeof(servaddr));
    servaddr.sin_port = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);//通配地址
    servaddr.sin_port = htons(SERV_PORT);
    //将本地协议地址赋值给套接字
    Bind(listenfd,(SA *) &servaddr,sizeof(servaddr));
    //监听
    Listen(listenfd,LISTENQ);
    Signal(SIGCHLD,sig_chld);
    while(1)
    {
        clilen = sizeof(cliaddr);//客户端套接字地址长度
        if((connfd = accept(listenfd,(SA*)&addcli,&clilen))<0){//接受客户端的消息
        if(errno == EINTR){//当返回一个EINTR错误
            continue;//继续for循环,重启被中断的系统调用
        }
        else
            err_sys("accepted error");
        if((pid = Fork())==0)//调用子进程去处理与客户的通信
        {
            Close(listenfd);//listenfd套接字的引用技术减1
            str_echo(connfd);//消息回射函数
            exit(0);
        }
        Close(connfd);//connfd客户套接字引用计数减1
    }
}
void sig_child(int signo)
{
    pid_t pid ;
    int stat;
    //第一个参数为-1:表示等到第一个终止的子进程
    //第三个参数指定为WHOCHANG,它告知waitpid在有尚未终止的子进程在运行时不要阻塞
    while((pid = waitpid(-1, &stat , WHOHANG))>0)  {//调用waitpid函数
        printf("child %d terminated\n", pid);
    }
    return;
}
void str_echo(int sockfd)
{
    ssize_t n;
    char buf[MAXLINE];
again:
    while((n=read(sockfd,buf,MAXLINE))>0)//读取客户传来的消息
    {
        Write(sockfd,buf,n);//回传回去
    }
    if(n<0 && errno == EINTR)
        goto again;
    else if(n<0)
        err_sys("str_echo:read error");
}

9. 服务器进程终止

本节讨论当服务器子进程意外崩溃的情况下,客户进程会发生什么。

(1) 首先正常启动服务器和客户程序,并测试一行文本可以由服务器回射给客户。

(2) 然后找到服务器子进程的进程ID,使用如下命令kill掉sudo kill XXXXX,此时,子进程中左右打开的套接字描述符都被关闭,这就导致向客户发出一个FIN,而客户TCP则响应一个ACK。

(3) SIGCHLD信号被发送给服务器父进程,并得到正确处理,即处理僵死进程

(4) 客户此时正阻塞在fgets调用上,等待从终端接受一行文本,当我们再键入一行时,会出现如下现象:

root@zc-Inspiron-N4010:/home/zc/Documents/unp# ./tcpcli 127.0.0.1      //启动客户
hello        //测试
hello        //正常回射
            //此处找到服务器子进程的进程ID,执行sudo kill XXXX杀死该进程
line        //再键入一行文本
strcli:server terminated prematurely //出错信息:服务器过早终止

(5) 我们可以分析,当键入最后一行时,str_cli调用writen,客户TCP接着把数据发送给服务器,之所以可以这么做,是因为客户TCP接收到FIN只是表示服务器进程已关闭了连接的服务器端,从而不再往其中发送数据,FIN并没有告知客户TCP服务器进程已经中止。

当服务器接收到来自客户的数据时,既然先前打开的那个套接字已经关闭,就返回一个RST。

(6) 然后客户看不到这个RST,于是继续调用readline,并且由于之前接受的FIN,所调用的readline立即返回0,客户此时并未预期收到EOF,于是弹出出错消息:server terminated prematurely服务器过早终止

(7) 当客户终止时,它所有打开的描述符都被关闭。

10. SIGPIPE信号

当客户不理会readline函数返回的错误,反而写入更多的数据到服务器上,如客户可能读回任何数据之前执行两次针对服务器的写操作,而RST是由其中一个写操作引发的。此时会发生什么?

当一个进程向某个已收到RST的套接字执行写操作时,内核向该进程发送一个SIGPIPE信号。该信号的默认行为是终止进程。

不管该进程时捕获了该信号并从信号处理函数返回,还是简单的忽略,第二次写操作都将返回EPIPE错误。

测试代码:

void str_cli(FILE *fp,int sockfd)
{
    char sendline[MAXLINE], recvline[MAXLINE];
    //获取客户端标准输入的字节
    while(Fgets(sendline,MAXLINE,fp)!=NULL)
    {
        Write(sockfd,sendline,1);
        //将标准输入读入的字节传给服务器
        sleep(1);
        Write(sockfd,sendline+1,strlen(sendline)-1);//连续写两次来测试是否产生SIGPIPE信号
        //读取服务器回射的字节
        if(Readline(sockfd,recvline,MAXLINE)==0)
        {
            err_quit("strcli:server terminated prematurely");
        }
        //将获取的服务器回射字节打印到标准输出
        Fputs(recvline,stdout);
    }
}
/**********测试输出**************/
unp# ./tcpcli 127.0.0.1
hello  //键入第一行文本
hello        //正常回射
             //此处杀死服务器子进程
bye          //然后继续键入此行,发现并没有任何输出
Broken pipe  //此行由shell显示,告诉我们客户因为SIGPIPE信号而死亡了

11. 服务器主机崩溃

本小节讨论服务器主机崩溃的情况下,客户端会发生什么

当服务器主机崩溃(注意不是此处不是服务器主机关机),客户端并不知情,所有客户端端连续发送数据,试图从服务器端接受一个ACK。

这时,可以看出TCP的重传机制,客户端总共重传该数据12次,共等待大约9分钟才放弃重传。当客户TCP最后终于放弃时,服务器主机并没有重新启动,或者如果时服务器主机未崩溃但是从网络上不可达,则返回ETIMEOUT错误;然而如果时中间某个路由器判定服务器主机不可达,从而响应一个“destination unreachable”目的地不可达ICMP信息,则返回的错误时EHOSTUNREACH或者ENETUNREACH。

12. 服务器主机崩溃后重启

当服务器崩溃后重启(9分钟内),它的TCP丢失了崩溃前的所有连接信息,因此服务器TCP对于所收到的来自客户的数据分节响应一个RST。

当客户TCP收到该RST时,客户正阻塞与readline调用,导致该调用返回ECONNRESET错误。

13. 服务器关机

Unix系统关机时,init进程通常先给所有进程发送SIGTERM信号,等待一段时间后,给所有仍在运行的进程发送SIGKILL信号,这么做留给所有运行的进程一小段时间来清除和终止。

当服务器子进程终止时,它所有打开着的描述符都被关闭,随后发生和服务器主机崩溃一样的效果。

14. 客户和服务器传送的数据类型

在客户和服务器间传送文本串,不论客户和服务器主机的字节序如何,都能很好的工作。

在客户和服务器间传送二进制结构,如下结构体:

struct args{
  long arg1;
  long arg2;
};
struct result{
  long sum;
};

socket不提倡传送这种二进制结构,因为如果客户或者服务器端某一方不支持这种数据结构,或字节序不一样,支持的long的字节不一样,这都会导致异常的现象

所以在socket传输时,应当把所有的二进制结构转换成字符型文本串,再进行传输。

或者,还有一种解决办法:显示的定义所支持的数据类型的二进制格式(位数,大端或小端字节序),并以这样的格式在客户与服务器之间传递所有数据。