多进程并发服务器

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

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

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

多进程并发服务器_第2张图片
图2:第二步:服务器派生一个子进程为这个客户端服务

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

多进程并发服务器_第3张图片
图3:第三步:服务器接受另一个连接请求

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

多进程并发服务器_第4张图片
图4:服务器派生另一个子进程为新的客户端服务

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

例子

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

  • 因为我们创建子进程,但并不等待子进程的完成,所以安排服务器忽略SIGCHLD信号以避免出现僵尸进程。
  • 父子进程必须关闭它们各自的已连接套接字拷贝,如上面所述,这样才能避免存储器资源泄漏。
  • 因为套接字的文件表项中的引用计数,直到父子进程的已连接套接字描述符都关闭了,到客户端的连接才会终止。
#include 
#include 
#include 
#include 
#include 
#include 
#include 

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 等著

附:客户端测试代码

/*  Make the necessary includes and set up the variables.  */

#include 
#include 
#include 
#include 
#include 
#include 
#include 

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);
}

你可能感兴趣的:(后台)