UNIX网络编程(UNP) 第五章学习笔记

导读

为了避免大家无法理解这一章的内容,我们还是有必要理一理我们在做什么。
首先,我们完成了一个简单的,可以反射内容的服务器,也就是说客户端发送了什么,服务端就返回什么。
虽然功能很简单,但是我们醉翁之意不在酒,我们试图想要解释的是,一个健壮的服务器,需要考虑到什么东西?
首先出现的问题就是僵尸进程,我们引入了信号处理函数提供处理接口,我们使用非阻塞的waitpid来解决信号丢失的问题。
然后因为我们要处理信号,被阻塞的系统调用(accept)就会被中断,我们需要提供机制来允许重新调用accept,这就要求我们需要判断错误是否是EINTR来恰当的重启。
在完成上述的步骤之后,我们的服务器已经能够正确的处理僵尸进程了,但是是否就是完备的呢,我们开始考虑到网络中可能出现的各种情况。
这些情况包括客户端在accept之前请求终止连接,或者客户端向收到RST的套接字发送数据,当然更多的出现在服务端,包括服务端进程关闭,主机崩溃,主机崩溃后重启,主机正常关闭,我们指出了每一种情况下可能需要考虑的问题并且提供了一定的解决检查思路

代码(带注释)

// server
#include "../unp.h"
int main(int argc, char **argv)
{
    int listenfd,connfd;
    pid_t child_pid;
    socklen_t clilen;
    struct sockaddr_in cliaddr,servaddr;
    
    listenfd = socket(AF_INET, SOCK_STREAM, 0);//表明我们生成的套接字是IPv4TCP协议
    
    // 下面主要是配置server address
    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_port = htons(8080);// 监听套接字端口应该是周知端口,这个端口是自行定的
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY); //表示我们随便IP地址
    servaddr.sin_family = AF_INET; //使用IPv4
 
    bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)); //将套接字和指定地址绑定
    listen(listenfd, 1024); // 这里1024表示的是backlog的长度,是自行指定的
    for (;;)
    {
        clilen=sizeof(cliaddr);
        connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen); //这里是阻塞监听,cliaddr和clilen其实我们没有用到,我们可以用NULL代替
        if ((child_pid=fork())== 0)
        {
            close(listenfd);
            str_echo(connfd);
            exit(0); //这里因为我们子进程会被关闭,所以不需要显式close
        }
        close(connfd);
    }
}
void str_echo(int fd)
{
    ssize_t n;
    char buf[MAXLINE];
    //下面的read会阻塞读取套接字内容,注意如果客户端关闭了套接字,read会读取到0,然后根据条件就自动退出来了,read不保证会读取到一个完整一行(事实上应该采用readline,就是多次读取然后找换行符)
    again:
    while ((n=read(fd,buf,sizeof(buf)))>0)
        writen(fd,buf,n);// 回射,写回去
    if (n<0 && errno==EINTR)
        goto again; //被中断就重启
    else if (n<0)
        err_sys("str_echo:read error");

}
// client
#include "../unp.h"
int main(int argc, char **argv)
{
    int fd;
    struct sockaddr_in servaddr;
    if (argc != 2)
        err_quit("usage: client <hostname/IPaddress> <service/port#>");
	//下面是配置连接server端的server address
    fd = socket(AF_INET, SOCK_STREAM, 0);
    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_port = htons(8080);//这里需要跟server端设置端口一致
    servaddr.sin_family = AF_INET;
    inet_pton(AF_INET, argv[1], &servaddr.sin_addr);//这里是在基于第一个参数,转换成网络字节序数字
    
    Connect(fd, (struct sockaddr *)&servaddr, sizeof(servaddr));
    str_cli(stdin, fd);
    exit(0);
}
void str_cli(FILE *fp, int sockfd)
{
    char sendline[MAXLINE],recvline[MAXLINE];
    
    while (Fgets(sendline,MAXLINE,fp)!=NULL){//Fgets会阻塞并且从fp(这里就是stdin)获取,文件结束或者错误的时候,fgets会返回NULL
        
        writen(sockfd,sendline,strlen(sendline));//就是往套接字写内容了
        if (Readline(sockfd,recvline,MAXLINE)==0) // 阻塞式读取,注意这里是读取一行
            err_quit("str_cli:server terminated prematurely");
        Fputs(recvline,stdout);
    }

}

状态分析

事实上上述的代码非常的清晰,不过以小见大,我们可以从中探索怎么启动,怎么终止,发生错误的时候会发生什么,为此我们不妨从语句中出发,观察每一步server的状态变化

时间 客户端发生了什么? 服务端发生了什么 服务端状态 客户端状态
1 调用socket,bind,listen,accept后阻塞在accept Listen
2 调用socket,connect,后者触发三次握手 三次握手完成建立连接,accept解除则色 解除阻塞状态 连接
3 调用fgets并阻塞 调用fork,子进程调用str_echo,然后子进程阻塞在read函数调用,而父进程经过循环继续阻塞在accept 子进程阻塞等待读取,父进程阻塞accept 阻塞等待输入
4 输入一行数据,传出socket,然后阻塞等待套接字结果,接着阻塞在fgets 子进程获取到read信息,试图阻塞写出,父进程继续阻塞在accept 子进程阻塞等待读取,父进程阻塞accept 阻塞等待输入
5 键入EOF,fgets返回NULL因此退出循环,str_cli返回,main函数结束
6 进程结束,关闭所有打开的文件描述符,对套接字会发送FIN报文开始关闭流程 返回ACK,套接字进入CLOSE_WAIT状态 发送FIN报文,接收到ACK,进入FIN_WAIT2状态
7 子进程read获取到FIN报文,read函数返回0,退出循环,回到主函数,子进程exit,同样触发关闭套接字流程 发送FIN报文,并等待接收到ACK报文 收到FIN报文,发送ACK报文,进入TIME_WAIT状态

上述是我们试图画出的时序图,当两者并列的时候,这意味着同时在进行,当然,我们有必要指出,在上图时序3触发的顺序中,客户端会比服务端快一点,因为客户端发送第三次ack之后connect就返回因此执行,而服务端需要等到ack到达accept才会执行,因此客户端会快1/2RTT左右

僵死进程

我们可以观察到,在接收到eof之后,子进程退出循环并最终触发exit(0),这是否意味着子进程已经彻底消失了呢?

如果我们使用ps -o pid,ppid,tty,stat,args,wchan观察server,我们会发现,子进程依然在,只是其状态(stat)变成了Z,Z表示的僵死进程,也就是说子进程在调用exit(0)之后进入了僵死状态。

可能会有人好奇,为什么不是正常的退出而是还要保留僵死状态呢?难道不会占用内存吗?

答案是父进程也许会在之后,希望访问子进程的进程ID,终止状态和资源利用信息(包括cpu时间,内存使用量等),而僵死进程就提供了这么个接口来访问。

当然另外一个担心油然而生,如果父进程技术了,这些僵死的子进程会得到处理吗?会怎么被处理?

显然,操作系统的设计者也考虑到这一点,因此设置成,如果父进程结束,而子进程僵死,那么就会设置僵死的子进程的ppid(父进程pid)为1(init进程),相当于init进程接管了这些僵死子进程,而init进程会清理掉他们(具体来说,是通过wait函数)。

不过即便如此,如果我们不能有效的处理僵死进程,像上述的server每次触发一次请求就多一个僵死进程的话,我们可以想想,很快内存就会不够用了,那么我们可以怎么去处理他们呢?

也许我们也可以用init进程的实现方法,用wait的方法来清理?

当然是可以的,但是问题出现了?我们怎么知道子进程已经完成任务退出了呢?

于是引入了我们的下一个议题-信号处理

信号与信号处理

子进程终止时,会给父进程发送SIGCHLD信号,但是UNIX默认是忽略该信号,而我们也没有捕获信号,于是子进程进入僵死状态

所谓的信号,就是告知某个进程发生了某事的通知,他可以发生在进程和进程之间(甚至是自己),也可以发生在内核与进程之间,既然有通知,那自然要有处理方法,我们可以称之为行为或者处置,我们有三种选择方式来处理信号

  1. 我们可以提供一个函数,特定信号发生的时候就会被调用

    一般来说我们可以使用sigaction函数来指定信号处理函数,该函数应该长这样void handler(int signo),注意,我们不能捕获SIGKILL和SIGSTOP

  2. 我们可以设置忽略该信号(SIG_IGN),SIGKILL和SIGSTOP不能被忽略

  3. 我们可以设置SIG_DFL来默认处理信号,一般来说就是终止进程,有些信号可能会在工作目录产生内存映像,而我们子进程结束发送的SIGCHLD则是默认忽略,同样的还有SIGURG

signal函数

在设置上,我们有两种选择,一个是posix明确了信号语义的sigaction,这东西主要问题是不方便,因为你还需要配置一些结构,另一个选择是signal函数原型如下

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

这看起来可能有点过于复杂,于是我将其拆开来理解

signo func 返回值
int, 整数表示的信号 void(int)的指针 返回void(int)的指针

这个函数很好理解,第二个参数除了指定函数以外,也可以是SIG_IGN或者SIG_DFL,但是缺点就在于,signal函数历史悠久,很多厂商对信号的处理方式不一致不像sigaction一样统一,所以我们不妨自己搭建一个简易版本的signal函数封装sigaction

#include "../unp.h"
typedef void Sigfunc(int);
Sigfunc *signal(int signo, Sigfunc *func)
{
    struct sigaction act, oact;
    act.__sigaction_u.__sa_handler = func;
    // sa_mask是用来指定说当这个信号处理函数被执行的时候,
    // 有哪些信号被阻塞(就是不会递交给进程从而触发信号处理),
    // POSIX保证了捕获的信号会被阻塞
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;
    if (signo == SIGALRM)
    {
        //早期的系统默认是自动重启,但是提供了
        //SA_INTERRUPT来允许停止这种默认,
        //所以我们可以设置反操作
#ifdef SA_INTERRUPT
        act.sa_flags |= SA_INTERRUPT;
#endif
    }
    else
    {
#ifdef SA_RESTART
        // Restart是可选的,如果设置了该标志,那么
        // 因为该信号而中断的系统调用会被内核自动重启
        // 之所以设置是“不在SIGALRM”的情况下触发,
        // 是因为SIGALRM一般发生在IO处理超时的情况下
        // 这个时候最好还是让信号中断掉阻塞的IO系统调用
        act.sa_flags |= SA_RESTART;
#endif
    }
    if (sigaction(signo, &act, &oact) < 0)
        return (SIG_ERR);
    return (oact.__sigaction_u.__sa_handler);
}

我们可以总结一下posix下信号处理的语义:

  1. 信号处理函数的装载的是永久的(早期可能触发一次就拆除)
  2. 在信号处理函数执行期间,正被递交的信号(就是触发执行的信号)是被阻塞的,除此之外,sa_mask中指定的信号也会被阻塞
  3. 如果信号在被阻塞递交了多次(就是触发了多次该信号),那么信号被解阻塞之后一般只会递交一次(等于说多次变一次),也就是说默认不排队,但是也可以定义排队的可靠信号
  4. 我们可以用sigprocmask来选择性阻塞或者接阻塞一组信号,这样可以在进入临界区的时候,防止捕获信号来保护代码(比如说停止捕获时钟中断来避免轮转)
处理SIGCHLD信号

上面我们提到了我们需要处理僵尸进程,那么好办法就是利用子进程结束运行时候发出的SIGCHLD信号,使用信号处理函数去清理僵尸进程,具体代码如下

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

如何处理被中断的系统调用

我们如果使用上述的代码,根据模型我们知道,当子进程结束的时候,父进程正在阻塞于accept系统调用,然后sig_chld函数被调用,结束之后,如果我们使用的是自带的signal函数而不是我们写的设置了Restart的signal函数,被中断的系统调用accept会返回EINTR错误,如果我们使用的是UNP提供的Accept包裹函数,该函数会在遇到错误的时候退出,于是我们就结束了服务器进程

但是这显然不是很多时候我们需要的选项,一般而言,我们希望的是在中断之后能够回到自行调用,我们可以用之前提到的标志位SA_RESTART,但是该标志是可选的,而且不同系统支持度不一样,比如有些系统从不重启select,有些实现也不accept和recvfrom

一般来说我们可以改造成下面的模式

// 下面是原先的代码
for (;;)
{
    connfd=accept(listenfd,(struct sockaddr*)&cliaddr,sizeof(cliaddr));//注,这里我们没有用Accept包裹函数,accept可能会返回负值(指示错误)
    if (fork()!=0){
        ...
    }
}

//下面是更新后的代码
for (;;)
{
    if ( (connfd=accept(listenfd,(struct sockaddr*)&cliaddr,sizeof(cliaddr)))<0){//检测到错误
        if (errno==EINTR)
            continue;//检测到被中断,则continue重新开始
        else 
            err_sys("accept error");
    }
    if (fork()!=0){
        ...
    }
}

我们要注意!connect函数是不能被这种方式重启的,如果connect返回EINTR,我们就不能在调用connect,否则会立刻触发错误,我们应该用select来处理这种情况

wait和waitpid函数

函数定义
pid_t   wait(int *statloc); //返回值是已终止子进程的进程ID号,以及statloc所代表的终止状态(正常结束,信号杀死,作业控制停止)
pid_t   waitpid(pid_t pid, int *statloc, int options);//pid指定了我们等待的子进程号,-1表示等待第一个终止的子进程,options可以指定一些选项,如“WNOHANG”表示没有待终结的子进程的时候就不要阻塞等待了
他们的区别在于哪里?

waitpid提供了更多的option,因此能够处理wait无法解决的问题:信号丢失

我们不妨假想这么个情景,假设在一瞬间,若干个客户端发送FIN报文要求断开连接,然后子进程就陆陆续续的退出并且发出SIGCHLD信号,但是信号时不排队的,也就是说可能只会触发一次handler,如果客户端和服务端是两台机子,那么可能触发两次(一次触发,然后函数运行时又收到若干个信号),总而言之,函数被调用几次是完全不确定的,在这个时候,waitpid提供的非阻塞功能WNOHANG就派上用场了,为什么呢?

我们不妨对比使用waitpid和wait函数的函数差异

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

while的好处就在于,不过函数被调用了是不是一次或者多次,只要返回值>0表示有未终结的pid,我就会继续运行,而如果确定没有了,就会返回负数退出循环

可能很多人好奇,为什么这种模式只有waitpid可以而wait不行,原因在于,waitpid可以非阻塞的(如果没有未终结的子进程,就返回负数退出),而wait是阻塞的,一定要等到下一个未终结的子进程,也就是说主进程就一直卡在这个信号处理函数里面了

最后版本

我们在上面的讨论中,对于初版的服务器,我们事实上做了三点改进:

  1. 我们加入了对SIGCHLD的信号处理函数,避免僵尸进程的问题
  2. 因为主进程在accept的时候可能会受到sigchld信号而被中断,我们加入了对中断的处理恢复
  3. 考虑到wait可能带来的关闭不完全的问题,我们使用waitpid提供的非阻塞等待功能

最后我们的server会有这么样的视图

#include "../unp.h"
#include "./signal.h"
#include "./sigchld_handler.c"
int main(int argc, char **argv)
{
    int clientfd;
    struct sockaddr_in servaddr;
    int fd;

    fd = socket(AF_INET, SOCK_STREAM, 0);

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_port = htons(8080);
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_family = AF_INET;

    bind(fd, (struct sockaddr *)&servaddr, sizeof(servaddr));
    listen(fd, 1024);
    signal(SIGCHLD, sig_chld2);//我们在这里加入了信号处理,sig_chld2是用waitpid写成的
    for (;;)
    {
        if ((clientfd = accept(fd, (struct sockaddr *)NULL, NULL)) < 0)
        {
            if (errno == EINTR)//我们在这里加入了恢复重试的功能
                continue;
            else
                err_sys("accept error");
        }
        if (fork() == 0)
        {
            close(fd);
            str_echo(clientfd);
            exit(0);
        }
        close(clientfd);
    }
}

特殊情形处理

服务器面对的是一个有着各种可能的环境,因此我们肯定会遇到很多问题,我们不妨一一分析

accept返回前连接中止

这种情形是说,当连接已经完成,进入TCP队列等着accept调用的时候,我们收到了一个RST报文

这种情形的模拟其实挺简单,我们可以构建一个服务器,然后设置在accept之前沉睡一段时间,然后我们在服务器沉睡的时候,启动客户端,调用socket,connect成功返回之后,我们设置套接字选项SO_LINGER来发送这个RST报文

其实对于这种非致命的报文,我们大可以再次调用accept,不过麻烦的地方在于,不同的系统对于这种情形下会做什么并不明确,berkerly的实现会在内核完成对于中止连接的处理,因此accept压根不知道原来有着一回事,有其他系统会通过返回EPROTO(protocol error),但是POSIX规定了返回ECONNABORTED(连接中断错误),原因是在流子系统(stream-subsystem)发生致命错误的时候会触发EPROTO,所以可能会混淆判断

服务器进程终止

在服务器进程崩溃的时候客户端也可能收到一定的影响,下面是模拟的步骤

  1. 启动之前的的服务器和客户端,键入字符,验证一切正常
  2. 调用kill杀死服务端进程
  3. 如果此时我们在客户端键入任意字符,我们就会发现报server terminated prematurely错误

在这背后,到底发生了什么呢?我们不妨跟着步骤捋一下背后的运行逻辑

事件 服务端 客户端
调用kill杀死子进程 进程退出,发出FIN报文,并收到ACK 收到FIN报文,并发送ACK,但是客户端进程一直阻塞在fgets
SIGCHLD信号触发 服务器正确处理了事件 客户端无事发生,仍然阻塞在fgets
客户端键入另外一个字符串 client调用writen发送数据(因为只收到了FIN报文,并不知道服务器进程挂了)
服务器收到数据,由于原先的进程已经关闭,发送RST报文 client调用writen之后,立即调用了Readline然后发现了一个未被预期的EOF,于是报错退出。 另一种可能是执行Readline的时候,收到了RST报文,反馈ECONNRESET

如果你仔细观察分析,你会发现,之所以会出现“未被预期的结束”或者说“复位连接错误”,原因是在于,fgets阻塞了请求致使client“不知道”发生了FIN报文结束,事实上我们应该是用SELECT或者EPOLL去同时监听两个描述符

SIGPIPE信号

如果向一个已经收到RST报文的套接字继续写入,会发生什么呢?

我们可以用以下的步骤模拟这个情景

  1. 修改client的代码,将其一次发送改为:发送一个字节内容,沉睡一定时间,发送剩下内容
  2. 启动client,确认一切正常后,kill服务器中该client相关的子进程
  3. client键入内容,从上面可知,第一次发送会获取RST报文,第二次就能出发这个场景了

答案是内核会向该进程发送一个SIGPIPE信号,这个信号如果不被捕获处理,默认处置就是结束进程。

当然无论是捕获了该信号并从中返回,还是说简单的忽略了该信号,write操作都会返回EPIPE错误

对于该情况的处理,我们有几种办法:

  1. 我们可以设置忽略该信号,然后从write读取到EPIPE错误的时候捕捉并终止
  2. 我们可以配置信号处理函数,来做一些必要的工作

我们要注意的是,在第二种办法中,你是不知道具体哪个write触发了这个信号的,如果你的确需要结合场景,要么你就选择第一种方案,要么你就在配置了函数之后,然后继续捕获EPIPE错误来处理

服务器主机崩溃

我们可以用以下的步骤模拟这个场景

我们需要有两部机子,一个运行server,一个运行client,我们在client和server连接上之后,断开服务器主机的连接,就可以模拟了

这种场景与服务器进程关闭的区别在于,服务器进程关闭是会发送FIN报文告知的,而服务器主机崩溃或者不可达是没有消息传递的

这个时候在client程序上,client程序运行writen无障碍(因为writen只确保传送到发送缓冲区),然后阻塞在Readline函数中,Berkeley的实现中会尝试发送12次信息,等待大约9分钟之后返回一个错误ETIMEOUT,如果是过程中发现不可达收到一个ICMP的话,会返回一个错误为EHOSTUNREACH或者ENETUNREACH,阻塞在readline的客户端会因此“发现”

显然9分钟太过于漫长,一般来说,我们可以配置readline的超时时间

如果我们希望在不主动发送信息的时候,也能感知到崩溃,我们需要依赖于SO_KEEPALIVE选项

服务器主机崩溃后重启

跟上面不一样的是,我们在客户端发送数据之前,服务器先关机重启来模拟这个场景

显然,重启之后的服务器没有相关信息,因此受到tcp数据的时候,会发送RST报文,而正在阻塞于readline系统调用的client因此收到一个ECONNRESET错误

服务器主机关机

在主动关机而不是崩溃的情况下,init进程首先会给所有的进程发送SIGTERM信号(可捕获,不过信号默认处理就是杀死进程,不主动捕获或者设置忽略的话就已经被关闭了),然后在固定的时间之后(一般5-20s),会向所有仍运行进程发送SIGKILL信号(不可捕获)来终止。

终止的过程会有点像是服务器进程终止(即会发送fin报文等)

发布了31 篇原创文章 · 获赞 32 · 访问量 745

猜你喜欢

转载自blog.csdn.net/a348752377/article/details/103210338