在我的上一篇博文UNIX网络编程入门——TCP客户/服务器程序详解中,介绍了一个简单的echo服务器及客户端程序,通过学习这个程序对我们了解网络编程大有裨益,但应该注意,这只是一个不完善的demo级程序,存在着许多实际环境中的问题,这里参照UNPv1第五章讲解一下存在的问题。
一、产生僵尸进程
如下图,我们后台执行服务器程序,然后打开一个客户端程序发送两行后关闭,此时我们使用命令ps aux | grep tcpserver
查看跟服务器程序有关的进程状态。可以看到,有三个跟tcpserver有关的进程,最下面那个是我们因为我们使用的这条命令产生的,不用管它。第一条是服务器主进程,其当前状态正常,第二条是因客户端连接而产生的子进程,可以看到其状态为Z
,意为已经僵死。
何为僵尸进程呢?当一个父进程fork出一个子进程,而子进程退出时父进程没有妥善处理好子进程,导致子进程一直停留在系统中,占用进程描述符,此子进程便是僵尸进程。系统的进程描述符是有限的,如果大量的产生僵死进程,将因为没有可用的进程号而导致系统不能产生新的进程. 此即为僵尸进程的危害,应当避免。
处理僵尸进程有两种方法,一种是我们直接手动kill该进程或者kill它的父进程,当kill父进程时,这些僵死进程的父进程id被置为1(init进程),也就是说它们被过继到init进程下,init进程负责清理它们。当然,手动清理有点不明智,那么我们应该如何编写代码来防止产生僵尸进程呢?这里就涉及到信号处理了,当子进程退出时,它会向父进程发送一个SIGCHLD信号,我们在可以在父进程中捕获该信号并关闭该子进程。
简单介绍一下,信号(signal)是unix或类unix系统中进程间通讯的一种方式,用来通知某个进程一个事件发生了,它通常是异步发生的,也就是说进程事先并不知道信号会何时产生。每个信号都有一个名字, 这些名字都以三个字符 SIG 开头。 例如,SIGABRT是夭折信号, 当进程调用 abort 函数时产生这种信号。SIGALRM 是闹钟信号,当由 alarm 函数设置的计时器超时后产生此信号。信号可以:
- 由一个进程发给另一个进程(或者自身)
- 由内核发给某个进程
例如我们上面提到的SIGCHLD就是由内核产生,在子进程终止时发给它的父进程的。
当信号发生后,需要进行相应的处置(disposition),我们通过调用signal函数来处理信号,不过因为这个函数历史悠久,不同的系统实现有不同的语义,为了避免歧义,我们定义自己的signal函数,它调用POSIX的sigaction函数。
使用signal进行信号处置有三种选择:
- 在一个信号发生后调用一个我们自己定义的特定函数,这称为
捕获
- 处置方式设置为SIG_IGN来忽略(ignore)它
- 处置方式设置为SIG_DFL来使用它的默认(default)处理方式
前面的子进程终止后内核发出的SIGCHLD信号的默认处置方式就是忽略,因为我们在父进程中没有使用signal进行信号处置,所以系统选择忽略这个信号。
下面给出Signal函数的实现,具体关于signal函数的信息请参阅UNPv1第五章和APUE第十章。
typedef void Sigfunc(int); /* for signal handlers */
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);
}
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函数来处理信号,还需要一个我们自己定义的捕获信号时调用的函数,这里我们定义为sig_chld(int),该函数在被调用时使用wait函数
来处理已被终止的子进程,它返回被终止的子进程id,并将stat指针指向的值修改为子进程终止状态(一个整数)。
void sig_chld(int signo)
{
pid_t pid;
int stat;
pid = wait(&stat);
printf("child %d terminated\n", pid);
return;
}
好了,有了上面的函数,我们就可以来处理子进程的终止信号了,将服务器程序修改如下:
tcpserver.c
#include "myunp.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处理函数 */
Signal(SIGCHLD, sig_chld);
/* 增加语句:安装signal处理函数 */
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 */
}
}
如上,增加了两条信号处理相关语句后,编译运行,可以看到在客户端程序退出后,sig_chld函数被调用了,它终止了子进程并且在控制台输出child 2885 terminated
。此时我们再次查看后台进程,发现已经不存在僵死的子进程了。
二、被中断的系统调用
实际上,上述服务器程序只是在当前系统上工作正常,在一些系统上他将因子进程信号来临而中断Accept函数,导致main函数提前终止,服务器程序被迫关闭。这是因为某些系统上不会自动重启被中断的系统调用。
我们把accept函数称为慢系统调用(slow system call),该术语指的是可能永远阻塞的系统调用,例如如果没有客户端来请求连接服务器,那么服务器将一直阻塞在accept函数处。通常当阻塞于一个慢系统调用的进程捕获到某个信号后调用处理函数且函数返回时,该系统调用可能会返回一个EINTR错误,即被中断了。有的内核会重启被中断的系统调用,例如accept函数被中断后重新执行程序,但为了保证在不同环境中程序的表现一致,我们需要修改代码在被中断后自动重启调用以适应各种环境。修改后的服务器程序如下:
#include "myunp.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);
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 */
}
}
这样,在不同的环境中,当客户端程序退出时,子进程终止的SIGCHLD信号打断accept函数的调用,程序也能自动恢复运行。
三、多个客户端连接时可能的情况
上面的问题都只考虑了只有单个客户端和服务器通信的情况,没有对多个连接同时存在的情况的分析。现在我们修改客户端的代码来模拟同时有5个与服务器连接的情况:
tcpclient.c
#include "myunp.h"
int main(int argc, char **argv)
{
/* 修改代码 */
int i,sockfd[5];
/* 修改代码 */
struct sockaddr_in servaddr;
if (argc != 2)
err_quit("usage: tcpcli <IPaddress>");
/* 修改代码 */
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);
}
如上,客户端同时与服务器建立5个连接,但只使用第一个连接来通信,当客户端退出时,5个描述符会被同时关闭,这就产生了5个FIN结束信号给服务器,服务器各子进程接收到信号就由内核发出SIGCHLD,这里就产生了一个问题,在unix中一般信号是不排队的,这意味着5个同时发生的信号只会使信号处理函数被调用一次,那么就只会清理掉一个子进程,剩下四个子进程就成为僵死进程了,这是我们无法容忍的。
那么应该如何解决呢?这里我们介绍另一个处理子进程的函数waitpid
,它有一个pid参数可以让我们指定要等待的进程id,当这个参数设置为-1时就跟wait函数一样等待第一个终止的子进程。另外它还可以指定附加选项,我们这里采用的附加选项为WNOHANG,它告知内核在没有已终止的子进程时不要阻塞在该函数上,这是一个很有用的功能,我们可以在信号处理函数里面一直调用该waitpid函数直到把僵尸进程都处理掉,这样即使5个信号只能调用信号处理函数一次,但这一次已足够了,当没有了已终止子进程,这个函数便结束执行。之所以不用wait进行这种操作是因为当所有的僵尸进程都被清理掉后,函数会一直阻塞在wait处等待下一个僵尸进程产生,无法正确退出该函数,它是阻塞的。
sig_chld函数修改如下:
void sig_chld(int signo)
{
pid_t pid;
int stat;
/* 修改代码 */
while ( (pid = waitpid(-1, &stat, WNOHANG)) > 0)
printf("child %d terminated\n", pid);
/* 修改代码 */
return;
}