8.4.3 回收子进程

8.4.3 回收子进程

当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收(reaped)。当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程,从此时开始,该进程就不存在了。一个终止了但还未被回收的进程称为僵死进程(zombie)。

旁注 为什么已终止的子进程被称为僵死进程?

在民间传说中,僵尸是活着的尸体,一种半生半死的实体。僵死进程已经终止了,而内核仍保留着它的某些状态直到父进程回收它为止,从这个意义上说它们是类似的。

如果一个父进程终止了,内核会安排 init 进程成为它的孤儿进程的养父。init 进程的 PID 为 1,是在系统启动时由内核创建的,它不会终止,是所有进程的祖先。如果父进程没有回收它的僵死子进程就终止了,那么内核会安排 init 进程去回收它们。不过,长时间运行的程序,比如 shell 或者服务器,总是应该回收它们的僵死子进程。即使僵死子进程没有运行,它们仍然消耗系统的内存资源。

一个进程可以通过调用 waitpid 函数来等待它的子进程终止或者停止。

#include <sys/types.h>
#include <sys/wait.h>

pid_t waitpid(pid_t pid, int *statusp, int options);

返回: 如果成功,则为子进程的 PID,如果 WNOHANG,则为 0,如果其他错误,则为 -1。

waitpid 函数有点复杂。默认情况下(当 options=0 时),waitpid 挂起调用进程的执行,直到它的等待集合(wait set)中的一个子进程终止。如果等待集合中的一个进程在刚调用的时刻就已经终止了,那么 waitpid 就立即返回。在这两种情况中,waitpid 返回导致 waitpid 返回的已终止子进程的 PID。此时,已终止的子进程已经被回收,内核会从系统中删除掉它的所有痕迹。

1. 判定等待集合的成员

等待集合的成员是由参数 pid 来确定的:

  • 如果 pid > 0,那么等待集合就是一个单独的子进程,它的进程 ID 等于 pid
  • 如果 pid = -1,那么等待集合就是由父进程所有的子进程组成的。

waitpid 函数还支持其他类型的等待集合,包括 Unix 进程组,对此我们将不做讨论。

2. 修改默认行为

可以通过将 options 设置为常量 WNOHANGWUNTRACEDWCONTINUED 的各种组合来修改默认行为:

  • WNOHANG:如果等待集合中的任何子进程都还没有终止,那么就立即返回(返回值为 0)。默认的行为是挂起调用进程,直到有子进程终止。在等待子进程终止的同时,如果还想做些有用的工作,这个选项会有用。
  • WUNTRACED:挂起调用进程的执行,直到等待集合中的一个进程变成已终止或者被停止。返回的 PID 为导致返回的已终止或被停止子进程的 PID。默认的行为是只返回已终止的子进程。当你想要检查已终止和被停止的子进程时,这个选项会有用。
  • WCONTINUED:挂起调用进程的执行,直到等待集合中一个正在运行的进程终止或等待集合中一个被停止的进程收到 SIGCONT 信号重新开始执行。(8.5 节会解释这些信号。)

可以用或运算把这些选项组合起来。例如:

  • WNOHANG | WUNTRACED:立即返回,如果等待集合中的子进程都没有被停止或终止,则返回值为 0;如果有一个停止或终止,则返回值为该子进程的 PID。

3. 检查已回收子进程的退出状态

如果 statusp 参数是非空的,那么 waitpid 就会在 status 中放上关于导致返回的子进程的状态信息,statusstatusp 指向的值。wait.h 头文件定义了解释 status 参数的几个宏:

  • WIFEXITED(status):如果子进程通过调用 exit 或者一个返回(return)正常终止,就返回真。
  • WEXITSTATUS(status):返回一个正常终止的子进程的退出状态。只有在 WIFEXITED() 返回为真时,才会定义这个状态。
  • WIFSIGNALED(status):如果子进程是因为一个未被捕获的信号终止的,那么就返回真。
  • WTERMSIG(status):返回导致子进程终止的信号的编号。只有在 WIFSIGNALED() 返回为真时,才定义这个状态。
  • WIFSTOPPED(status):如果引起返回的子进程当前是停止的,那么就返回真。
  • WSTOPSIG(status):返回引起子进程停止的信号的编号。只有在 WIFSTOPPED() 返回为真时,才定义这个状态。
  • WIFCONTINUED(status):如果子进程收到 SIGCONT 信号重新启动,则返回真。

4. 错误条件

如果调用进程没有子进程,那么 waitpid 返回 -1,并且设置 errnoECHILD。如果 waitpid 函数被一个信号中断,那么它返回 -1,并设置 errnoEINTR

旁注 和 Unix 函数相关的常量

WNOHANGWUNTRACED 这样的常量是由系统头文件定义的。例如,WNOHANGWUNTRACED 是由 wait.h 头文件(间接)定义的:

/* Bits in the third argument to 'waitpid'. */
#define WNOHANG    1  /* Don't block waiting. */
#define WUNTRACED  2  /* Report status of stopped children. */

为了使用这些常量,必须在代码中包含 wait.h 头文件:

#include <sys/wait.h>

每个 Unix 函数的 man 页列出了无论何时你在代码中使用那个函数都要包含的头文件。同时,为了检查诸如 ECHILDEINTR 之类的返回代码,你必须包含 errno.h。为了简化代码示例,我们包含了一个称为 csapp.h 的头文件,它包括了本书中使用的所有函数的头文件。csapp.h 头文件可以从 CS:APP 网站在线获得。

练习题 8.3 列出下面程序所有可能的输出序列:

code/ecf/waitprob0.c

int main()
{
    if (Fork() == 0) {
        printf("a"); fflush(stdout);
    }
    else {
        printf("b"); fflush(stdout);
        waitpid(-1, NULL, 0);
    }
    printf("c"); fflush(stdout);
    exit(0);
}

5. wait 函数

wait 函数是 waitpid 函数的简单版本:

#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *statusp);

返回: 如果成功,则为子进程的 PID,如果出错,则为 -1。

调用 wait(&status) 等价于调用 waitpid(-1, &status, 0)

6. 使用 waitpid 的示例

因为 waitpid 函数有些复杂,看几个例子会有所帮助。图 8-18 展示了一个程序,它使用 waitpid,不按照特定的顺序等待它的所有 N 个子进程终止。在第 11 行,父进程创建 N 个子进程,在第 12 行,每个子进程以一个唯一的退出状态退出。在我们继续讲解之前,请确认你已经理解为什么每个子进程会执行第 12 行,而父进程不会。

在第 15 行,父进程用 waitpid 作为 while 循环的测试条件,等待它所有的子进程终止。因为第一个参数是 -1,所以对 waitpid 的调用会阻塞,直到任意一个子进程终止。在每个子进程终止时,对 waitpid 的调用会返回,返回值为该子进程的非零的 PID。第 16 行检查子进程的退出状态。如果子进程是正常终止的——在此是以调用 exit 函数终止的——那么父进程就提取出退出状态,把它输出到 stdout 上。

code/ecf/waitpid1.c

#include "csapp.h"
#define N 2

int main()
{
    int status, i;
    pid_t pid;

    /* Parent creates N children */
    for (i = 0; i < N; i++)
        if ((pid = Fork()) == 0)  /* Child */
            exit(100+i);

    /* Parent reaps N children in no particular order */
    while ((pid = waitpid(-1, &status, 0)) > 0) {
        if (WIFEXITED(status))
            printf("child %d terminated normally with exit status=%d\n",
                   pid, WEXITSTATUS(status));
        else
            printf("child %d terminated abnormally\n", pid);
    }

    /* The only normal termination is if there are no more children */
    if (errno != ECHILD)
        unix_error("waitpid error");

    exit(0);
}

图 8-18 使用 waitpid 函数不按照特定的顺序回收僵死子进程

当回收了所有的子进程之后,再调用 waitpid 就返回 -1,并且设置 errnoECHILD。第 24 行检查 waitpid 函数是正常终止的,否则就输出一个错误消息。在我们的 Linux 系统上运行这个程序时,它产生如下输出:

linux> ./waitpid1
child 22966 terminated normally with exit status=100
child 22967 terminated normally with exit status=101

注意,程序不会按照特定的顺序回收子进程。子进程回收的顺序是这台特定的计算机系统的属性。在另一个系统上,甚至在同一个系统上再执行一次,两个子进程都可能以相反的顺序被回收。这是非确定性行为的一个示例,这种非确定性行为使得对并发进行推理非常困难。两种可能的结果都同样是正确的,作为一个程序员,你绝不可以假设总是会出现某一个结果,无论多么不可能出现另一个结果。唯一正确的假设是每一个可能的结果都同样可能出现。

图 8-19 展示了一个简单的改变,它消除了这种不确定性,按照父进程创建子进程的相同顺序来回收这些子进程。在第 11 行中,父进程按照顺序存储了它的子进程的 PID,然后通过用适当的 PID 作为第一个参数来调用 waitpid,按照同样的顺序来等待每个子进程。

code/ecf/waitpid2.c

#include "csapp.h"
#define N 2

int main()
{
    int status, i;
    pid_t pid[N], retpid;

    /* Parent creates N children */
    for (i = 0; i < N; i++)
        if ((pid[i] = Fork()) == 0)  /* Child */
            exit(100+i);

    /* Parent reaps N children in order */
    i = 0;
    while ((retpid = waitpid(pid[i++], &status, 0)) > 0) {
        if (WIFEXITED(status))
            printf("child %d terminated normally with exit status=%d\n",
                   retpid, WEXITSTATUS(status));
        else
            printf("child %d terminated abnormally\n", retpid);
    }

    /* The only normal termination is if there are no more children */
    if (errno != ECHILD)
        unix_error("waitpid error");

    exit(0);
}

图 8-19 使用 waitpid 按照创建子进程的顺序来回收这些僵死子进程

练习题 8.4 考虑下面的程序:

code/ecf/waitprob1.c

int main()
{
    int status;
    pid_t pid;

    printf("Hello\n");
    pid = Fork();
    printf("%d\n", !pid);
    if (pid != 0) {
        if (waitpid(-1, &status, 0) > 0) {
            if (WIFEXITED(status) != 0)
                printf("%d\n", WEXITSTATUS(status));
        }
    }
    printf("Bye\n");
    exit(2);
}

A. 这个程序会产生多少输出行?

B. 这些输出行的一种可能的顺序是什么?