【Linux】进程等待
📖 博主简介:

概览
- 僵尸进程为什么必须收
- wait 与 waitpid 的参数分工
- status 低十六位里的数据
- 阻塞等待
- WNOHANG 轮询与阻塞等待的现场差别
- 多个子进程被回收的先后顺序
- 回收失败时 errno 里的值
- SIGCHLD
核心知识
一、为什么必须等
子进程退出的那一刻,内核不会把它留下的痕迹一次抹干净。地址空间可以拆掉,打开的文件可以关掉,但有两样东西必须留着:一样是子进程的退出状态,另一样是它在进程表里占的那个位置。前者是父进程迟早要读走的结果,后者是内核替这个结果安排的存放处。父进程把结果读走之前,这个位置不能撤。处在这一步的进程在 ps 里的 STAT 是 Z,命令后面还会多出一个 的标记。
先把僵尸窗口用回显摊开,下面这段程序 fork 之后让子进程立刻用退出码 5 离开,父进程故意拖 6 秒才去回收,中间那 6 秒就是僵尸窗口:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void)
{
pid_t pid = fork();
int st = 0;
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
printf("子进程 %d 立刻用退出码 5 离开\n", getpid());
fflush(stdout);
_exit(5);
}
printf("父进程 %d 先不回收,睡 6 秒,这 6 秒里子进程是僵尸\n", getpid());
fflush(stdout);
sleep(6);
waitpid(pid, &st, 0);
printf("回收完成,读出退出码 %d,再睡 2 秒\n", WEXITSTATUS(st));
fflush(stdout);
sleep(2);
printf("父进程离开\n");
return 0;
}
程序放在后台跑,趁它还没走到回收那一步,从另一个方向看父子两个进程的状态:
--- 父进程 27519 的状态 ---
PID PPID STAT WCHAN CMD
27519 27445 S+ hrtimer_nanosleep ./bin/wait_zombie_window
--- 父进程名下的子进程 ---
PID PPID STAT WCHAN CMD
27521 27519 Z+ do_exit [wait_zombie_win] <defunct>
--- 回收之后再看父进程名下的子进程 ---
PID PPID STAT WCHAN CMD
第二条命令输出里只有一个子进程,状态栏写着 Z+,命令一栏用方括号括起程序名,末尾挂着 。WCHAN 是 do_exit,说明它的内核路径停在退出流程上,不会再往下走。第三条命令在回收之后执行,同一个父进程名下的子进程列表已经空了,那个位置被还了回去。整个过程中父进程一直是 S+,睡眠与回收都有明确的去处。
接着看内核在这个时刻给僵尸进程留了什么。用一个打印自身孩子号的程序起一个僵尸,再去读它的 /proc 目录:
孩子号 28264
--- /proc/28264/status ---
Name: zombie_stat
State: Z (zombie)
PPid: 28262
--- /proc/28264/stat 全文 ---
28264 (zombie_stat) Z 28262 28161 28161 34816 28161 4227148 23 0 0 0 0 0 0 0 20 0 1 0 2673196736 0 0 0 0 0 0 0 6 0 18446744073709551615 0 0 17 1 0 0 0 0 0 0 0 0 0 0 0 0 0
status 文件里 State 一栏写着 Z (zombie),PPid 还指着父进程。stat 文件的字段是固定顺序的,第 3 个字段就是状态,第 38 个字段是 17,也就是 SIGCHLD 的编号,意思还是子进程默认用这颗信号通知父进程自己走了。往后跟地址空间有关的几个字段(start_data、end_data、start_brk,以及参数表和 environ 表的起止地址)全是 0,因为 mm 结构已经拆掉了。留在进程表里的这具空壳只负责一件事:让父进程还能把它读回去。
想到这里自然会出现一个问题:内核为什么不干脆把退出信息丢掉,连同 PCB 一起删掉?因为这个位置上的数据是父子之间的一次交接。父进程派了活,子进程干完交出结论,结论要送回父进程手上。内核要是自作主张丢掉结论,父进程就永远不知道自己交代的事情做成了没有。反过来,父进程若是不在意结论,也应该明确地把这一次回收做掉,把 PCB 还回去,回收这件事没有会自动发生的那一天。
第 9 篇复现过僵尸进程,当时停在现象上:ps 里挂着一个 Z 状态的进程,kill -9 对它也没有反应,因为信号要发给一个还能接收信号的执行流,而僵尸的执行流早就结束了。那一篇把僵尸的成因与危害讲完,收尾留了一句要等,这一次把这个等字落到接口上。回收的动作由父进程发起,接口有两个,一个参数最少,一个参数最全。

图 1 僵尸从产生到被回收
二、wait 的参数与返回值
wait 的声明放在 sys/wait.h 里,参数只有一个,是指向整型的指针:
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
这个指针是输出型参数,内核会把子进程的退出信息写进去。它的特殊之处在于可以传 NULL,表示父进程只关心孩子有没有走完,不关心它留下什么话。传 NULL 的时候回收照样发生,僵尸照样消失,下面这一段就是回显:
父进程 27882 先睡 3 秒,这段时间孩子是僵尸
--- 还没回收时父进程 27882 名下的孩子 ---
PID PPID STAT WCHAN CMD
27884 27882 Z+ do_exit [wait_status_nul] <defunct>
status 传 NULL,wait 照样返回 27884 并把孩子收掉
返回值是被回收的子进程号,父进程手上通常存着 fork 的返回值,也就是孩子的号,两者对得上才说明这一次 wait 收到的是自己的孩子。wait 的失败情形里最常见的是没有孩子可等,这一点留到第十节讲。它还有一个容易忽略的行为:等到的是谁并不由父进程决定,哪一孩子先退出就先收谁,父子之间的对应关系在这一步被交给调度器决定。
把一次完整的回收连参数一起看,下面这段程序让子进程睡 2 秒再用退出码 10 离开,父进程拿到 status 之后手工拆一遍位,再用宏拆一遍:
子进程 27490 先睡 2 秒,再用退出码 10 离开
wait 返回 27490,子进程号是 27490
status 原始值 2560,十六进制 0x0a00
低 7 位 st & 0x7f = 0
高 8 位 (st >> 8) & 0xff = 10
WIFEXITED = 1
WEXITSTATUS = 10
WIFSIGNALED = 0
WTERMSIG = 0
WCOREDUMP = 0
十进制 2560 换算成十六进制是 0x0a00,退出码 10 落在高 8 位上,低 7 位是 0,意思是这个进程正常走完。这几个数字怎么来的、怎么读,是这一篇后半段的主要内容。
三、waitpid 的三个参数
waitpid 比 wait 多两个参数,第一个指明等谁,第三个指明用什么方式等:
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
第一个参数 pid 不是简单的一个进程号,它有四种填法,下一节单独讲。第二个参数 status 与 wait 的完全一样,也是输出型参数,也可以传 NULL。第三个参数 options 默认填 0,表示一路阻塞等待;把 WNOHANG 或进去,就变成问一句就走。
返回值比 wait 多出一种情况:收到孩子时返回孩子的进程号;带了 WNOHANG 而孩子一个都没退出时返回 0;调用出错时返回 -1,errno 会被填上对应的值。这三种返回值的区分在写轮询的时候必须处理好,把 0 当成失败来处理是很常见的写法错误,父进程会提前退出循环,然后对着一个没有被填过的 status 去调 WEXITSTATUS。
pid 填 -1 的时候,waitpid 与 wait 做的事情一样,都是等任意一个孩子:
########## strace 里父进程卡在哪个系统调用 ##########
27792 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f03d42e9a10) = 27793
27792 wait4(27793, <unfinished ...
27792 <... wait4 resumed>[{WIFEXITED(s) && WEXITSTATUS(s) == 9}], 0, NULL) = 27793
27792 --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=27793, si_uid=1000, si_status=9, si_utime=0, si_stime=0} ---
这一段是 strace 的输出,第一行是 fork 落到的 clone,第二行是父进程调用 waitpid 指定了孩子号 27793 时进入系统调用的那一刻,第三行是这个调用返回的样子,第四行是内核随后投递的 SIGCHLD。用 wait 来写同一件事,第二行里那个位置会变成 -1。两个函数在接口上有区别,落到系统调用层是同一个 wait4,靠第一个参数区分等谁。

图 2 wait 与 waitpid 的参数对照
四、pid 的四种取值
waitpid 的第一个参数有四种填法,它们的含义按正负分开:
| pid 取值 | 等谁 | 典型场景 |
|---|---|---|
| 大于 0 | 进程号等于这个值的那个孩子 | 父进程记下了具体是哪个孩子,只等它 |
| 等于 -1 | 任意一个孩子 | 与 wait 等价,谁先退出就先收谁 |
| 等于 0 | 与调用者同进程组的任意一个孩子 | 多个孩子各自分组时按组收 |
| 小于 -1 | 进程组号等于绝对值的任意一个孩子 | 父进程按组批量收 |
后两种填法都跟进程组有关,进程组是进程的一个属性,同一组的进程共享一个组号,父进程与它 fork 出来的孩子默认落在同一个组里。把这件事直接跑一遍,父进程与两个孩子的组号都打印出来,再用 pid 填 0 的 waitpid 去轮询:
父进程 27840,进程组 27672
第 1 个孩子 27841,进程组 27672,睡 1 秒后退出码 61
两个孩子的进程组号 27672 和 27672,父进程用 pid 为 0 的 waitpid 轮询
第 1 轮 waitpid(0, &st, WNOHANG) 返回 0,errno=0(Success)
第 2 个孩子 27842,进程组 27672,睡 3 秒后退出码 62
第 2 轮 waitpid(0, &st, WNOHANG) 返回 0,errno=0(Success)
第 3 轮 waitpid(0, &st, WNOHANG) 返回 27841,收掉的是 27841,退出码 61
再用负的进程组号等一次,回来的是 27842,退出码 62
两个孩子的组号与父进程一样,都是 27672。pid 填 0 的 waitpid 在第三轮收到了先退出的那个孩子,返回 0 的两轮说明这个组里还没有走掉的孩子。第二个孩子随后用负的组号等回来,负号后面跟的就是这个组号。这两种填法在批量管理进程时有用,比如父进程派出去一组分工相同的孩子,收的时候按组收,接口上就不用一个个记号。
把具体进程号这一种填法单独跑一遍,回收的顺序会看得更清楚。下面这段程序 fork 出三个孩子,分别睡 3 秒、1 秒、2 秒,父进程按创建顺序挨个指定进程号去等:
第 1 个子进程 27820 睡 3 秒,退出码 11
第 2 个子进程 27821 睡 1 秒,退出码 22
第 3 个子进程 27822 睡 2 秒,退出码 33
指定等第 1 个孩子 27820,回来的是 27820,退出码 11,已过去 3 秒
指定等第 2 个孩子 27821,回来的是 27821,退出码 22,已过去 3 秒
指定等第 3 个孩子 27822,回来的是 27822,退出码 33,已过去 3 秒
第一次调用等了 3 秒才回来,因为指定的那个孩子睡得最久。后面两次调用几乎立刻返回,那两个孩子早就退出成了僵尸,一直在等父进程来收。指定进程号的写法能改变回收顺序,代价是父进程会被那个指定的孩子卡住,如果它排在最后退出,父进程就要陪着它等完全场。
五、status
status 是输出型参数,内核填什么父进程就读什么。它的含义要按位去解释,只有低 16 位有意义,退出码并不是原样装进去的,这 16 位在正常结束与被信号终止这两种情况下有两种不同的装法。
子进程正常走完(调用 exit、_exit,或者 main 里 return)的时候,内核把退出码放进高 8 位,低 7 位留 0,中间的第 7 位放 core dump 标志:
正常终止:
15 8 7 0
┌──────────────┬──┬───────────┐
│ 退出码 8 位 │标志│ 全 0 │
└──────────────┴──┴───────────┘
子进程被信号终止的时候,低 7 位放的是终止它的信号编号,高 8 位是 0:
被信号终止:
15 8 7 0
┌──────────────┬──┬───────────┐
│ 全 0 │标志│ 信号 7 位 │
└──────────────┴──┴───────────┘
两张图里那个标志位就是 status 的 0x80,父进程用 WCOREDUMP 读它,含义是这个进程是不是吐出了 core 文件。它跟内核的 core 文件策略连在一起,把同一个程序在两种 ulimit 设置下各跑一遍,两种结果都出现。先把退出码这一组跑出来:
core 文件上限:unlimited
---- 正常退出这一组 ----
exit(0) status=0x0000 低7位= 0 高8位= 0 WIFEXITED=1 WEXITSTATUS= 0 WIFSIGNALED=0 WTERMSIG= -1 WCOREDUMP=0
exit(1) status=0x0100 低7位= 0 高8位= 1 WIFEXITED=1 WEXITSTATUS= 1 WIFSIGNALED=0 WTERMSIG= -1 WCOREDUMP=0
exit(255) status=0xff00 低7位= 0 高8位=255 WIFEXITED=1 WEXITSTATUS=255 WIFSIGNALED=0 WTERMSIG= -1 WCOREDUMP=0
exit(256) status=0x0000 低7位= 0 高8位= 0 WIFEXITED=1 WEXITSTATUS= 0 WIFSIGNALED=0 WTERMSIG= -1 WCOREDUMP=0
exit(300) status=0x2c00 低7位= 0 高8位= 44 WIFEXITED=1 WEXITSTATUS= 44 WIFSIGNALED=0 WTERMSIG= -1 WCOREDUMP=0
---- 被信号终止这一组 ----
SIGKILL status=0x0009 低7位= 9 高8位= 0 WIFEXITED=0 WEXITSTATUS= -1 WIFSIGNALED=1 WTERMSIG= 9 WCOREDUMP=0
SIGTERM status=0x000f 低7位=15 高8位= 0 WIFEXITED=0 WEXITSTATUS= -1 WIFSIGNALED=1 WTERMSIG= 15 WCOREDUMP=0
SIGINT status=0x0002 低7位= 2 高8位= 0 WIFEXITED=0 WEXITSTATUS= -1 WIFSIGNALED=1 WTERMSIG= 2 WCOREDUMP=0
SIGSEGV status=0x008b 低7位=11 高8位= 0 WIFEXITED=0 WEXITSTATUS= -1 WIFSIGNALED=1 WTERMSIG= 11 WCOREDUMP=1
---- 真去踩一次整数除零 ----
1/0 status=0x0088 低7位= 8 高8位= 0 WIFEXITED=0 WEXITSTATUS= -1 WIFSIGNALED=1 WTERMSIG= 8 WCOREDUMP=1
表里的数字逐行都有说法,exit(0) 的 status 是 0x0000,因为退出码 0 落在高 8 位,整个值就是零。exit(255) 是 0xff00,低 7 位仍然是 0。exit(256) 是 0x0000,与 exit(0) 一模一样,因为内核只留退出码的低 8 位,256 的低八位是 0。exit(300) 换成十六进制是 0x12c,低八位是 0x2c,也就是十进制 44,表里正是 44。
被信号终止的这一组里,低 7 位换成了信号编号:SIGKILL 是 9,SIGTERM 是 15,SIGINT 是 2。SIGSEGV 那一行的 status 是 0x008b,低七位还是 11,多出来的 0x80 正是标志位,表示这个进程在收到 SIGSEGV 之后吐了 core 文件。最后一行是故意去踩一次整数除零,内核发给它的是 8 号信号 SIGFPE,标志位同样立起来。
把 core 文件上限改成 0,还是同一份程序,标志位就不见了:
core 文件上限:0
SIGSEGV status=0x000b 低7位=11 高8位= 0 WIFEXITED=0 WEXITSTATUS= -1 WIFSIGNALED=1 WTERMSIG= 11 WCOREDUMP=0
1/0 status=0x0008 低7位= 8 高8位= 0 WIFEXITED=0 WEXITSTATUS= -1 WIFSIGNALED=1 WTERMSIG= 8 WCOREDUMP=0
两次出现的信号编号没变,变化的只有标志位。core 文件是该进程能不能写出文件这件事,受 ulimit 控制,不受信号本身控制。顺带把文件找出来:这台机器的 core_pattern 是 core,程序的工作目录下真的多出一个 core 文件名加编号的文件,大小 249856 字节。写完 core 文件之后才轮到父进程来收,标志位的信息就是在这个过程中被记进 status 的。

图 3 status 的低十六位
六、 status 的四个宏
手工拆位能看清结构,写代码时不适合到处写 st & 0x7f。头文件里给了几个宏,把这几个位运算包了起来,四个常用的宏与它们背后的表达式是这样的:
| 宏 | 等价表达式 | 管什么 |
|---|---|---|
| WIFEXITED(status) | (status & 0x7f) == 0 | 是不是正常结束 |
| WEXITSTATUS(status) | (status >> 8) & 0xff | 正常结束时的退出码 |
| WIFSIGNALED(status) | 低位不为 0 且不是被跟踪停下 | 是不是被信号终止 |
| WTERMSIG(status) | status & 0x7f | 终止它的信号编号 |
| WCOREDUMP(status) | status & 0x80 | 有没有吐出 core 文件 |
宏的使用有一个前後顺序:先判断类别,再取对应的码。WEXITSTATUS 只在 WIFEXITED 为真时才有意义,WTERMSIG 只在 WIFSIGNALED 为真时才有意义。前面那张表里的 WTERMSIG 一栏出现 -1,那是程序在打印时自己填的默认值,写法是先判断 WIFSIGNALED,为假就打印 -1,并不是这个宏返回了负数。把这层包装放进一个函数里,回收之后统一打印一次,代码会比散落的位运算清楚:
static void show_status(pid_t pid, int status)
{
if (WIFEXITED(status)) {
printf("孩子 %d 正常结束,退出码 %d\n", pid, WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("孩子 %d 被 %d 号信号终止", pid, WTERMSIG(status));
if (WCOREDUMP(status))
printf(",并且留下了 core 文件");
printf("\n");
}
}
这段函数把三种情况分开:正常结束打印退出码,被信号终止打印信号编号,core 文件标志单独补一句。真正写业务代码时,父进程往往只关心第一种情况,第二种情况更适合记进日志,因为它意味着孩子遇到了自己处理不了的错误。
七、阻塞等待
默认的 waits 是阻塞的:孩子还在跑的时候,父进程会停在这行代码上不动。停这个词容易让人想到忙等,实际不是,父进程会被内核挂起,CPU 交给别的进程用,进程状态从 R 变成 S。下面这段程序 fork 之后让子进程睡 5 秒,父进程先去 wait,在外面看它停在哪儿:
--- 父进程 27739 这一刻的状态 ---
PID PPID STAT WCHAN CMD
27739 27672 S+ do_wait ./bin/wait_block
--- 孩子这一刻的状态 ---
PID PPID STAT WCHAN CMD
27741 27739 S+ hrtimer_nanosleep ./bin/wait_block
--- 程序自己的输出 ---
父进程 27739 先停 1 秒,外面好看清状态
子进程 27741 睡 5 秒,父进程这段时间会卡在 wait 里
父进程现在进 wait
wait 返回 27741,退出码 9
两行 ps 采样正好把这一刻的父子关系摆出来。孩子的 WCHAN 是 hrtimer_nanosleep,它睡在定时器上;父进程的 WCHAN 是 do_wait,它睡在等待孩子这件事上。STAT 一栏两行都是 S+,都在等,一个等时间过去,一个等另一个进程结束。
系统调用层能看到同样的停顿,strace 把 waitpid 的进入和返回分成两行打印,中间是孩子活着的那 5 秒:
27792 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f03d42e9a10) = 27793
27792 wait4(27793, <unfinished ...
27792 <... wait4 resumed>[{WIFEXITED(s) && WEXITSTATUS(s) == 9}], 0, NULL) = 27793
27792 --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=27793, si_uid=1000, si_status=9, si_utime=0, si_stime=0} ---
第二行末尾的 <unfinished …> 是停在里面的标记,第三行的 <… wait4 resumed> 是同一个调用醒过来的标记。方括号里那段 {WIFEXITED(s) && WEXITSTATUS(s) == 9} 是 strace 自己做的解析,它读的就是第三节里那张位图。最后一行是内核投递的 SIGCHLD,里面带着 si_code 与 si_status,说明孩子是自己退出的,退出码是 9。
阻塞等待适合父进程手上没有别的活的场景,比如父进程 fork 出孩子之后唯一要做的就是等结果,那么阻塞式的一次调用最直接,一个函数调用就换回一个孩子的结果,中间不需要任何循环。它的代价也清楚:孩子要是卡住了,父进程会一起卡住,而且一卡就是永久。

图 4 父进程阻塞在 do_wait 上
八、非阻塞轮询:WNOHANG
options 里或上 WNOHANG,等待就变成问一句:孩子退了没有?没有就返回 0,父进程接着往下跑。下面这段程序让子进程睡 3 秒,父进程每 0.3 秒问一次,两问问之间干一点计算量明确的活:
父进程进入非阻塞轮询,等的同时顺手干活
子进程 27803 睡 3 秒,然后用退出码 7 离开
第 1 轮:子进程还没退出,waitpid 返回 0,累计 3999800000
第 2 轮:子进程还没退出,waitpid 返回 0,累计 7999600001
第 3 轮:子进程还没退出,waitpid 返回 0,累计 11999400003
第 4 轮:子进程还没退出,waitpid 返回 0,累计 15999200006
第 5 轮:子进程还没退出,waitpid 返回 0,累计 19999000010
第 6 轮:子进程还没退出,waitpid 返回 0,累计 23998800015
第 7 轮:子进程还没退出,waitpid 返回 0,累计 27998600021
第 8 轮:子进程还没退出,waitpid 返回 0,累计 31998400028
第 9 轮:子进程还没退出,waitpid 返回 0,累计 35998200036
第 10 轮:子进程还没退出,waitpid 返回 0,累计 39998000045
子进程退出后 waitpid 返回 27803,退出码 7,一共轮询了 10 次
每一轮都真的在干活,最后一列的数字在往上走,父进程没有白白停在那里。轮询的代价是系统调用被多调了 10 次,孩子在这 3 秒里的前 2.9 秒都在被一遍遍问同一个问题。轮询间隔是这对矛盾的调节旋钮,间隔调大省系统调用,但孩子退出到父进程发现之间会多出最多一个间隔的延迟。
非阻塞在系统调用层只多了一个标志位,同一个程序放进 strace,前面十行长得一模一样,只有最后一行变了样:
27808 wait4(27809, 0x7ffe3732b2cc, WNOHANG, NULL) = 0
27808 wait4(27809, 0x7ffe3732b2cc, WNOHANG, NULL) = 0
27808 wait4(27809, 0x7ffe3732b2cc, WNOHANG, NULL) = 0
27808 wait4(27809, 0x7ffe3732b2cc, WNOHANG, NULL) = 0
27808 wait4(27809, 0x7ffe3732b2cc, WNOHANG, NULL) = 0
27808 wait4(27809, 0x7ffe3732b2cc, WNOHANG, NULL) = 0
27808 wait4(27809, [{WIFEXITED(s) && WEXITSTATUS(s) == 7}], WNOHANG, NULL) = 27809
前六行是同一行代码的重复执行,每一次都在 flag 位置带着 WNOHANG,返回值都是 0。最后一行是孩子退出之后的那一次询问,返回值换成了孩子的进程号,方括号里出现了退出码 7。用 WNOHANG 写循环时,返回 0 表示还没到时候,应该继续轮询;返回 -1 才是真的出错,这两种返回值的区别是这一节最容易写错的地方。

图 5 WNOHANG 轮询的时间轴
九、多个子进程的回收顺序
一个父进程可以有好几个孩子,回收顺序按谁先退出排,与创建顺序无关。下面这段程序 fork 出三个孩子,退出时间分别是 3 秒、1 秒、2 秒,父进程连续调用三次 wait:
父进程建好第 1 个子进程,进程号 27537
第 1 个子进程 27537 打算睡 3 秒,退出码 11
父进程建好第 2 个子进程,进程号 27538
第 2 个子进程 27538 打算睡 1 秒,退出码 22
父进程建好第 3 个子进程,进程号 27539
第 3 个子进程 27539 打算睡 2 秒,退出码 33
第 1 次 wait 回来的是 27538,退出码 22
第 2 次 wait 回来的是 27539,退出码 33
第 3 次 wait 回来的是 27537,退出码 11
三次回收的次序是 27538、27539、27537,对应睡 1 秒、2 秒、3 秒的那三个。第一个被创建的孩子排在最后,因为它睡得最久。父进程拿到进程号之后就可以对着自己的记录查,看这一次收的是哪一个任务的结论。
要把几个孩子一次收干净,标准的写法是一个循环,把第一个参数填 -1,每收一个就往回拿一个进程号,收到没有为止:
四个孩子分别睡 1 到 4 秒,退出码 100 到 103
第 1 次收到 27828,退出码 100
第 2 次收到 27829,退出码 101
第 3 次收到 27830,退出码 102
第 4 次收到 27831,退出码 103
循环退出,waitpid 返回 -1,errno=10(No child processes),一共收了 4 个
循环的出口靠的就是下一节要讲的错误码,四个孩子收完之后再问一次,内核给的是 -1 加 errno 10。把 0 当成循环出口的写法在这里会死循环,因为阻塞模式下没有孩子退出时不会返回 0,它会一直等着。三种回收需求与对应的写法整理成一张表:
| 需求 | 写法 | 返回值含义 |
|---|---|---|
| 只要一个结论,不在乎是谁 | wait(&st) | 收到孩子号,或者 -1 |
| 一次收全部已经退出的孩子 | 循环 waitpid(-1, &st, 0) | 正数是孩子号,-1 且 errno 为 10 表示收完 |
| 一边等一边干别的 | 循环 waitpid(-1, &st, WNOHANG) | 0 表示这次没人退出,正数是孩子号 |

图 6 三个孩子的回收顺序
十、回收失败与被打断
回收这件事不是每次都能成功,父进程手上的那个进程号可能早就作废了。把几种失败凑在一个程序里跑,回显里的 errno 各不相同:
一个子进程都没有时 wait 返回 -1,errno=10(No child processes)
第一次收 27826,返回 27826,退出码 4
对同一个进程号再收一次,返回 -1,errno=10(No child processes)
去等 1 号进程,返回 -1,errno=10(No child processes)
带一个乱写的选项,返回 -1,errno=22(Invalid argument)
第一行是当前进程一个孩子都没有时调用 wait,内核给的是 ECHILD,编号 10,意思是没有可以等的孩子。第三行是同一个孩子被收过之后再收一次,同一个进程号已经不在父进程的待收名单里,也是 ECHILD。第四行去等 1 号进程,它确实存在,但它不是当前进程的孩子,父进程只能收自己派出去的那些,这一条也是 ECHILD。原因在进程表里写得很死,PCB 里记着父进程是谁,收回的动作只对父子关系成立。最后一行的 EINVAL 是 options 里塞了内核不认识的东西,编号 22。
另一种失败落在回收动作被打断上,父进程的代码本身没有写错。父进程停在 wait 里的时候,来了一个带处理函数的信号,内核会先把处理函数跑完,再回到 wait 那一行,这时候 errno 是 EINTR:
父进程 28043 装了一个 2 秒的闹钟,然后进 wait
子进程 28044 睡 5 秒
闹钟响了,处理函数跑了一下
第一次 wait 返回 -1,errno=4(Interrupted system call)
被闹钟打断了,重新等一次
第二次 wait 返回 28044,退出码 12,errno=0
第一次调用在闹钟响的那一刻返回了 -1,errno 是 4。第二次调用从原处重新等,等回来的是孩子的正常退出。把 2 秒的闹钟与 5 秒的孩子放在一起,是为了让打断这件事必然发生在等待中间。写代码时这一条容易被忽略:父进程如果在 wait 返回 -1 时直接打印错误退出,那么任何一次闹钟、任何一次子进程状态变化带来的信号,都会让这次回收半途而废。
处理函数里加上 SA_RESTART 就看不到这个 -1 了,同一个程序把 sigaction 的标志位改掉,第一次 wait 直接拿到结果:
父进程 28564 装了 2 秒的闹钟,处理函数带 SA_RESTART,然后进 wait
子进程 28565 睡 5 秒
闹钟响了,处理函数跑了一下
这一次 wait 返回 28565,errno=0(Success)
读到的退出码 12
闹钟照样响了,处理函数照样跑了一次,但父进程代码里那一行 wait 没有被中断,内核在处理函数返回之后把系统调用重启了一遍。两种写法都能用,区别在于被打断这件事由谁来收尾:不带 SA_RESTART 时,父进程自己的代码要判断 EINTR 并重试;带上之后,重试由内核替你做完,代价是别的系统调用也会跟着变成不可打断,写代码的人需要对哪些调用适合重启心里有数。
十一、SIGCHLD
孩子退出的时候内核会通知父进程,用的信号是 SIGCHLD,编号 17。这个编号在第一节的 /proc 采样里已经露过一次面,它在 stat 文件里是第 38 个字段,也就是 exit_signal,含义是进程退出时发给父进程的信号。默认情况下这颗信号什么都不做,父进程想要在第一时间知道孩子走了,可以给它装一个处理函数,把回收的动作放进处理函数里执行:
孩子 1 的进程号 27850,退出码 31
孩子 2 的进程号 27851,退出码 32
父进程 27849 接着干自己的活,顺手在信号处理函数里收孩子
[1] 父进程第 1 轮干活
[2] 父进程第 2 轮干活
处理函数里收掉 27850,退出码 31
[3] 父进程第 3 轮干活
[4] 父进程第 4 轮干活
处理函数里收掉 27851,退出码 32
[5] 父进程第 5 轮干活
[6] 父进程第 6 轮干活
六轮干完,收掉 2 个孩子
父进程的六轮活没有被回收动作挡住,两个孩子在各自退出的时候被处理函数收走,收的动作插在第 2 轮与第 3 轮之间、第 4 轮与第 5 轮之间执行。这个模式适合父进程手上有长期任务的场景,父进程不需要为一个孩子停下来。
处理函数里收孩子必须带 WNOHANG,而且要写成循环。理由是信号会合并:两个孩子如果在极短时间里相继退出,内核可能只给父进程投递一次 SIGCHLD,处理函数里只收一个就会漏掉另一个,漏掉的那个会一直挂在进程表里。带上 WNOHANG 的循环一次把已经退出的孩子全收走,循环靠 ECHILD 结束。
如果处理函数里只打印一句、不做回收,孩子就留下来了。同一个程序换一个版本,处理函数收到信号只往终端写一行字:
--- 父进程 27862 名下的孩子 ---
PID PPID STAT WCHAN CMD
27864 27862 Z+ do_exit [wait_sigchld] <defunct>
27865 27862 Z+ do_exit [wait_sigchld] <defunct>
--- 程序自己的输出 ---
六轮干完,收掉 0 个孩子
两个孩子现在都是僵尸,父进程再停 4 秒
处理好信号不等于回收完成,这两件事分开。信号只是通知,回收还得父进程自己动手,处理函数是唯一能在第一时间动手的地方。
还有一种写法干脆不收了,把 SIGCHLD 的处理方式设成忽略,内核会直接把孩子收掉,父进程后面再想收也收不到:
--- SIGCHLD 设成忽略时,父进程 28245 名下的孩子 ---
PID PPID STAT CMD
--- 程序自己的输出 ---
父进程 28245 把 SIGCHLD 设成忽略
孩子 1 的进程号 28247
孩子 2 的进程号 28248
等了 4 秒之后收一次,返回 -1,errno=10(No child processes)
ps 那一栏是空的,一个僵尸都没有留下,因为内核替父进程做了回收。等到父进程自己再去收的时候,收到的是 ECHILD。默认处理方式下同一段程序留了两个僵尸,把它设成忽略之后一个都不剩,这一对照也是信号与回收之间关系的一个旁证。
处理函数里能调用的函数还要挑一挑,信号处理函数是在被打断的任意时刻执行的,里面的代码要满足异步信号安全这个要求。printf 内部有缓冲区和锁,在被信号打断的场景里可能会撞上正在被写入的同一份缓冲区,安全的选择是 write。这一篇里的处理函数用的都是 write:
static void on_chld(int sig)
{
int st = 0;
pid_t ret;
char buf[160];
int n;
(void)sig;
while ((ret = waitpid(-1, &st, WNOHANG)) > 0) {
if (WIFEXITED(st))
n = snprintf(buf, sizeof(buf), " 处理函数里收掉 %d,退出码 %d\n", (int)ret, WEXITSTATUS(st));
else
n = snprintf(buf, sizeof(buf), " 处理函数里收掉 %d,被信号 %d 终止\n", (int)ret, WTERMSIG(st));
write(1, buf, n);
}
}
snprintf 把要打印的内容先拼进一个栈上的字符数组,再用一次 write 写到 1 号描述符上。拼串与输出分开之后,处理函数里只有一次系统调用,被再次打断的窗口很短。

图 7 SIGCHLD 把回收挪到信号处理函数里
十二、三个入口
回收这件事一共有三个入口,用哪一个取决于父进程在等待期间要不要干别的活,以及它是不是只关心某一个孩子:
| 场景 | 选择 | 关键点 |
|---|---|---|
| 父进程没有别的活,等谁都行 | wait(&st) | 一个调用换一个结果,返回孩子号 |
| 要挑特定的孩子 | waitpid(孩子号, &st, 0) | 会阻塞到那个孩子退出为止 |
| 要一边等一边干活 | waitpid(-1, &st, WNOHANG) 循环 | 返回 0 表示还没退,别当失败处理 |
| 父进程忙得停不下来 | SIGCHLD 处理函数里循环 waitpid | 处理函数里只能用异步信号安全的函数 |
三个入口最后都落到同一个系统调用上,wait 与 waitpid(-1, &st, 0) 在 strace 里长得完全一样:
28606 wait4(-1, <unfinished ...
28606 <... wait4 resumed>[{WIFEXITED(s) && WEXITSTATUS(s) == 8}], 0, NULL) = 28607
接口层的区别只在于参数的多少与返回值的解释,内核那一层只有一套机制。想清楚这一点之后,选接口就变成选参数:要不要挑人,要不要阻塞,要不要把回收放到信号处理函数里。

图 8 三个入口怎么选
这一篇到此把第 9 篇留下的问题收了口。那一篇里 ps 显示出来的 Z 状态,是父进程没有读走孩子的结论;这一篇给出了读的动作,以及读的时候可能遇到的三种情况:孩子还在跑,父进程要么等要么走开;孩子已经退出,父进程一次性拿走退出码并把 PCB 还回去;父进程要把这些动作放到信号处理函数里执行时,还要照顾信号合并与异步信号安全这两个细节。
回收的结论本身在下一篇才登场,父进程 fork 出来的孩子如果只是接着跑同一份程序,父子之间能共享的东西有限;要让子进程去跑另一个程序,得用 exec 系列接口把它的地址空间整份换掉。换完之后孩子按新程序的逻辑退出,父进程仍旧要在这边等一次,把退出码读走。
实例代码
工程结构
/home/cocatrice/lab13
├── src (19 个 .c 源文件)
│ crash_core.c job_reap.c sigchld_ignore.c
│ status_matrix.c wait_basic.c wait_block.c
│ wait_echild.c wait_eintr.c wait_eintr2.c
│ wait_loop_reap.c wait_nohang.c wait_order.c
│ wait_pid_zero.c wait_sigchld.c wait_status_null.c
│ wait_target.c wait_waitpid_same.c wait_zombie_window.c
│ zombie_stat.c
├── bin (19 个可执行文件)
└── out (11 个输出文件)
block.txt core.28089 ignore_default.txt
ignore_ign.txt same.strace sigchld_noreap.txt
status_null.txt wait_block.strace wait_nohang.strace
zombie_stat.txt zombie.txt
文件名与子进程要做的事对得上,编译时按文件名去后缀生成可执行文件,一段循环就能把十九个程序一次编完:
cd /home/cocatrice/lab13/src
for f in *.c; do
name=$(basename $f .c)
if gcc -Wall -o ../bin/$name $f 2>/tmp/gcc_err.txt; then
echo "编译通过 $name"
else
echo "编译失败 $name"
cat /tmp/gcc_err.txt
fi
done
十九个文件全部一次过,回显是一串整齐的编译通过:
编译通过 crash_core
编译通过 job_reap
编译通过 sigchld_ignore
编译通过 status_matrix
编译通过 wait_basic
编译通过 wait_block
编译通过 wait_echild
编译通过 wait_eintr2
编译通过 wait_eintr
编译通过 wait_loop_reap
编译通过 wait_nohang
编译通过 wait_order
编译通过 wait_pid_zero
编译通过 wait_sigchld
编译通过 wait_status_null
编译通过 wait_target
编译通过 wait_waitpid_same
编译通过 wait_zombie_window
编译通过 zombie_stat
完整代码
前面各节按需要给过一部分程序,这里补上两个贯穿全篇的完整程序。第一个是边等边干活的轮询版本,它把等待、干活、退出三件事排进同一个循环:
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
static long busy_work(int round)
{
long sum = 0;
int i, j;
for (i = 0; i < 20000; i++)
for (j = 0; j < 20; j++)
sum += (i ^ j);
return sum + round;
}
int main(void)
{
pid_t pid = fork();
int st = 0;
pid_t ret;
int round = 0;
long sum = 0;
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
printf("子进程 %d 睡 3 秒,然后用退出码 7 离开\n", getpid());
fflush(stdout);
sleep(3);
_exit(7);
}
printf("父进程进入非阻塞轮询,等的同时顺手干活\n");
fflush(stdout);
while (1) {
ret = waitpid(pid, &st, WNOHANG);
if (ret == pid)
break;
if (ret == 0) {
sum += busy_work(round);
round++;
printf("第 %d 轮:子进程还没退出,waitpid 返回 0,累计 %ld\n", round, sum);
fflush(stdout);
usleep(300000);
} else {
perror("waitpid");
return 1;
}
}
printf("子进程退出后 waitpid 返回 %d,退出码 %d,一共轮询了 %d 次\n", ret, WEXITSTATUS(st), round);
return 0;
}
第二个是把回收放进信号处理函数的版本,处理函数里用的是异步信号安全的 write,循环的条件是 waitpid 的返回值大于 0:
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/wait.h>
static int g_reap = 1;
static int g_reaped = 0;
static void on_chld(int sig)
{
int st = 0;
pid_t ret;
char buf[160];
int n;
(void)sig;
if (!g_reap) {
n = snprintf(buf, sizeof(buf), " 收到 SIGCHLD,但这个版本的处理函数什么都不做\n");
write(1, buf, n);
return;
}
while ((ret = waitpid(-1, &st, WNOHANG)) > 0) {
g_reaped++;
if (WIFEXITED(st))
n = snprintf(buf, sizeof(buf), " 处理函数里收掉 %d,退出码 %d\n", (int)ret, WEXITSTATUS(st));
else
n = snprintf(buf, sizeof(buf), " 处理函数里收掉 %d,被信号 %d 终止\n", (int)ret, WTERMSIG(st));
write(1, buf, n);
}
}
int main(int argc, char *argv[])
{
struct sigaction sa;
pid_t kid[2];
int i;
if (argc > 1 && strcmp(argv[1], "noreap") == 0)
g_reap = 0;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = on_chld;
sa.sa_flags = SA_RESTART;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGCHLD, &sa, NULL) < 0) {
perror("sigaction");
return 1;
}
for (i = 0; i < 2; i++) {
kid[i] = fork();
if (kid[i] == 0) {
sleep(i == 0 ? 1 : 3);
_exit(31 + i);
}
printf("孩子 %d 的进程号 %d,退出码 %d\n", i + 1, (int)kid[i], 31 + i);
}
printf("父进程 %d 接着干自己的活%s\n", getpid(), g_reap ? ",顺手在信号处理函数里收孩子" : ",处理函数什么都不收");
fflush(stdout);
for (i = 0; i < 6; i++) {
printf("[%d] 父进程第 %d 轮干活\n", i + 1, i + 1);
fflush(stdout);
sleep(1);
}
printf("六轮干完,收掉 %d 个孩子\n", g_reaped);
if (!g_reap) {
printf("两个孩子现在都是僵尸,父进程再停 4 秒\n");
fflush(stdout);
sleep(4);
}
return 0;
}
另外十七个程序都能在前面的小节里按节找到全文:第一节的 wait_zombie_window.c、第二节的 wait_basic.c、第三节的 wait_status_null.c 与 wait_target.c、第四节的 wait_pid_zero.c、第五节的 status_matrix.c、第七节的 wait_block.c、第八节的 wait_nohang.c、第九节的 wait_order.c 与 wait_loop_reap.c、第十节的 wait_echild.c 与 wait_eintr.c 与 wait_eintr2.c、第十一节的 sigchld_ignore.c、以及 wait_waitpid_same.c、crash_core.c、zombie_stat.c,再加上后面实例里的 job_reap.c,与 src 目录里的十九个文件一一对应。
正常情况
先看一次最普通的回收,父进程派一个孩子出去,孩子睡两秒交出 10,父进程用 wait 收,收完把 status 按位拆开:
子进程 27490 先睡 2 秒,再用退出码 10 离开
wait 返回 27490,子进程号是 27490
status 原始值 2560,十六进制 0x0a00
低 7 位 st & 0x7f = 0
高 8 位 (st >> 8) & 0xff = 10
WIFEXITED = 1
WEXITSTATUS = 10
WIFSIGNALED = 0
WTERMSIG = 0
WCOREDUMP = 0
wait 的返回值与 fork 那一刻父进程拿到的孩子号一模一样,这个数字是父子之间的对号凭据。status 的原始值是 2560,换成十六进制是 0x0a00,退出码 10 待在高八位上,低七位是全零,最后一行的四个宏各读走自己关心的那几位。
再让三个孩子一起跑,退出时间各不一样,看父进程按什么次序收:
父进程建好第 1 个子进程,进程号 27537
第 1 个子进程 27537 打算睡 3 秒,退出码 11
父进程建好第 2 个子进程,进程号 27538
第 2 个子进程 27538 打算睡 1 秒,退出码 22
父进程建好第 3 个子进程,进程号 27539
第 3 个子进程 27539 打算睡 2 秒,退出码 33
第 1 次 wait 回来的是 27538,退出码 22
第 2 次 wait 回来的是 27539,退出码 33
第 3 次 wait 回来的是 27537,退出码 11
三次回收的次序与创建次序无关,前面的 27538 是最先退出的那一个。父进程想知道某个结论属于哪个任务,靠的是进程号,而不是回收的先后。
常见报错
第一类错误出在编译阶段,写了 waitpid 与 WEXITSTATUS 却忘记包含 sys/wait.h,编译器把它们当成没有声明过的函数:
nowait13.c: In function ‘main’:
nowait13.c:12:5: warning: implicit declaration of function ‘waitpid’ [-Wimplicit-function-declaration]
waitpid(pid, &st, 0);
^
nowait13.c:13:5: warning: implicit declaration of function ‘WEXITSTATUS’ [-Wimplicit-function-declaration]
printf("退出码 %d\n", WEXITSTATUS(st));
^
/tmp/ccidx9n3.o: In function `main':
nowait13.c:(.text+0x4d): undefined reference to `WEXITSTATUS'
collect2: error: ld returned 1 exit status
gcc 退出码 1
waitpid 只在第一段告警里出现,链接的时候找不到符号的是 WEXITSTATUS。原因是这个宏在没有头文件的情况下被当成普通函数,链接器去库里找同名函数,自然找不到。这一类报错里两个警告加一条链接错误是一起出现的,看到 undefined reference 时往上看一眼缺了哪个头文件。
第二类错误出在参数上,waitpid 的第二个参数要的是指针,少写一个取地址符就会把一个整数当成地址传进去:
badptr13.c: In function ‘main’:
badptr13.c:13:5: warning: passing argument 2 of ‘waitpid’ makes pointer from integer without a cast [enabled by default]
waitpid(pid, st, 0);
^
In file included from badptr13.c:3:0:
/usr/include/sys/wait.h:137:16: note: expected ‘int *’ but argument is of type ‘int’
extern __pid_t waitpid (__pid_t __pid, int *__stat_loc, int __options);
gcc 退出码 0
编译器只给一句警告就放行了,可执行文件照样生成,程序跑起来退出码是 0,没有任何报错。这里的整数恰好是 0,而 0 就是空指针,waitpid 把它当成不需要写回 status,于是回收动作照常完成、结论被丢掉。这一类问题在编译输出里只有一行 warning,跑起来安静得让人以为一切正常,等到按退出码分支的逻辑失效时才发现 status 从来没有被写进去。
第三类错误在运行阶段,回收的请求指向了一个不存在的对象,内核用 errno 给出理由:
一个孩子都没有的时候 wait 返回 -1,errno=10(No child processes)
第一次收 27826,返回 27826,退出码 4
对同一个进程号再收一次,返回 -1,errno=10(No child processes)
去等 1 号进程,返回 -1,errno=10(No child processes)
带一个乱写的选项,返回 -1,errno=22(Invalid argument)
前面三行都是 ECHILD,编号 10,把三种看着不同的场景归成了一个理由:手上没有可以收的孩子。第四行的 EINVAL 是参数写坏了,编号 22。写循环时把 -1 当成收尾信号,要额外确认 errno 是 10 而不是别的,因为 EINTR 也会返回 -1。
第四类错误的成因在信号上,父进程的代码本身没有问题:
父进程 28043 装了一个 2 秒的闹钟,然后进 wait
子进程 28044 睡 5 秒
闹钟响了,处理函数跑了一下
第一次 wait 返回 -1,errno=4(Interrupted system call)
被闹钟打断了,重新等一次
第二次 wait 返回 28044,退出码 12,errno=0
边界情况
第一个边界是 status 传空指针。回收的动作与读结果的动作可以分开,父进程只想知道孩子什么时候走,不关心结论:
--- 还没回收时父进程 27882 名下的孩子 ---
PID PPID STAT WCHAN CMD
27884 27882 Z+ do_exit [wait_status_nul] <defunct>
--- 程序自己的输出 ---
父进程 27882 先睡 3 秒,这段时间孩子是僵尸
status 传 NULL,wait 照样返回 27884 并把孩子收掉
第二个边界是 pid 取 0 与负数。pid 为 0 表示只等与自己同一个进程组的孩子,把进程组号取反之后传给 waitpid 也能等到组里的孩子:
父进程 27840,进程组 27672
第 1 个孩子 27841,进程组 27672,睡 1 秒后退出码 61
两个孩子的进程组号 27672 和 27672,父进程用 pid 为 0 的 waitpid 轮询
第 1 轮 waitpid(0, &st, WNOHANG) 返回 0,errno=0(Success)
第 2 个孩子 27842,进程组 27672,睡 3 秒后退出码 62
第 2 轮 waitpid(0, &st, WNOHANG) 返回 0,errno=0(Success)
第 3 轮 waitpid(0, &st, WNOHANG) 返回 27841,收掉的是 27841,退出码 61
再用负的进程组号等一次,回来的是 27842,退出码 62
第三个边界是退出码装不下的情况,同一张对照表里的两行能说明问题:exit(256) 的 status 是 0x0000,与 exit(0) 完全一样;exit(300) 的 status 是 0x2c00,高八位读出来是 44。结论如果是一个大整数,塞进退出的那一步就会被截断,需要往上传的场合要先把它拆成能装下的形式。
第四个边界是把 SIGCHLD 的处理方式设成忽略。同一段程序跑两遍,一遍不动信号处理方式,一遍把它设成忽略:
########## SIGCHLD 保持默认时 ##########
--- 父进程 28192 名下的孩子 ---
PID PPID STAT CMD
28194 28192 Z+ [sigchld_ignore] <defunct>
28195 28192 Z+ [sigchld_ignore] <defunct>
--- 程序自己的输出 ---
父进程 28192 不动 SIGCHLD,保持默认
孩子 1 的进程号 28194
孩子 2 的进程号 28195
等了 4 秒之后收一次,返回 28194,errno=0(Success)
########## SIGCHLD 设成忽略时 ##########
--- 父进程 28245 名下的孩子 ---
PID PPID STAT CMD
--- 程序自己的输出 ---
父进程 28245 把 SIGCHLD 设成忽略
孩子 1 的进程号 28247
孩子 2 的进程号 28248
等了 4 秒之后收一次,返回 -1,errno=10(No child processes)
默认那一遍两个孩子都变成了僵尸,设成忽略那一遍 ps 的孩子栏是空的,父进程自己去收反而收到 ECHILD。忽略 SIGCHLD 之后回收由内核代做,这个用法的代价是父进程再也拿不到孩子的退出码。
第五个边界是僵尸在 /proc 里剩下的东西。把休眠时间拉长到 8 秒,趁孩子还是僵尸的时候读它的记录:
孩子号 28264
--- /proc/28264/status ---
Name: zombie_stat
State: Z (zombie)
PPid: 28262
SigQ: 0/7267
--- /proc/28264/stat 逐字段编号 ---
1=28264 2=(zombie_stat) 3=Z 4=28262 ... 18=20 ... 38=17 39=1 40=0 ... 52=0
--- 程序自己的输出 ---
父进程 28262,孩子 28264 已经退出,父进程先睡 8 秒
回收返回 28264,status=0x0500,退出码 5
第 3 个字段是状态 Z,第 4 个字段是它的父进程号,第 22 到第 28 个字段那一批地址空间相关的数字全是 0,因为地址空间已经还掉了。第 38 个字段是 17,也就是 SIGCHLD 的编号,这一位记着进程退出时该通知父进程用哪个信号。第 52 个字段是 exit_code,这颗内核对僵尸打印出来的是 0,孩子的真实退出码 5 并没有留在里面,父进程一旦错过回收,那个数字就找不回来了。
实测数据
| 项目 | 读数 | 说明 |
|---|---|---|
| 内核版本 | 3.10.0-1160.119.1.el7.x86_64 | CentOS 7 默认内核 |
| 编译器 | gcc 4.8.5 20150623 | 十九个程序加 -Wall 全部编译通过 |
| status 原始值 | 2560,即 0x0a00 | 退出码 10 待在高八位 |
| 退出码截断 | 256 到 0、300 到 44 | 只有低八位有效 |
| 信号终止的 status | 0x0009、0x000f、0x0002 | 分别对应 SIGKILL、SIGTERM、SIGINT |
| core 标志位 | 0x008b 与 0x0088 | ulimit -c unlimited 时的 SIGSEGV 与除零 |
| 轮询次数 | 10 次换一次成功回收 | 间隔 0.3 秒,孩子睡 3 秒 |
| 回收顺序 | 27538、27539、27537 | 与创建顺序无关 |
| 循环收尾的 errno | 10,No child processes | 四个孩子收完之后 |
| 阻塞时父进程的状态 | S+,wchan 是 do_wait | 孩子睡在 hrtimer_nanosleep |
| 僵尸期的父进程 | S+,wchan 是 hrtimer_nanosleep | 孩子是 Z+,wchan 是 do_exit |
| 被打断的 errno | 4,Interrupted system call | 处理函数不带 SA_RESTART 时 |
| SIGCHLD 的编号 | 17 | /proc//stat 的第 38 个字段 |
| 一次收干净 | 4 个孩子,循环退出时收 4 个 | 每个孩子一个退出码 |
实例
把这一篇里的等待、轮询、对号入座拼起来,就是一个能派活、能一边等一边干活、还能把结论按任务号归位的回收器。它给四个任务各建一个子进程,孩子睡的时间不一样,交回的退出码也不一样,父进程用非阻塞的方式边问边干活:
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/wait.h>
#define TASKS 4
static long busy_work(void)
{
long sum = 0;
int i, j;
for (i = 0; i < 60000; i++)
for (j = 0; j < 20; j++)
sum += (i ^ j);
return sum;
}
int main(void)
{
int seconds[TASKS] = {1, 2, 3, 4};
int codes[TASKS] = {10, 20, 30, 40};
pid_t kids[TASKS];
int got[TASKS];
int i, round = 0, reaped = 0;
int st = 0;
long sum = 0;
time_t start = time(NULL);
pid_t ret;
int idx;
memset(got, 0, sizeof(got));
for (i = 0; i < TASKS; i++) {
kids[i] = fork();
if (kids[i] < 0) {
perror("fork");
return 1;
}
if (kids[i] == 0) {
sleep(seconds[i]);
_exit(codes[i]);
}
printf("派发任务 %d,孩子 %d,打算睡 %d 秒,退出码 %d\n", i + 1, (int)kids[i], seconds[i], codes[i]);
}
fflush(stdout);
printf("父进程 %d 进入回收循环,用非阻塞的方式边问边干活\n", getpid());
fflush(stdout);
while (reaped < TASKS) {
ret = waitpid(-1, &st, WNOHANG);
round++;
if (ret > 0) {
for (idx = 0; idx < TASKS; idx++)
if (kids[idx] == ret)
break;
got[idx] = WEXITSTATUS(st);
reaped++;
printf("第 %d 次问:收到孩子 %d,它是任务 %d,退出码 %d,已收 %d/%d\n",
round, (int)ret, idx + 1, got[idx], reaped, TASKS);
} else if (ret == 0) {
sum += busy_work();
printf("第 %d 次问:还没有人退,父进程继续算,累计 %ld\n", round, sum);
} else {
printf("waitpid 返回 -1,errno=%d(%s)\n", errno, strerror(errno));
break;
}
fflush(stdout);
usleep(300000);
}
printf("---- 按派发顺序把结论排一遍 ----\n");
for (i = 0; i < TASKS; i++)
printf("任务 %d 的孩子 %d 交回的退出码是 %d\n", i + 1, (int)kids[i], got[i]);
printf("收完 %d 个孩子,一共问了 %d 次,用了 %ld 秒\n", reaped, round, (long)(time(NULL) - start));
return 0;
}
这台机器上跑一次,十五次询问把四个孩子收干净:
派发任务 1,孩子 28891,打算睡 1 秒,退出码 10
派发任务 2,孩子 28892,打算睡 2 秒,退出码 20
派发任务 3,孩子 28893,打算睡 3 秒,退出码 30
派发任务 4,孩子 28894,打算睡 4 秒,退出码 40
父进程 28890 进入回收循环,用非阻塞的方式边问边干活
第 1 次问:还没有人退,父进程继续算,累计 35999400000
第 2 次问:还没有人退,父进程继续算,累计 71998800000
第 3 次问:还没有人退,父进程继续算,累计 107998200000
第 4 次问:还没有人退,父进程继续算,累计 143997600000
第 5 次问:收到孩子 28891,它是任务 1,退出码 10,已收 1/4
第 6 次问:还没有人退,父进程继续算,累计 179997000000
第 7 次问:还没有人退,父进程继续算,累计 215996400000
第 8 次问:收到孩子 28892,它是任务 2,退出码 20,已收 2/4
第 9 次问:还没有人退,父进程继续算,累计 251995800000
第 10 次问:还没有人退,父进程继续算,累计 287995200000
第 11 次问:收到孩子 28893,它是任务 3,退出码 30,已收 3/4
第 12 次问:还没有人退,父进程继续算,累计 323994600000
第 13 次问:还没有人退,父进程继续算,累计 359994000000
第 14 次问:还没有人退,父进程继续算,累计 395993400000
第 15 次问:收到孩子 28894,它是任务 4,退出码 40,已收 4/4
---- 按派发顺序把结论排一遍 ----
任务 1 的孩子 28891 交回的退出码是 10
任务 2 的孩子 28892 交回的退出码是 20
任务 3 的孩子 28893 交回的退出码是 30
任务 4 的孩子 28894 交回的退出码是 40
收完 4 个孩子,一共问了 15 次,用了 5 秒
父进程每问一次都有回音:没有人退的时候返回 0,父进程接着算自己那笔账,累计值一路涨到 395993400000;有人退的时候返回进程号,父进程拿着这个号去 kids 数组里对下标,把退出码存进 got 里对应的一格。四秒左右四个孩子陆续退出,回收的动作被夹在干活的间隙里完成,父进程一次都没有停下来干等。
最后一节那次循环出口特别值得再看一眼。十五次询问里前十四次要么返回 0 要么返回一个进程号,最后收满四个之后 while 靠计数器退出,没有再去问第十五次之外的第十六次。如果把循环的出口写成靠 waitpid 返回 -1,那么在收满之后的那一次询问上会拿到 ECHILD,这个写法在这里同样成立,只是多问了一次。
踩坑点
- wait 与 waitpid 的第一个参数写法不同,wait 没有进程号参数,只收任意一个孩子,要挑人得用 waitpid。
- waitpid 的第二个参数是输出用的指针,少写取地址符时编译器只给一句警告,程序照常跑,status 里的结论被丢掉。
- 第三个参数 WNOHANG 要按位或进去,写成赋值会把别的标志位一起清掉。
- WNOHANG 模式下返回 0 表示孩子还没退出,这是正常情况,把它当成失败处理会让父进程提前放弃回收。
- 返回 -1 才是出错,判断的时候要把 -1 与 0 分开,循环里把返回值是否大于 0 当成唯一的判定依据,可以少写一层分支。
- status 不是退出码本身,退出码待在高八位,直接用 status 比较会拿到 256 的整数倍。
- WTERMSIG 只在被信号终止时有意义,正常退出的进程读它得到的是 0 或者程序自己填的默认值。
- 退出码只有低八位有效,256 会被读成 0、300 会被读成 44,用大数字表示结论要预料到截断。
- 回收顺序等于退出顺序,与创建顺序无关,想把结论归到任务上必须用进程号对号入座。
- 循环收孩子的出口靠 errno 等于 10,收到 ECHILD 时不要当成错误打印,它意味着该收的都收完了。
- options 里塞了内核不认识的东西会得到 EINVAL,这类失败在参数写错的第一次调用就会暴露。
- 处理函数不带 SA_RESTART 时,等待会被信号打断并返回 EINTR,父进程不重试就会漏掉这次回收。
- 处理函数里循环收孩子要带 WNOHANG,信号会合并,只收一个会漏。
- 处理函数里不要用 printf,它内部有缓冲区与锁,写终端请改用 write。
- 把 SIGCHLD 设成忽略之后内核会代收孩子,父进程后面再收会得到 ECHILD,退出码也拿不到了。
- 只收到信号不做回收等于没有回收,孩子会一直留在进程表里变成僵尸。
- 僵尸的地址空间已经还掉了,/proc 里那批地址字段全是 0,父进程一旦错过回收,退出码也随之下线。
本篇总结(模拟面试问题)
问:父进程为什么必须回收子进程,不回收会怎样?
核心要点:子进程退出之后内核会留下一个 PCB 记录它的退出结论,等父进程来取走。父进程不取,这条记录就一直占着进程表里的一个位置,进程本身已经不能运行也不会被调度,ps 里显示成 Z 状态,kill 也杀不掉,同时退出码这个信息随着进程表项的释放才会消失。
问:wait 与 waitpid 有什么区别,各自适合什么场合?
核心要点:wait 只有一个 status 参数,收任意一个孩子;waitpid 多了进程号与选项两个参数,可以挑特定的孩子,也可以选非阻塞。父进程不关心是谁、也没有别的活要干时用 wait 更直接;要按任务对号入座或者一边等一边干活时用 waitpid。
问:waitpid 的第一个参数有哪几种取值,分别是什么意思?
核心要点:大于 0 表示等指定进程号的那一个孩子;等于 -1 表示任意一个孩子;等于 0 表示与自己同一个进程组的孩子;小于 -1 表示进程组号等于这个参数绝对值的那一组孩子。
问:WNOHANG 的作用是什么,用它写循环要注意什么?
核心要点:它在 options 里按位或进去,让本次询问在孩子还没退出时立刻返回 0,父进程可以接着干别的活。循环里要把 0 与 -1 分开,0 表示继续轮询,-1 才是失败;轮询间隔决定系统调用的次数与发现退出的延迟。
问:status 里的那些位分别代表什么,怎么读?
核心要点:低七位用于区分终止原因,全零表示正常退出,非零时它装的是终止信号编号,第八位是 core 文件标志。正常退出时退出码待在高八位。头文件里把这些位运算包成了宏,先判断类别再取对应的值,需要看原始位的时候再把 status 按十六进制打印出来。
问:等待被信号打断会出现什么现象,两种处理方式各有什么代价?
核心要点:处理函数不带 SA_RESTART 时,等待返回 -1 并且 errno 是 4,父进程要在代码里判断 EINTR 并重试;带上 SA_RESTART 之后内核会替它重启系统调用,代码里看不到这次打断,代价是别的可重启的系统调用也会跟着被重启,写代码的人要对哪些调用适合重启心里有数。
问:在 SIGCHLD 的处理函数里收孩子要注意哪些细节?
核心要点:要带 WNOHANG 并且写成循环,因为多个孩子几乎同时退出时内核可能只投递一次信号,只收一个会漏。处理函数在被打断的任意位置执行,里面只能调用异步信号安全的函数,输出用 write 而不是 printf,需要拼串就先在栈上拼好再一次性写出去。
参考
- wait(2):https://man7.org/linux/man-pages/man2/wait.2.html
- sigaction(2):https://man7.org/linux/man-pages/man2/sigaction.2.html
- signal(7):https://man7.org/linux/man-pages/man7/signal.7.html
- signal-safety(7):https://man7.org/linux/man-pages/man7/signal-safety.7.html
- errno(3):https://man7.org/linux/man-pages/man3/errno.3.html
- core(5):https://man7.org/linux/man-pages/man5/core.5.html
- proc(5):https://man7.org/linux/man-pages/man5/proc.5.html
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/weixin_63897076/article/details/167589037




