You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

9.9 KiB

25 | 使用阻塞I/O和进程模型最传统的方式

你好我是盛延敏这里是网络编程实战第25讲欢迎回来。

上一讲中我们讲到了C10K问题并引入了解决C10K问题的各种解法。其中最简单也是最有效的一种解决方法就是为每个连接创建一个独立的进程去服务。那么到底如何为每个连接客户创建一个进程来服务呢在这其中又需要特别注意什么呢今天我们就围绕这部分内容展开期望经过今天的学习你对父子进程、僵尸进程、使用进程处理连接等有一个比较直观的理解。

父进程和子进程

我们知道进程是程序执行的最小单位一个进程有完整的地址空间、程序计数器等如果想创建一个新的进程使用函数fork就可以。

pid_t fork(void)
返回在子进程中为0在父进程中为子进程ID若出错则为-1

如果你是第一次使用这个函数你会觉得难以理解的地方在于虽然我们的程序调用fork一次它却在父、子进程里各返回一次。在调用该函数的进程即为父进程中返回的是新派生的进程ID号在子进程中返回的值为0。想要知道当前执行的进程到底是父进程还是子进程只能通过返回值来进行判断。

fork函数实现的时候实际上会把当前父进程的所有相关值都克隆一份包括地址空间、打开的文件描述符、程序计数器等就连执行代码也会拷贝一份新派生的进程的表现行为和父进程近乎一样就好像是派生进程调用过fork函数一样。为了区别两个不同的进程实现者可以通过改变fork函数的栈空间值来判断对应到程序中就是返回值的不同。

这样就形成了编程范式:

if(fork() == 0){
  do_child_process(); //子进程执行代码
}else{
  do_parent_process();  //父进程执行代码
}

当一个子进程退出时系统内核还保留了该进程的若干信息比如退出状态。这样的进程如果不回收就会变成僵尸进程。在Linux下这样的“僵尸”进程会被挂到进程号为1的init进程上。所以由父进程派生出来的子进程也必须由父进程负责回收否则子进程就会变成僵尸进程。僵尸进程会占用不必要的内存空间如果量多到了一定数量级就会耗尽我们的系统资源。

有两种方式可以在子进程退出后回收资源分别是调用wait和waitpid函数。

pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);

函数wait和waitpid都可以返回两个值一个是函数返回值表示已终止子进程的进程ID号另一个则是通过statloc指针返回子进程终止的实际状态。这个状态可能的值为正常终止、被信号杀死、作业控制停止等。

如果没有已终止的子进程而是有一个或多个子进程在正常运行那么wait将阻塞直到第一个子进程终止。

waitpid可以认为是wait函数的升级版它的参数更多提供的控制权也更多。pid参数允许我们指定任意想等待终止的进程ID值-1表示等待第一个终止的子进程。options参数给了我们更多的控制选项。

处理子进程退出的方式一般是注册一个信号处理函数捕捉信号SIGCHLD信号然后再在信号处理函数里调用waitpid函数来完成子进程资源的回收。SIGCHLD是子进程退出或者中断时由内核向父进程发出的信号默认这个信号是忽略的。所以如果想在子进程退出时能回收它需要像下面一样注册一个SIGCHLD函数。

signal(SIGCHLD, sigchld_handler);  

阻塞I/O的进程模型

为了说明使用阻塞I/O和进程模型我们假设有两个客户端服务器初始监听在套接字lisnted_fd上。当第一个客户端发起连接请求连接建立后产生出连接套接字此时父进程派生出一个子进程在子进程中使用连接套接字和客户端通信因此子进程不需要关心监听套接字只需要关心连接套接字父进程则相反将客户服务交给子进程来处理因此父进程不需要关心连接套接字只需要关心监听套接字。

这张图描述了从连接请求到连接建立,父进程派生子进程为客户服务。


假设父进程之后又接收了新的连接请求从accept调用返回新的已连接套接字父进程又派生出另一个子进程这个子进程用第二个已连接套接字为客户端服务。

这张图同样描述了这个过程。


现在,服务器端的父进程继续监听在套接字上,等待新的客户连接到来;两个子进程分别使用两个不同的连接套接字为两个客户服务。

程序讲解

我们将前面的内容串联起来,就是下面完整的一个基于进程模型的服务器端程序。

#include "lib/common.h"

#define MAX_LINE 4096

char rot13_char(char c) {
    if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
        return c + 13;
    else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
        return c - 13;
    else
        return c;
}

void child_run(int fd) {
    char outbuf[MAX_LINE + 1];
    size_t outbuf_used = 0;
    ssize_t result;

    while (1) {
        char ch;
        result = recv(fd, &ch, 1, 0);
        if (result == 0) {
            break;
        } else if (result == -1) {
            perror("read");
            break;
        }

        if (outbuf_used < sizeof(outbuf)) {
            outbuf[outbuf_used++] = rot13_char(ch);
        }

        if (ch == '\n') {
            send(fd, outbuf, outbuf_used, 0);
            outbuf_used = 0;
            continue;
        }
    }
}

void sigchld_handler(int sig) {
    while (waitpid(-1, 0, WNOHANG) > 0);
    return;
}

int main(int c, char **v) {
    int listener_fd = tcp_server_listen(SERV_PORT);
    signal(SIGCHLD, sigchld_handler);
    while (1) {
        struct sockaddr_storage ss;
        socklen_t slen = sizeof(ss);
        int fd = accept(listener_fd, (struct sockaddr *) &ss, &slen);
        if (fd < 0) {
            error(1, errno, "accept failed");
            exit(1);
        }

        if (fork() == 0) {
            close(listener_fd);
            child_run(fd);
            exit(0);
        } else {
            close(fd);
        }
    }

    return 0;
}

程序的48行注册了一个信号处理函数用来回收子进程资源。函数sigchld_handler在一个循环体内调用了waitpid函数以便回收所有已终止的子进程。这里选项WNOHANG用来告诉内核即使还有未终止的子进程也不要阻塞在waitpid上。注意这里不可以使用wait因为wait函数在有未终止子进程的情况下没有办法不阻塞。

程序的58-62行通过判断fork的返回值为0进入子进程处理逻辑。按照前面的讲述子进程不需要关心监听套接字故而在这里关闭掉监听套接字listen_fd之后调用child_run函数使用已连接套接字fd来进行数据读写。第63行进入的是父进程处理逻辑父进程不需要关心连接套接字所以在这里关闭连接套接字。

还记得第11讲中讲到的close函数吗我们知道从父进程派生出的子进程同时也会复制一份描述字也就是说连接套接字和监听套接字的引用计数都会被加1而调用close函数则会对引用计数进行减1操作这样在套接字引用计数到0时才可以将套接字资源回收。所以这里的close函数非常重要缺少了它们就会引起服务器端资源的泄露。

child_run函数中通过一个while循环来不断和客户端进行交互依次读出字符之后进行了简单的转码如果读到回车符则将转码之后的结果通过连接套接字发送出去。这样的回显方式显得比较有“交互感”。

实验

我们启动该服务器监听在对应的端口43211上。

./fork01

再启动两个telnet客户端连接到43211端口每次通过标准输入和服务器端传输一些数据我们看到服务器和客户端的交互正常。

$telnet 127.0.0.1 43211
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
afasfa
nsnfsn
]
telnet> quit
Connection closed.

$telnet 127.0.0.1 43211
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
agasgasg
ntnftnft
]
telnet> quit
Connection closed.

客户端退出服务器端也在正常工作此时如果再通过telnet建立新的连接客户端和服务器端的数据传输也会正常进行。

至此,我们构建了一个完整的服务器端程序,可以并发处理多个不同的客户连接,互不干扰。

总结

使用阻塞I/O和进程模型为每一个连接创建一个独立的子进程来进行服务是一个非常简单有效的实现方式这种方式可能很难满足高性能程序的需求但好处在于实现简单。在实现这样的程序时我们需要注意两点

  • 要注意对套接字的关闭梳理;
  • 要注意对子进程进行回收,避免产生不必要的僵尸进程。

思考题

给你出两道思考题:

第一道,你可以查查资料,看看有没有比较著名的程序是使用这样的模式来构建的?

第二道程序中处理SIGCHLD信号时使用了一个循环来回收处理终止的子进程为什么要这么做呢如果不使用循环会有什么后果

欢迎你在评论区写下你的思考,我会和你一起交流,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。