UNIX网络编程——并发服务器(TCP)

时间:2021-03-05 05:14:29

在迭代服务器中,服务器只能处理一个客户端的请求,如何同时服务多个客户端呢?在未讲到select/poll/epoll等高级IO之前,比较老土的办法是使用fork来实现。

网络服务器通常用fork来同时服务多个客户端,父进程专门负责监听端口,每次accept一个新的客户端连接就fork出一个子进程专门服务这个客户端。但是子进程退出时会产生僵尸进程,父进程要注意处理SIGCHLD信号和调用wait清理僵尸进程,最简单的办法就是直接忽略SIGCHLD信号。

当一个连接建立时,accept返回,服务器接着调用fork,然后由子进程服务客户(通过已连接套接字connfd),父进程则等待另一个连接(通过监听套接字listenfd)。既然新的客户由子进程提供服务,父进程就关闭已连接套接字。

首先下图给出了在服务器阻塞于accept调用且来自客户的连接请求到达时客户和服务器的状态。

UNIX网络编程——并发服务器(TCP)

从accept返回后,我们立即就有下面的状态。连接被内核接受,新的套接字connfd被创建。这是一个已连接套接字,可由此跨连接读写数据。

UNIX网络编程——并发服务器(TCP)

并发服务器的下一步是调用fork,下面是从fork返回后的状态。

UNIX网络编程——并发服务器(TCP)

注意,此时listenfd和connfd这两个描述符都在父进程和子进程之间共享(被复制),再下一步是由父进程关闭已连接套接字,由子进程关闭监听套接字。如下图:

UNIX网络编程——并发服务器(TCP)

在编写TCP并发服务器的时可能会遇到三种情况:

  • 当fork子进程时,必须捕获SIGCHLD信号;
  • 当捕获信号时,必须处理被中断的慢系统调用;
  • SIGCHLD的信号处理函数必须正确编写,应使用waitpid函数,以免留下僵死进程。

我们用术语慢系统调用描述accept,该术语也适用于那些可能永远阻塞的系统调用。
     适用于慢系统调用的基本规则是:当阻塞于某个慢系统调用的一个进程捕获某个信号且相应信号处理函数返回时,该系统调用可能返回一个EINTR错误。有些内核自动重启某些被中断的系统调用。不过为了便于移植,当我们编写捕获信号的程序时(多数并发服务器捕获SIGCHLD),我们必须对慢系统调用返回EINTR有所准备。

服务器程序serv.c:

#include<stdio.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<unistd.h>
#include<stdlib.h>
#include<errno.h>
#include<arpa/inet.h>
#include<netinet/in.h>
#include<string.h>
#include<signal.h> #define ERR_EXIT(m) \
do { \
perror(m); \
exit(EXIT_FAILURE); \
} while (0) void do_service(int); int main(void)
{
signal(SIGCHLD, SIG_IGN);
int listenfd; //被动套接字(文件描述符),即只可以accept, 监听套接字
if ((listenfd = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0)
// listenfd = socket(AF_INET, SOCK_STREAM, 0)
ERR_EXIT("socket error"); struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(5188);
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
/* servaddr.sin_addr.s_addr = inet_addr("127.0.0.1"); */
/* inet_aton("127.0.0.1", &servaddr.sin_addr); */ int on = 1;
if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) < 0)
ERR_EXIT("setsockopt error"); if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0)
ERR_EXIT("bind error"); if (listen(listenfd, SOMAXCONN) < 0) //listen应在socket和bind之后,而在accept之前
ERR_EXIT("listen error"); struct sockaddr_in peeraddr; //传出参数
socklen_t peerlen = sizeof(peeraddr); //传入传出参数,必须有初始值
int conn; // 已连接套接字(变为主动套接字,即可以主动connect) pid_t pid; while (1)
{
if ((conn = accept(listenfd, (struct sockaddr *)&peeraddr, &peerlen)) < 0) //3次握手完成的序列
{
if( errno == EINTR ) ///////////////////////////////////////////////////////////////////必须处理被中断的系统调用
continue;
else
ERR_EXIT("accept error");
}
printf("recv connect ip=%s port=%d\n", inet_ntoa(peeraddr.sin_addr),
ntohs(peeraddr.sin_port)); pid = fork();
if (pid == -1)
ERR_EXIT("fork error");
if (pid == 0)
{
// 子进程
close(listenfd);
do_service(conn);
exit(EXIT_SUCCESS);
}
else
close(conn); //父进程
} return 0;
} void do_service(int conn)
{
char recvbuf[1024];
while (1)
{
memset(recvbuf, 0, sizeof(recvbuf));
int ret = read(conn, recvbuf, sizeof(recvbuf));
if (ret == 0) //客户端关闭了
{
printf("client close\n");
break;
}
else if (ret == -1)
ERR_EXIT("read error");
fputs(recvbuf, stdout);
write(conn, recvbuf, ret);
}
}

上述程序利用了一点,就是父子进程共享打开的文件描述符,因为在子进程已经用不到监听描述符,故将其关闭,而连接描述符对父进程也没价值,将其关闭。当某个客户端关闭,则read 返回0,退出循环,子进程顺便exit,但如果没有设置对SIGCHLD信号的忽略,则因为父进程还没退出,故子进程会变成僵尸进程。

现在先运行server,再打开另外两个终端,运行client(直接用<<UNIX网络编程——TCP回射服务器/客户端程序>>中的客户端程序),可以看到server输出如下:

huangcheng@ubuntu:~$ ./serv
recv connect ip=127.0.0.1 port=42114
recv connect ip=127.0.0.1 port=42115

在另一个终端ps一下:

huangcheng@ubuntu:~$ ps -aux | grep serv
/usr/lib/system-service/system-service-d
1000 3813 0.0 0.0 1640 404 pts/1 S+ 11:27 0:00 ./serv
1000 3815 0.0 0.0 1640 168 pts/1 S+ 11:27 0:00 ./serv
1000 3817 0.0 0.0 1640 156 pts/1 S+ 11:27 0:00 ./serv
1000 3824 0.0 0.0 3572 904 pts/3 S+ 11:28 0:00 grep --color=auto serv

发现共有3个进程,其中一个是父进程处于监听中,另外两个是子进程处于对客户端服务中,现在ctrl+c 掉其中一个client,由上面的分析可知对应服务的子进程也会退出,而因为我们设置了父进程对SIGCHLD信号进行忽略,故不会产生僵尸进程,输出如下:

huangcheng@ubuntu:~$ ps -aux | grep serv
1000 3813 0.0 0.0 1640 404 pts/1 S+ 11:27 0:00 ./serv
1000 3815 0.0 0.0 1640 168 pts/1 S+ 11:27 0:00 ./serv
1000 3831 0.0 0.0 3572 904 pts/3 S+ 11:29 0:00 grep --color=auto serv

如果把第22行代码注释掉,上述的情景输出为:

1000      3876  0.0  0.0   1640   408 pts/1    S+   11:32   0:00 ./serv
1000 3878 0.0 0.0 1640 172 pts/1 S+ 11:32 0:00 ./serv
1000 3880 0.0 0.0 0 0 pts/1 Z+ 11:32 0:00 [serv] <defunct>
1000 3885 0.0 0.0 3572 900 pts/3 S+ 11:33 0:00 grep --color=auto serv

即子进程退出后变成了僵尸进程。
     如果不想忽略SIGCHLD信号,则必须在信号处理函数中调用wait处理,但这里需要注意的是wait只能等待第一个退出的子进程,所以这里需要使用waitpid函数,如下所示:

signal(SIGCHLD, handler);
..................... void handler(int sig)
{
pid_t pid;
int stat;
/* wait(NULL); //只能等待第一个退出的子进程 */
/* 即使因为几个连接同时断开,信号因不能排队而父进程只收到一个信号
* 直到已经waitpid到所有子进程,返回0,才退出循环 */
while ( (pid = waitpid(-1, &stat, WNOHANG)) > 0)
printf("child %d terminated\n", pid);
return;
}

1.  必须编写SIGCHLD信号的信号处理函数,原因为:防止出现僵死进程

2.  当捕获信号时,必须处理被中断的系统调用,原因为:

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

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

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

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

3.  SIGCHLD的信号处理函数必须正确编写,应使用waitpid函数,以免留下僵死进程,原因为:

客户建立于服务器5个连接

UNIX网络编程——并发服务器(TCP)

修改过后的客户端程序如下:

#include<stdio.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<unistd.h>
#include<stdlib.h>
#include<errno.h>
#include<arpa/inet.h>
#include<netinet/in.h>
#include<string.h> #define ERR_EXIT(m) \
do { \
perror(m); \
exit(EXIT_FAILURE); \
} while (0) void do_echocli(int sock)
{ char sendbuf[1024] = {0};
char recvbuf[1024] = {0}; while (fgets(sendbuf, sizeof(sendbuf), stdin) != NULL)
{ write(sock, sendbuf, strlen(sendbuf)); int ret = read(sock, recvbuf, sizeof(recvbuf));
if (ret == -1)
ERR_EXIT("read error");
else if (ret == 0) //服务器关闭
{
printf("server close\n");
break;
} fputs(recvbuf, stdout); memset(sendbuf, 0, sizeof(sendbuf));
memset(recvbuf, 0, sizeof(recvbuf)); } close(sock);
} int main(void)
{
int sock[5];
int i;
for (i = 0; i < 5; i++)
{
if ((sock[i] = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0)
// listenfd = socket(AF_INET, SOCK_STREAM, 0)
ERR_EXIT("socket error"); struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(5188);
servaddr.sin_addr.s_addr = inet_addr("127.0.0.1");
/* inet_aton("127.0.0.1", &servaddr.sin_addr); */ if (connect(sock[i], (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0)
ERR_EXIT("connect error"); struct sockaddr_in localaddr;
socklen_t addrlen = sizeof(localaddr);
if (getsockname(sock[i], (struct sockaddr *)&localaddr, &addrlen) < 0)
ERR_EXIT("getsockname error");
/* getpeername()获取对等方的地址 */
printf("local ip=%s port=%d\n", inet_ntoa(localaddr.sin_addr),
ntohs(localaddr.sin_port));
}
/* 一个进程也可以发起多个socket连接,因为每次的端口号都不同 */
do_echocli(sock[0]); //发起5个套接字连接,但只借助第一个套接口通信 return 0;
}

在上述程序中,我们发起5个sock连接,但只是使用sock0通信,且利用getsockname 打印5个连接的信息。

先运行服务器程序,再运行客户端,客户端输出如下:

huangcheng@ubuntu:~$ ./cli
local ip=127.0.0.1 port=33867
local ip=127.0.0.1 port=33868
local ip=127.0.0.1 port=33869
local ip=127.0.0.1 port=33870
local ip=127.0.0.1 port=33871
huangcheng
huangcheng

即每个连接的ip地址是一样的,但端口号不同,服务器方面通过accept返回的信息也打印出连接信息,如下:

huangcheng@ubuntu:~$ ./serv
recv connect ip=127.0.0.1 port=33867
recv connect ip=127.0.0.1 port=33868
recv connect ip=127.0.0.1 port=33869
recv connect ip=127.0.0.1 port=33870
recv connect ip=127.0.0.1 port=33871
huangcheng

当客户终止时,所有打开的描述符由内核自动关闭(我们不调用close,仅调用exit),且所有的5个连接基本在同一时刻终止。这就引发了5个FIN,每个连接一个,他们反过来使服务器的5个子进程基本在同一时刻终止。这又导致差不多在同一时刻有5个SIGCHLD信号递交给父进程:

UNIX网络编程——并发服务器(TCP)

我们预期所有的5个子进程都终止了。但是运行PS,我们发现其他4个子进程仍然作为僵死进程存在着。

正确的解决办法是调用waitpid而不是wait。我们必须指定WNOHANG选项,它告知waitpid在有尚未终止的子进程在运行时不要阻塞。我们不能再循环内调用wait,因为没有办法防止wait在运行的子进程尚未终止时阻塞。

注意前面的代码:

  while (1)
{
if ((conn = accept(listenfd, (struct sockaddr *)&peeraddr, &peerlen)) < 0) //3次握手完成的序列
{
if( errno == EINTR ) ///////////////////////////////////////////////////////////////////必须处理被中断的系统调用
continue;
else
ERR_EXIT("accept error");
}

这段代码所做的事情就是自己重启被中断的系统调用。对于accept以及诸如read、write、select和open之类函数来说,这是合适的。不过有一个函数我们不能重启:connect。如果该函数返回EINTR,我们就不能再次调用它,否则将立即返回一个错误。当connect被一个捕获的信号中断而且不能重启时,我们必须调用select来等待连接完成。

注意:关于此说明在后面的博客<<UNIX网络编程——非阻塞connect:时间获取客户程序>>里面的被中断的connect有说明。