H.莓飛头像
【Linux】进程等待封面图

【Linux】进程等待

【Linux】进程等待


头像

🔥 H.莓飛: 个人主页

❄️个人专栏:「C++」「数据结构」「Linux系统编程」

✨ 心不畏死,踏屍前行。

📦 代码仓库:cocatrice_csdn_code

📖 博主简介:

博主简介


概览

  • 僵尸进程为什么必须收
  • 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_64CentOS 7 默认内核
编译器gcc 4.8.5 20150623十九个程序加 -Wall 全部编译通过
status 原始值2560,即 0x0a00退出码 10 待在高八位
退出码截断256 到 0、300 到 44只有低八位有效
信号终止的 status0x0009、0x000f、0x0002分别对应 SIGKILL、SIGTERM、SIGINT
core 标志位0x008b 与 0x0088ulimit -c unlimited 时的 SIGSEGV 与除零
轮询次数10 次换一次成功回收间隔 0.3 秒,孩子睡 3 秒
回收顺序27538、27539、27537与创建顺序无关
循环收尾的 errno10,No child processes四个孩子收完之后
阻塞时父进程的状态S+,wchan 是 do_wait孩子睡在 hrtimer_nanosleep
僵尸期的父进程S+,wchan 是 hrtimer_nanosleep孩子是 Z+,wchan 是 do_exit
被打断的 errno4,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

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--