1. 基础知识

为什么需要进程等待

在很多应用程序的设计中,父进程需要知道某个子进程于何时改变了状态——子进程终止或因收到信号而停止。而在Linux中,如果子进程因为任何一种原因而终止后,父进程对此“不管不顾”,那么子进程的状态就会变为“Zombie”僵尸进程,顾名思义,半死不活。而解决这一问题的方法就是“进程等待”,即父进程等待子进程退出,获取退出返回值或者杀死子进程的信号值,然后告诉系统可以释放子进程的资源了,之后子进程才能毫无遗憾的释放于天地之中~

系统调用 wait()

#include <sys/wait.h>
pid_t wait(int* status);

系统调用 wait() 将执行如下动作:

  1. 如果调用进程并没有已经终止的子进程(但确实有还没终止的子进程),则调用会一直阻塞,直到某个子进程终止,这说明 wait() 不是等待某一特定子进程而是等待任一子进程。如果调用时已经有已终止的子进程,则 wait() 立即返回。
  2. 如果 status 非空,则关于子进程如何终止的信息会通过 status 指向的整型变量返回。
  3. 将终止的子进程PID 作为 wait() 的结果返回。

出错时,wait() 返回 -1,可能的一个出错原因是调用进程压根就没有子进程,那还何谈等待子进程,此时会将 errno 置为 ECHILD 。

系统调用 waitpid()

#include <sys/wait.h>
pid_t waitpid(pid_t pid, int* status, int options);

waitpid() 的返回值与status的意义与wait() 的相同。参数pid 用于指定要等待的具体子进程,意义如下:

  • 如果pid 大于0,表示等待进程ID为 pid 的子进程。
  • 如果pid 等于-1,则等待任一进程,与 wait() 类似。
  • 如果pid 等于0,则等待与调用进程同一个进程组的所有子进程。
  • 如果pid 小与-1,则会等待进程标识符与 pid 绝对值相等的所有子进程。

参数 options 是一个位掩码,可以用来设置一些选项,着重介绍其中的 WNOHANG。

WNOHANG:如果pid 指定的子进程没有结束,则 waitpid() 立即返回0,不予阻塞。这就提供了一种可以周期性轮询回收子进程的方法:

while(waitpid(pid, &status, WNOHANG) == 0) {
    printf("Smoking\n");
    sleep(1);
}

阻塞函数与非阻塞函数

  • 阻塞函数:为了完成某个功能发起函数调用,如果当前不具备完成条件,则一直等待直到条件满足,完成功能后函数返回。
  • 非阻塞函数:为了完成某个功能发起函数调用,如果当前不具备完成条件,则函数调用立刻返回。

2. 解读 status

虽然 status 是4字节,但系统只用到了其中的低2字节,如图所示。

如果进程正常终止,则其中的高8位存储了进程的终止状态,即调用 exit() 等函数传递的参数,而低8位全为0。

如果进程是被信号杀死的,则其中低7位存储了杀死进程的信号值,第八位标志是否产生了核心转储文件,而高8位没有用到。

从上面可以看出,想要区分这两种情况从低八位入手准没错,然后再通过一系列流畅的位运算可以计算出退出状态或者终止信号。但事实上也不需要这么麻烦,头文件 <sys/wait.h> 定义了一系列解析 status 的标准宏,例如 WIFEXITED(status)WIFSIGNALED(status)等。

3. 孤儿进程与僵尸进程

父子进程生命周期各不相同,这就引出了孤儿进程与僵尸进程的概念:

  • 父进程先于子进程终止,则子进程变成孤儿进程,而进程号为1的“进程之祖”接管该孤儿进程,即成为它的父进程。
  • 子进程先于父进程终止且父进程并未等待子进程,则子进程变为僵尸进程,系统释放其占有的大部分资源以供其他进程使用。该进程保留的唯一一样是内核进程表中的一条记录,其中包含该进程的ID、终止状态、资源使用数据等。

系统允许父进程在子进程变成僵尸进程后再调用wait() 函数来回收子进程。另一方面,如果父进程还没调用 wait() 就退出了,那么1号进程接管该僵尸进程后会自动调用 wait(),进而从系统中移除僵尸进程。

如果僵尸进程堆积过多,最终一定会填满内核进程表,从而阻碍新进程的创建。

4. SIGCHLD信号

子进程的终止属于异步事件。父进程无法预知子进程何时终止。所以父进程为了避免产生僵尸进程必须使用如下两种方法:

  • 父进程调用 wait() 或者不带 WNOHANG 标志的 waitpid() 函数,如果此时没有已经终止的子进程,则调用者会阻塞。
  • 父进程调用带 WNOHANG 标志的 waitpid() 函数用轮询的方式检查是否有终止的子进程。
    但这两种方法都并不怎么好,一方面可能并不希望进程为了等待子进程退出而一直被阻塞着,另一方面不停的轮询会造成CPU资源的浪费,并增加应用程序设计的复杂度。为了规避这一问题,可以为 SIGCHLD 信号建立信号处理器程序。

为 SIGCHLD 信号建立信号处理器程序

无论一个子进程何时终止,系统都会向父进程发送 SIGCHLD 信号。对该信号的默认处理方式是忽略,所以我们可以安装信号处理器函数来捕获它,并在内部调用 wait() 来回收子进程。这听起来是不是就比上面的那些显式调用 wait() 的方法好一些,不过使用这一方法时需要掌握一些诀窍。

可能编写上面的信号处理器函数的时候我们会这样写:

void handler(int sig) {
    wait(NULL);
}

但这段代码很明显忽略了一个严重的问题,即假如在调用这个信号处理器函数的时候我又收到了 SIGCHLD 信号怎么办?我们知道,调用信号处理器函数的时候引发调用的信号会暂时被加入信号掩码中,所以此时如果又来了一个 SIGCHLD信号它会被阻塞住加入 pending 信号集,但关键在于 pending 信号集是一个位图,即只能表示信号的发生而不能存储信号发生的次数。所以,如果在调用信号处理器函数时又不止一次收到 SIGCHLD 信号的话,一定会丢失其中的一些,从而导致有些僵尸进程成了“漏网之鱼”。所以,解决方法如下所示:

void handler(int sig) {
    while(waitpid(-1, NULL, WNOHANG) > 0) {
        continue;
    }
}

这样就基本可以解决上述问题。

最后修改:2019 年 11 月 10 日
如果觉得我的文章对你有用,请随意赞赏