【UNIX网络编程】TCP客户/服务器程序示例
做一个简单的回射服务器:
客户从标准输入读入一行文本,写给服务器 -> 服务器从网络输入读入这行文本,并回射给客户 -> 客户从网络输入读入这行回射文本,并显示在标准输出上
以下是我的代码(部分.h文件是由unpv13e文件夹中的.c文件改名得到)
#include "../unpv13e/unp.h" #include "../unpv13e/apueerror.h" #include "../unpv13e/wrapsock.h" #include "../unpv13e/wrapunix.h" #include "../unpv13e/wraplib.h" #include "../unpv13e/writen.h" #include "../unpv13e/str_echo.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); 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 */ } }
#include "../unpv13e/unp.h" #include "../unpv13e/apueerror.h" #include "../unpv13e/wrapsock.h" #include "../unpv13e/wrapunix.h" #include "../unpv13e/wraplib.h" #include "../unpv13e/writen.h" #include "../unpv13e/str_cli.h" #include "../unpv13e/readline.h" #include "../unpv13e/wrapstdio.h" 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); /* do it all */ exit(0); }
// 服务器代码和客户端代码如上所示。
其中,str_echo是从客户读入数据,并把他们回射给客户;str_cli是从标准输入读入一行文本,写到服务器上,读回服务器对该行的回射,并把回射行写到标准输出上。
正常启动服务器程序,然后可以使用netstat -a来查看服务器监听套接字的状态:
可以看到*:9877那一行就是上面的程序,调用了socket,bind,listen之后,由于backlog的队列为空,阻塞于accept调用。
当客户程序运行(./tcpcli01 127.0.0.1),再次使用命令netstat -a查看:
可以看到子进程与客户端已经连接成功,处于ESTABLISHED状态。
使用ps命令来检查进程的状态:
可以看到最后一行的进程的父进程是第一行。STAT中的S都为睡眠状态,WCHAN表示他们处于STAT的条件。(此处显示和书上不同,不过意思应该是一样的)
正常终止:按下ctrl+D然后立即执行netstat -a,显示如下(由于测试,临时端口跟上图不同):
正常终止客户和服务器的步骤是:
1) 键入EOF字符,fgets返回一个空指针,于是str_cli函数返回
2) 当str_cli返回到客户的main函数,main通过调用exit终止
3) 进程终止处理的部分工作是关闭所有打开的描述符,因此客户打开的套接字由内核关闭。这导致客户TCP发送一个FIN给服务器,服务器TCP则以ACK响应,这就是TCP连接终止序列的前半部分。
4) 当服务器TCP接收FIN时,服务器子进程阻塞于readline调用,于是readline返回0,导致str_echo函数返回服务器子进程的main函数
5) 服务器子进程通过调用exit来终止
6) 服务器子进程中打开的所有描述符随之关闭。有子进程来关闭已连接套接字会引发TCP连接终止序列的最后两个字节:一个从服务器到客户的FIN和一个从客户到服务器的ACK。至此,连接完全终止,客户套接字进入TIME_WAIT状态
7) 进程终止处理的另一部分内容是:在服务器子进程终止时,给父进程发送一个SIGCHLD信号。这一点在本例中发生了,但是我们没有在代码中捕获该信号,而该信号的默认行为是被忽略。既然父进程未加处理,子进程进入僵死状态。
僵死进程:子进程退出时,父进程并未对其发出的SIGCHLD信号进行适当处理,导致子进程停留在僵死状态等待父进程为其收尸。
POSIX信号处理
POSIX 表示可移植操作系统接口(Portable Operating System Interface ,缩写为 POSIX ),POSIX标准定义了操作系统应该为应用程序提供的接口标准
信号:告知某个进程发生了某个事件的通知,也称为软件中断(software interrupt)。信号通常是异步发生的,也就是说进程预先不知道信号的准确发生时刻。
异步双方不需要共同的时钟,也就是接收方不知道发送方什么时候发送,所以在发送的信息中就要有提示接收方开始接收的信息,如开始位,同时在结束时有停止位。异步的另外一种含义是计算机多线程的异步处理。与同步处理相对,异步处理不用阻塞当前线程来等待处理完成,而是允许后续操作,直至其它线程将处理完成,并回调通知此线程。
信号可以由一个进程发给另一个进程(或自身),也可以由内核发给某个进程。
上面提到的SIGCHLD信号就是内核在任何一个进程终止时发给它的父进程的一个信号。
每隔信号都有一个与之关联的处置(disposition),也叫行为(action),通过调用sigaction函数来设定一个信号的处置,三种选择:
1) 提供一个函数,只要有特定信号发生就被调用,这样的函数成为信号处理函数(signal handler),这种行为成为捕获(catching)信号。其中有两个信号不能被捕获(SIGKILL 和 SIGSTOP)。信号处理函数由信号值这个整数参数调用,且没有返回值,函数原型为 void handler(int signo); 。对于大多数信号来说,调用sigaction函数并指定信号发生时所调用的函数就是捕获信号所需做的全部工作,但是像SIGIO,SIGPOLL,SIGURG这些个别信号还要求捕获它们的进程做些额外工作。
2) 将信号设置为SIG_IGN来忽略它。但是SIGKILL和SIGSTOP这两个信号不能被忽略。
3) 将信号设置为SIG_DFL来启用它的默认处置。默认处置通常是在收到信号后终止进程,其中某些信号还在当前工作目录产生一个进程的核心映像(core image),另有个别信号默认是忽略,SIGCHLD和SIGURG就是这样。
signal函数:用于建立信号处置的函数。这个函数需要自己封装(因为,POSIX提供的方法sigaction函数太复杂,而系统提供的简单的signal函数是在POSIX出现之前,所以不兼容不同的信号。 => 定义自己的signal函数,其中在里面调用sigaction函数)
以下函数是书上通用(最终版本)的:
/* include 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, 44BSD */ #endif } if (sigaction(signo, &act, &oact) < 0) return(SIG_ERR); return(oact.sa_handler); } /* end signal */ Sigfunc * Signal(int signo, Sigfunc *func) /* for our signal() function */ { Sigfunc *sigfunc; if ( (sigfunc = signal(signo, func)) == SIG_ERR) err_sys("signal error"); return(sigfunc); }
系统提供的signal函数的原型是这样的: void (*signal(int signo, void (*func) (int)))(int);
要理解这个函数原型需要先知道 函数指针
函数指针是指向函数的指针变量。 因而“函数指针”本身首先应是指针变量,只不过该指针变量指向函数。函数指针有两个用途:调用函数和做函数的参数。
拆解的来理解的话,这是形如 void (*) (int) 的一个函数,而signal(int signo, void (*func) (int))这样的一个函数返回了一个函数指针,这个函数指针指向的函数正是 void (*) (int)
于是,函数原型的含义是:声明一个signal函数,参数是信号量(int型)和信号处理函数(只有1个int参数,无返回值),返回值是函数指针(指向一个有1个int参数,无返回值)
简化的理解方式:
先定义一个Sigfunc类型, typedef void Sigfunc(int);
如何理解typedef? 找到一篇参考文章。
typedef 在语句中所起的作用只不过是把语句原先定义变量的功能变成了定义类型的功能。例如:typedef int *apple,先别看typedef。int *apple是声明指向整型变量的指针,所以apple就是指向整型变量指针的类型。
所以,Sigfunc是一个函数类型,有一个整数参数且不返回值。原型此时变成了 Sigfunc *signal(int signo, Sigfunc *func); // 该函数的第二个参数和返回值都是指向信号处理函数的指针
处理SIGCHLD信号
设置僵死(zoombie)状态的目的是维护子进程的信息,以便父进程在以后某个时候获取这些信息包括子进程的进程ID,终止状态以及资源利用信息等。
如果父进程终止,僵死的子进程的父进程ID会被重置为1(init 进程),init进程会清理它们(wait)
无论何时fork子进程都得wait它们,以防它们变成僵死进程。
所以,要建立一个俘获SIGCHLD信号的信号处理函数,并在函数体内调用wait。
在调用listen之后,增加signal(SIGCHLD, sig_chld); // 要在fork第一个子进程之前完成,且只做一次。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; }
处理僵死进程的可移植方法就是捕获SIGCHLD,并调用wait或waitpid。
如果signal函数是来自系统自带的函数库,而不是自定义的signal函数,会出现以下的情况:
即使处理了SIGCHLD信号,仍然会造成慢系统调用(accept)被中断(返回一个EINTR错误),而父进程不处理该错误,所以父进程中止。虽然有些内核会自动重启某些被中断的系统调用,但是我们必须对慢系统调用返回EINTR有所准备来兼容。标准C函数库中提供的signal函数不会使内核自动重启被中断的系统调用。所以需要设置SA_RESTART标志。但是即使设置了,有些内核或者一些系统调用不能重启,于是需要在accept之后处理。
该术语适用于那些可能永远阻塞的系统调用。永远阻塞的系统调用是指调用永远无法返回,多数网络支持函数都属于这一类。如:若没有客户连接到服务器上,那么服务器的accept调用就会一直阻塞。
在我的机器中测试的时候,代码和相应显示如下:
#include "../unpv13e/unp.h" #include "../unpv13e/apueerror.h" #include "../unpv13e/wrapsock.h" #include "../unpv13e/wrapunix.h" #include "../unpv13e/wraplib.h" #include "../unpv13e/writen.h" #include "../unpv13e/str_echo.h" // #include "../unpv13e/signal.h" #include "../unpv13e/sigchldwait.h" // 使用wait来处理僵死进程 int main(int argc, char **argv) { int listenfd, connfd; pid_t childpid; socklen_t clilen; struct sockaddr_in cliaddr, servaddr; void sig_chld(int); 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); Bind(listenfd, (SA *) &servaddr, sizeof(servaddr)); Listen(listenfd, LISTENQ); signal(SIGCHLD, sig_chld); 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 */ } }
没有出现accept error: Interrupted system call,属于内核自动重启某些被中断的系统调用的类型。但是为了便于移植,还是要设置SA_RESTART标志,并且在accept函数中忽略EINTR错误,即:
1) 使用自定义的Signal函数(最终版本见上面的signal.h)
2) 在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这个包裹函数中不直接处理EINTR错误呢?因为connect函数在返回EINTR的时候,不能再次调用它,否则立即返回一个错误!
修改了EINTR的处理并且使用自定义Signal函数之后,服务器程序的最终版就实现了。(信号处理函数不在tcpserv程序中,所以书上的tcpserv03和tcpserv04程序相同)
wait和waitpid函数:处理已终止的子进程
#include <sys/wait.h> pid_t wait(int *statloc); pid_t waitpid(pid_t pid, int *statloc, int options); // 成功返回进程ID,出错则为0或-1
以上两个函数返回进程ID号(pid),和子进程终止状态(statloc)
如果调用wait的进程没有已终止的子进程,不过有一个或多个子进程仍在执行,那么wait将阻塞到现有子进程第一个终止为止。
waitpid函数有更多的控制选择,pid参数允许指定等待的进程ID,-1表示等待第一个终止的子进程。options中最常用的选项是WNOHANG,它告知内核在没有已终止子进程时不要阻塞。
这两个函数的区别在于:
建立一个信号处理函数并在其中调用wait并不足以防止出现僵死进程。当有多个信号同时终止,引发多个FIN并产生SIGCHLD信号的时候,由于Unix信号是不排队的,所以信号处理函数不保证执行几次,很有可能会留下僵死进程。
测试使用wait的情况,以下是相应显示:
(信号处理函数见上面,主要是 pid = wait(&stat); )
做了两次测试,严重的问题不仅是会留下僵死进程,并且不确定会有怎样的结果。上面的两个例子,一个是留下了4个僵死进程,一个是留下了一个。
正确的方法是调用waitpid:在一个循环内调用waitpid,以获取所有已终止子进程的状态,而且必须指定WNOHANG选项告知waitpid在有尚未终止的子进程在运行时不要阻塞。
最终版本的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; }
服务器程序的最终版本如下(可以正确处理accept返回的EINTR,并建立一个给所有已终止子程序调用waitpid的信号处理函数)
#include "unp.h" int main(int argc, char **argv) { int listenfd, connfd; pid_t childpid; socklen_t clilen; struct sockaddr_in cliaddr, servaddr; void sig_chld(int); 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); Bind(listenfd, (SA *) &servaddr, sizeof(servaddr)); Listen(listenfd, LISTENQ); Signal(SIGCHLD, sig_chld); /* must call waitpid() */ 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 */ } }
改用waitpid之后的图示:
最终版本能够处理的情况:
1) 当fork子进程时,必须捕获SIGCHLD信号
2) 当捕获信号时,必须处理被中断的系统调用
3) SIGCHLD的信号处理函数必须正确编写,应使用waitpid函数以免留下的僵死进程
一些异常情况:
1) accept返回前连接中止:三路握手完成从而建立连接之后,客户TCP发送了一个RST。即,该连接已经排队,等着服务器进程调用accept的时候RST到达。
如何处理这种中止的连接依赖于不同的实现。有些实现完全在内核中处理中止的连接,服务器进程根本看不到;有些返回EPROTO的errno值,POSIX返回ECONNABORTED(因为有些致命的协议相关事件也会返回EPROTO,所以换成ECONNABORTED错误,使得服务器可以忽略它)。一般服务器处理的时候只需要再次调用accept就行。
2) 服务器进程终止:处理客户的子进程意外终止,该子进程所有打开着的描述符都关闭,这就导致向客户发送一个FIN,而客户TCP则响应一个ACK,完成了四次挥手的前半部分,同时,SIGCHLD信号被发送给服务器父进程,并得到正确处理。然而此时,客户进程阻塞在fgets调用上,等待从终端接收一行文本。
其中在打开tcpcli01之后,在另一个终端中kill掉了子进程,所以此时服务器返回了一个FIN给客户,客户响应ACK,然后当客户再次发送一行数据的时候(此时可以发送数据给服务器,因为在四次挥手的前半部分结束之后,客户需要告知服务器自身已经关闭),服务器进程已经关闭了,所以返回一个RST。此时,在客户进程中可能出现两种情况: 1) 调用readline发生在收到RST之前(本例所示),接收到的FIN使readline立即返回0(EOF),而客户此时并未预期收到EOF,于是以出错信息退出。 2) 如果readline发生在收到RST之后,则返回一个ECONNRESET,表示对方复位连接错误。
本例的问题在于,当FIN到达套接字时,客户正阻塞在fgets调用上,而客户实际上在应对两个描述符——套接字和用户输入,它不能单纯阻塞其中某个输入,而是应该阻塞其中任何一个源的输入上,这就是 select 和 poll 这两个函数的目的之一。
后面会重新编写str_cli函数,一旦杀死服务器子进程,客户就会立即被告知已收到FIN。
3) SIGPIPE信号:不理会readline函数返回的错误,写入更多的数据到服务器。(如,客户在读回任何数据(调用readline)之前执行两次写(Writen)操作,而RST是第一次写的时候引发的)
写一个已接收了FIN的套接字没问题,但是写一个已接收了RST的套接字则是一个错误。所以根据服务器进程终止的例子来看,杀死子进程之后,客户第一次写入数据,会引发RST,而第二次写则会引发SIGPIPE信号,该信号默认行为是终止进程,并且不论该进程是捕获了该信号并从其信号处理函数返回,还是忽略这个信号,写操作都会返回EPIPE错误。
我的测试中没有显示Broken pipe。处理SIGPIPE的方法取决于它发生时应用进程想做什么。如果没有特殊的事情要做,那么将信号处理办法直接设置成SIG_IGN,并假设后续的输出操作将捕捉EPIPE错误并终止。如果有要登记到日志文件等操作,则必须捕获该信号,如果需要知道是哪个套接字出了错,则需要在信号处理函数或者忽略之后再处理来自write的EPIPE。
4) 服务器主机崩溃:(模拟过程:正常连接并测试文本发送正常,然后断开服务器主机不再重启) 客户发送文本之后阻塞于readline,等待回射的应答。根据重传机制,客户TCP会尝试多次重传,都失败之后才会返回ETIMEDOUT(服务器主机崩溃,对客户的数据分节根本没有响应)或EHOSTUNREACH / ENETUNREACH(中间路由器判定服务器主机不可达)。然而,通过多次重传来检测很耗费时间,为了更快的检测出这种情况并且不用主动向服务器主机发送数据来确认,需要使用SO_KEEPALIVE套接字选项。
5) 服务器主机崩溃后重启:(模拟过程:正常连接并测试文本发送正常,之后马上重启,在重启的过程中,客户没有发送文本) 重启之后,客户发送文本,但在服务器TCP中已经丢失了之前的连接信息,所以响应一个RST,调用返回ECONNRESET。
6) 服务器主机关机:正常关机时,init进程通常先给所有进程发送SIGTERM信号(可被捕获,默认动作是终止进程,只有忽略或处理了之后没有终止,才会被SIGKILL终止),并等待一段固定的时间,让进程有时间可以清除和终止,时间到了之后则给所有仍在运行的进程发送SIGKILL信号(不能被捕获),当进程关闭时,发生的情况如2)中所示,所以必须在客户中使用select或poll函数,使得服务器进程的终止一经发生就能被客户检测到。
穿越套接字在客户与服务器之间传递二进制结构是不明智的,可能出现以下问题:
1) 大端字节序和小端字节序
2) 32位和64位,各种数据类型的大小不一
3) 结构打包方式不同
解决方法:
1) 在客户和服务器主机具有相同的字符集的前提下,所有的数值数据作为文本串来传输
2) 显示定义所支持的数据类型的二进制格式(位数,大小端字节序)