22. 阻塞IO与进程模型

32 阅读5分钟

父进程和子进程

进程是程序执行的最小单位,一个进程有完整的地址空间、程序计数器等,如果想创建一个新的进程,使用函数 fork

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

程序调用 fork 一次,却在父、子进程里各返回一次。在调用该函数的进程(即为父进程)中返回的是新派生的进程 ID 号,在子进程中返回的值为 0。想知道当前执行的进程是父进程还是子进程,只能通过返回值来进行判断。

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 参数给了我们更多的控制选项。

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

signal(SIGCHLD, sigchld_handler);  

阻塞 I/O 的进程模型

假设有两个客户端,服务器初始监听在套接字 lisnted_fd 上。当第一个客户端发起连接请求,连接建立后产生出连接套接字,父进程派生出一个子进程,在子进程中,使用连接套接字和客户端通信,子进程不需要关心监听套接字,只需要关心连接套接字;父进程则相反,将客户服务交给子进程来处理,父进程不需要关心连接套接字,只需要关心监听套接字。 image.png

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

程序讲解

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

#include<sys/wait.h>
#include "common.h"
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[MAXLINE + 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) {
    //调用 waitpid 函数回收所有已终止的子进程。 
    while (waitpid(-1, 0, WNOHANG) > 0);//WNOHANG 告诉内核即使还有未终止的子进程也不要阻塞在 waitpid 上。
    // 这里不可以使用 wait,因为 wait 函数在有未终止子进程的情况下,没有办法不阻塞。
    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) {//通过判断 fork 的返回值为 0,进入子进程处理逻辑
            close(listener_fd);//子进程不需要关心监听套接字,所以关闭
            child_run(fd);
            exit(0);
        } else {//进入的是父进程处理逻辑,不需要关心连接套接字,关闭连接套接字。
            close(fd);
        }
    }

    return 0;
}

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

实验

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

./fork01

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

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

总结

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

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