多进程并发服务器

我们来考虑有多个客户同时连接一个服务器的情况。在前面的TCP套接字编程的例子中,我们已经看到,服务器程序在接受来自客户端的一个新连接时,会创建出一个新的套接字(已连接套接字),而原先的监听套接字则继续监听后面的连接请求。如果服务器不能立刻接受后来的连接,它们将被放到队列中以等待处理。
原先的套接字仍然可用并且套接字的行为就像文件描述符,这一事实给我们提供了一种同时服务多个客户的方法。如果服务器调用fork为自己创建第二份副本,打开的套接字就将被新的子进程所继承。新的子进程可以和连接的客户进行通信,而主服务器进程可以继续接受以后的客户连接。

为了了解这是如何工作的,假设我们有两个客户端和一个服务器,服务器正在监听一个监听套接字(比如描述符3)上的连接请求。
现在假设服务器接受了客户端1的连接请求,并返回一个已连接套接字(比如描述符4),如图1所示。

图1:第一步:服务器接受客户端的连接请求

在接受连接请求后,服务器派生一个子进程,这个子进程获得服务器描述符表的完整拷贝。子进程关闭它的拷贝中的监听套接字(描述符为3),而父进程关闭它的已连接套接字(描述符为4)的拷贝,因为不需要这些描述符了。这就得到图2中的状态,其中的子进程正忙于为客户端提供服务。
因为父、子进程中的已连接套接字描述符都指向同一个文件表表项,所以父进程关闭它的已连接套接字描述符的拷贝是至关重要的。否则,将永远不会释放已连接套接字描述符4的文件表表项,这会导致存储器资源泄漏并将最终消耗尽可用的存储器,使系统崩溃。


图2:第二步:服务器派生一个子进程为这个客户端服务

现在,假设在父进程为客户端1创建了子进程后,它接受一个新的客户端2的连接请求,并返回一个新的已连接套接字(比如描述符5),如图3所示。


图3:第三步:服务器接受另一个连接请求

然后父进程又派生另一个子进程,这个子进程利用已连接套接字(描述符为5)为它的客户端提供服务,如图4所示。


图4:服务器派生另一个子进程为新的客户端服务

此时,父进程继续等待下一个连接请求,而两个子进程正在并发地为它们各自的客户端提供服务。

例子

我们现在来看下如何使用代码来实现多进程并发服务器。在编写代码时,有几点需要着重强调的:

  • 因为我们创建子进程,但并不等待子进程的完成,所以安排服务器忽略SIGCHLD信号以避免出现僵尸进程。
  • 父子进程必须关闭它们各自的已连接套接字拷贝,如上面所述,这样才能避免存储器资源泄漏。
  • 因为套接字的文件表项中的引用计数,直到父子进程的已连接套接字描述符都关闭了,到客户端的连接才会终止。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <sys/types.h>
#include <sys/socket.h>
#include <stdio.h>
#include <netinet/in.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>

int main() {
int listenfd, connfd;
int server_len, client_len;
struct sockaddr_in server_address;
struct sockaddr_in client_address;

// 创建套接字
listenfd = socket(AF_INET, SOCK_STREAM, 0);

// 命名套接字
server_address.sin_family = AF_INET;
server_address.sin_addr.s_addr = htonl(INADDR_ANY);
server_address.sin_port = htons(6240);
server_len = sizeof(server_address);
bind(listenfd, (struct sockaddr*)&server_address, server_len);

// 创建套接字队列
listen(listenfd, 5);

// 避免出现僵尸进程
signal(SIGCHLD, SIG_IGN);

// 接受客户连接
while (1) {
char ch;
printf("server waiting\n");

client_len = sizeof(client_address);
connfd = accept(listenfd, (struct sockaddr*)&client_address, &client_len);

// 创建子进程,为这个客户创建一个子进程,并判断当前是运行是在父进程还是在子进程中
if (fork() == 0) {
// 在子进程中
close(listenfd);
read(connfd, &ch, 1);
ch++;
write(connfd, &ch, 1);
close(connfd);
sleep(5);
printf("subprocess, ch: %d, exit\n", ch);
exit(0);
} else {
// 在父进程中
close(connfd);
}
}
}

运行上面的代码,然后使用客户端测试程序(见本文附录)来测试多进程并发服务器的实现。
运行客户端程序:

$ ./client3 & ./client3 & ./client3

客户端终端输出:

char from server = B
char from server = B
char from server = B

同时,可以看到服务器程序输出:

server waiting
server waiting
server waiting
server waiting
subprocess, ch: 66, exit
subprocess, ch: 66, exit
subprocess, ch: 66, exit

多进程优劣

在父、子进程间共享状态信息,进程有一个非常清晰的模型:共享文件表,但是不共享用户地址空间。这样一来,一个进程不可能覆盖另一个进程的用户地址空间。这就消除了许多令人迷惑的错误。这是多进程实现并发服务器的优点。
另一方面,独立的地址空间使用进程共享状态信息变得困难,为了共享信息,必须使用IPC(进程间通信机制)。多进程的另一个缺点是,它们往往比较慢,因为进程控制和IPC的开销都较高。

参考资料

  1. 深入理解计算机系统,第2版,机械工业出版社
  2. Linux程序设计(第4版),Neil Matthew等著,人民邮电出版社,2010年
  3. UNIX 网络编程卷1:套接字联网API(第三版), W.Richard Stevens 等著

附:客户端测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/*  Make the necessary includes and set up the variables.  */

#include <sys/types.h>
#include <sys/socket.h>
#include <stdio.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>

int main()
{
int sockfd;
int len;
struct sockaddr_in address;
int result;
char ch = 'A';

/* Create a socket for the client. */
sockfd = socket(AF_INET, SOCK_STREAM, 0);

/* Name the socket, as agreed with the server. */
address.sin_family = AF_INET;
address.sin_addr.s_addr = inet_addr("127.0.0.1");
address.sin_port = htons(6240);
len = sizeof(address);

/* Now connect our socket to the server's socket. */
result = connect(sockfd, (struct sockaddr *)&address, len);
if(result == -1) {
perror("oops: client3");
exit(1);
}

/* We can now read/write via sockfd. */
write(sockfd, &ch, 1);
read(sockfd, &ch, 1);
printf("char from server = %c\n", ch);
close(sockfd);
exit(0);
}