26 监控子进程 wait()

子进程的状态

1. 状态的分类

  • 调用 _exit() 正常退出(无论是手动调用还是运行时在 main() 函数退出后自动调用)。
  • 收到信号而终止。
  • 收到信号而暂停执行。
  • 收到信号而恢复执行。

2. 如何在父进程中区分子进程状态

为了区分这 4 种状态,等待函数中 int 类型的输出参数实际上会有低 2 个字节被使用(虽然实际上返回状态只需要用 1 个字节表示):

可以分别通过 WIFEXITED (status)WIFSIGNALED (status)WIFSTOPPED (status)WIFCONTINUED (status) 来判断是否属于这几种情况。这几种情况是互斥的。在进程被信号所杀时,内核转储标志可能会被设置,这时如果宏 WCOREDUMP 被定义,可以用 WCOREDUMP 来检查内核转换标志是否被设置。

3. 在信号处理函数中终止子进程

如果需要对某个信号做出一定的处理再终止子进程,则不能调用 _exit() 函数,否则子进程会被视为正常终止。如果想要让子进程终止时可以反应真实的终止原因,需要在信号处理器函数中做出善后处理之后,再将该信号的处置方式设置成默认,然后用 raise() 系统调用发送相同的信号。

用来等待子进程的 API

pid_t wait(int *_Nullable wstatus);
pid_t waitpid(pid_t pid, int *_Nullable wstatus, int options);  -------+
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);   |
               /* This is the glibc and POSIX interface; see           |
                  NOTES for information on the raw system call. */     |
                                                                       |
                            +------------------------------------------+
                            |
                            V
pid_t wait3(int *_Nullable wstatus, int options,                     
           struct rusage *_Nullable rusage);
pid_t wait4(pid_t pid, int *_Nullable wstatus, int options,
           struct rusage *_Nullable rusage);

wait 是等待任一子进程停止。

waitpidwait 多了两个参数,其中 pid 参数是等待给定子进程停止,但其含义很多,详见 man 手册;options 参数则可以指定其他标志:WUNTRACED 表示还会返回因信号暂停的进程的信息,WCONTINUED 表示还会返回因信号恢复执行的进程的信息,WNOHANG 表示本次操作转换为轮询,在有子进程、但子进程状态未改变时立即返回 0。

在 Linux 中,waitpid 还有一些别的 options 可以使用,这些选项和 Linux 特有的 clone() 系统调用相关:__WCLONE__WALL__WNOTHREAD

wait3wait4waitpid 很像,但是可以额外获取子进程的资源信息。wait3 还额外固定了 pid 这个参数为 -1(表示等待任意一个子进程。

waitid 源于 System V,已经被 SUSv3 采纳,并从 2.6.9 版本开始加入了 Linux 内核。其参数含义如下:

  1. idtype == P_ALL 时,表示等待任何子进程;idtype == P_PID 时,表示等待特定子进程;idtype == P_PGID 时,表示等待特定进程组内的任意子进程。
  2. idtype 不同时,id 的含义不同。
  3. infop 参数提供了 waitpid 等系统调用无法获取的、更加详细的子进程信息。(看了一下这个类型,好像里面的信息也没有特别有用?)
  4. options 参数提供的控制也比 waitpid 更加精细。尤其是可以控制只等待某些类型的子进程事件。
    1. WEXITED 表示等待已经终止的子进程,无论其是否正常返回。在 waitpid 中无法控制,相当于永远启用。(因信号而终止的子进程应该也在此列。)
    2. WSTOPPED 表示等待已通过信号而停止的子进程。相当于是 waitpid 中的 WUNTRACED(命名是历史遗留问题)。
    3. WCONTINUED 表示等待通过 SIGCONT 信号恢复的子进程。
    4. WNOHANG 表示本次操作转换为轮询,同 waitpid
    5. WNOWAIT 表示虽然 waitid() 本次返回了子进程的信息,但是不将其回收,下次还能再获取相同的信息。就像输入流的 peek 操作一样。

孤儿进程和僵尸进程

之所以有僵尸进程,是因为系统需要提供一种方式让父进程来检查子进程的信息。在父进程调用 wait() 系列的系统调用获取已结束的子进程的信息之前,子进程的绝大部分资源可以释放,但是关于子进程的信息条目需要被保留下来。这些条目仍然会需要占用内核的资源(子进程的进程号、终止状态、资源使用数据等),所以如果有大量僵尸进程会影响系统的正常运行。

如果自己还在运行,但是父进程没了,那么就成为了孤儿进程。孤儿进程会被 init 进程收养,从而保证其退出时资源可以正常被回收。如果有进程成为僵尸,可以将其父进程杀死(2025/4/21 前提是 init 能正确回收它,某些容器环境的 init 可能不具备这个能力)。

Tip

僵尸进程概念的引入是操作系统为了“父进程能检查子进程退出状态及资源”这盘醋包的饺子。

Warning

僵尸进程是杀不死的,只能由父进程来回收。所以如果父进程不去回收它,应该杀掉其父进程,使僵尸被 init 收养并回收。

SIGCHLD 信号

1. 正确回收子进程的资源

先设置好 SIGCHLD 的信号处理器,在信号处理器函数中用循环的方式轮询(WNOHANG)回收子进程。这种方式也是可移植性最强的。

Caution

由于 waitpid 可能会修改 errno,虽然它是异步信号安全的,但是是不可重入的。最好在调用前保存 errno,返回信号处理器函数前恢复。

2. 显式忽略 SIGCHLD 信号(也是一种回收子进程资源的方式,但不可移植)

虽然 SIGCHLD 信号的默认处理方式就是忽略,但是还是可以调用信号处理器函数来显式忽略 SIGCHLD,这样做的语义是有子进程退出时自动回收资源,无需先将其转换成僵尸进程。其实这也是和默认处理方式有区别的,因为默认处理方式是 SIG_DFL,改成 SIG_IGN 也算是有变化了。

不可移植性:在包括 Linux 在内的实现中,这不会影响已经成为僵尸的进程,而 Solaris 8 则会将僵尸进程一并删除。而且在较老的 UNIX 实现中,忽略 SIGCHLD 信号并没有阻止僵尸进程创建的语义。因而这样的操作是不可移植的。

其他影响:SUSv3 规定,将 SIGCHLD 处理方式设置成 SIG_IGN,还会丢弃子进程的资源使用信息。这会影响带有 RUSAGE_CHILDREN 标志的 getrusage(),以及 times() 函数等。

3. 什么时候父进程会收到 SIGCHLD?

在子进程状态发生变化的时候父进程就会收到 SIGCHLD,而并不一定是子进程退出的时候。调用 sigaction() 来设置 SIGCHLD 的处理方式时可以指定 SA_NOCLDWAIT 标志禁止子进程在暂停或者恢复执行时向父进程传递 SIGCHLD 信号。

之所以 wait 默认不等待暂停 / 恢复的进程,而 SIGCHLD 在子进程暂停 / 恢复时也默认传递,可能是因为暂停 / 恢复的子进程并不是僵尸,不需要回收。

4. sigaction()SA_NOCLDWAIT 标志(不可移植)

类似显式设置 SIGCHLD 的处理方式为 SIG_IGN 的效果,子进程资源会被自动回收。但是 SUSv3 没有规定系统在子进程终止时是否还需要发送 SIGCHLD 信号。在 Linux 在内的一些实现中,系统还会产生 SIGCHLD 信号,信号处理器函数会执行,但是无法用 wait() 获得子进程信息(因为被丢弃了)。而在有的实现中,则由于不产生信号而不会进入信号处理器函数,就和 SIG_IGN 一样。

5. System V 的 SIGCLD 信号

历史原因。Linux 中现在同时有 SIGCLD 和 SIGCHLD,两者意义相同。