exec、wait、exit:进程怎么收尾

exec、wait、exit:进程怎么收尾
隔壁老康难度 ★★★☆☆ · 前置:进程状态:R、S、D、Z 各自在等什么 | 一句话结论:退出码只有低 8 位,
exit(256)会变成0;wait的 status 是位图,必须用WIFEXITED/WEXITSTATUS解;exit之后进程变成僵尸,父进程不wait就永远占着一个 PID。
写脚本的时候习惯性这么写,跑了几个月都没事,因为 some_command 成功就返回 0,失败返回非 0,if 判断的是那个 0,exit 1 只是把失败再往外传一层。
1 | if [ "$(some_command)" = "ok" ]; then |
直到有一次我在上面加了别的判断,顺手改成了 exit 256 表示严重错误,然后 echo $? 出来是 0,那一瞬间我以为是自己看错了行号,或者 $? 被后面那条命令覆盖了,来回看了三遍脚本才确认没有别的命令,就是这一行,就是 0。我以为是我记错了什么,把脚本从头到尾又看了一遍,确认那一瞬间 $? 就是 0。
1 | bash -c 'exit 256'; echo $? |
1 | 0 |
1 | bash -c 'exit 300'; echo $? |
1 | 44 |
256 变 0,300 变 44,300 = 256 + 44,也就是 300 mod 256。我当时的猜测是 shell 有某种自己的编码,把大数字映射成小数字,这个猜测跑了十分钟就被推翻了,因为 exit 511 出来是 255,511 = 256 + 255,它没做任何编码,它就是干脆利落地砍掉高位,砍剩下的那截正好是原数对一个 256 取模的结果,所以我一开始看到的那些映射全是巧合。
不是 bug,也不是 shell 抽风,退出码只有 8 位。这一个事实往后连着整套退出机制,为什么需要 wait、为什么会有僵尸、为什么 WEXITSTATUS 这个宏非存在不可,因为它真的不是多余的封装,它是唯一正确的读法。
结束是三步,不是一步
| 步骤 | 动作 | 内核做什么 |
|---|---|---|
| 1 | exit(code) |
释放内存、关闭 fd、把 code 存进 task_struct、状态改成 Z |
| 2 | 父进程 wait |
读到 code,释放 task_struct |
| 3 | 父进程不 wait 也不行 | 内核兜底,init 或 systemd 收养并回收 |
第 1 步是进程自己干的,第 2 步必须别人干,这一点值得停一秒。
僵尸的全部来源就在这句话里,第 1 步做完了,第 2 步没人做,于是那个 task_struct 就悬在那儿,谁都不动它,进程不在了,记录还在,ps 里还占着一位。
退出码为什么只有 8 位
exit 的签名是 void _exit(int status),但真正被保存下来的只有 status & 0xFF。
历史原因在 wait 身上,它要同时表达三件事,进程是正常退出还是被信号杀死,如果是正常退出,退出码是多少,如果是被杀死,是哪个信号,这三件事得塞进同一个 int 里,因为它只有一个返回值,而返回值已经被 pid 占用了,所以只能塞进 status 指针指向的那块内存。
它把这两件事打包进一个 16 位整数。
1 | 15 14 13 12 11 10 9 8 | 7 6 5 4 3 2 1 0 |
低 8 位管信号这一半,第 0 到 6 位是信号编号,第 7 位是 core 标志,高 8 位放退出码。信号编号也就到 64,占 7 位足够,剩下能给的只有 8 位,这个分配在 1970 年代的 Unix 上做出来的时候没人觉得会不够用,五十年后我们还在受它的约束,一个 exit(256) 就这么静悄悄地变成了 0,没有任何警告,没有任何报错。现代 wait 的 int *status 是 32 位的,但这个布局一直没改,兼容性比干净更重要,所以内核只写低 16 位,上面的位是垃圾值,你要是把 status 整个打出来会看到一串莫名其妙的十六进制,那串数字的高半部分毫无意义,只有低 16 位是真的。
status 必须用宏去解
因为它是位域打包的,直接读一定错。
| 宏 | 作用 |
|---|---|
WIFEXITED(status) |
是正常退出吗? |
WEXITSTATUS(status) |
若是,退出码是多少(取高 8 位) |
WIFSIGNALED(status) |
是被信号杀死吗? |
WTERMSIG(status) |
若是,哪个信号 |
WCOREDUMP(status) |
有没有产生 core(非标准,要 #define _GNU_SOURCE) |
WIFSTOPPED(status) |
被停住了吗(配合 WUNTRACED) |
WSTOPSIG(status) |
停住它的信号 |
1 | int status; |
直接读 status 是经典错误,exit(1) 的时候 status 是 0x0100,也就是 256,不是 1,你拿它去跟 1 比当然不相等,然后你会以为程序压根没退出成功,接着开始查 waitpid 的返回值,查了半天发现返回值是对的,问题在于你读错了字段,这种错我犯过不止一次,而且每次都要愣几秒才想起来。
graph TD A["int status<br/>低 16 位"] --> B["WIFEXITED ?"] B -->|"真"| C["WEXITSTATUS<br/>(status >> 8) & 0xFF"] B -->|"假"| D["WIFSIGNALED ?"] D -->|"真"| E["WTERMSIG<br/>status & 0x7F"] D -->|"假"| F["WIFSTOPPED ?"]
exit 和 _exit 是两个东西
C 库的 exit() 不是系统调用,它先做一堆清理。
1 | void exit(int status) { |
_exit() 才是系统调用(exit_group 或 exit),它什么都不刷,直接把当前进程的内存丢掉,atexit 注册的函数一个都不跑,stdio 缓冲里那些还没写出去的东西也一起没了,这就是为什么在信号处理函数或者 fork 出来的子进程里,你必须用 _exit,用 exit 会把父进程攒在缓冲区里的数据再刷一遍,这个区别在 fork 之后很要命,因为缓冲区是随内存一起被复制的。
1 | pid_t pid = fork(); |
如果 stdout 重定向到了文件(全缓冲),fork 之前缓冲区里可能已经有内容,父子各持一份缓冲副本,各自 exit 时各刷一次,同一行打两遍,这个现象在终端里看不到,因为终端是行缓冲,每次 printf 结束就刷了,只有重定向到文件或者通过管道接给别的程序时才会冒出来,所以它总是在生产环境的日志里出现,而不在你自己的实验里,我第一份工作写的一个备份脚本就是这样把同一行日志写了两遍,运维看日志以为是跑了两次,其实只跑了一次,多出来的那行来自一个缓冲区。
子进程要么用 _exit(0),要么在 fork 前 fflush(stdout)。
| 场景 | 用什么 |
|---|---|
| 正常程序结束 | exit() 或 return from main |
fork 出来的子进程 |
_exit() |
| 信号处理函数里 | 只能 _exit()(exit 不是异步信号安全的) |
(这张表我抄在便签上贴了半年,因为每次写到 fork 都会顺手打 exit,编辑器补全也是先给 exit,它就是更容易被敲出来。)
exit 之后,内核还给谁留着东西
进程调 exit_group 之后,内核按这个顺序做事。
- 退出码写进
task_struct->exit_code; - 关闭所有 fd(引用计数减一,可能顺带删掉已经
unlink的文件); - 释放
mm_struct,这一步可以立刻做; - 把子进程挂到
init名下,孤儿收养; - 状态改成
EXIT_ZOMBIE; - 给父进程发
SIGCHLD。
task_struct 不释放,它要留着让父进程读退出码,但内核栈会释放,进程真正死透(TASK_DEAD,被切换出去的那一刻)时 put_task_stack() 就把它还掉了,等到父进程 wait 完,release_task() 再回收 task_struct 本身,所以一个僵尸在物理内存里剩下的东西比你想象的小得多,一整页都不到。所以僵尸占的东西真的很少,一个 task_struct(64 位机器上常见 8~10KB,32 位老内核才是 2KB 量级)、一个 PID、一点记账数据。但那个 PID 会打满,/proc/sys/kernel/pid_max 默认 32768(也能配到 4194304),僵尸堆到上限之后任何 fork 都失败,内核只丢回来一行报错,就是下面这句。
1 | fork: Cannot allocate memory |
这句话极具误导性,机器内存明明还有一大堆,我第一次见的时候把 free -h 看了一遍又一遍,怎么都对不上,最后是 ps -eo stat | grep Z | wc -l 数出来的僵尸,三万多个,全是同一个父进程的孩子,那个父进程是个写得不太对的上传服务,只 Popen 不 wait,跑了两周就把 PID 吃完了。
wait 的四种等法
1 | pid_t wait(int *status); /* 任意一个子进程 */ |
waitpid 的第一个参数有四种含义。
pid |
等谁 |
|---|---|
> 0 |
指定的那个子进程 |
-1 |
任意一个子进程(等价于 wait) |
0 |
同一个进程组里的任意子进程 |
< -1 |
进程组 ID 为 ` |
第三个参数常用三个。
| 常量 | 作用 |
|---|---|
WNOHANG |
不阻塞,没死透就返回 0 |
WUNTRACED |
也报告被 SIGSTOP 停住的 |
WCONTINUED |
也报告被 SIGCONT 恢复的 |
非阻塞收尸的标准写法,外层套一个循环,每次只收一个,一直到没有可收的为止。这样写的原因是 waitpid(-1, ...) 一次只能报一个孩子的死讯,剩下那些还在队列里排着。
1 | while ((pid = waitpid(-1, &status, WNOHANG)) > 0) { |
-1 要分两种情况看,ECHILD 是没子进程可等了(正常结束),EINTR 是被信号打断(要重试)。混着不看会写出死循环,而且这个死循环很讨厌,它只在收尸的那一刻才转起来,平时完全正常,等到流量上来了才露头。
SIGCHLD 的三个坑
父进程收到 SIGCHLD 就 wait 一下这条建议在实践中会翻车,而且翻得很难看。
第一个坑,SIGCHLD 不排队。 标准信号(SIGCHLD 是第 17 号)在内核里是位标志,不是队列,三个子进程同时死,父进程可能只收到一次 SIGCHLD,剩下两个的死讯就压在那个位上了,你 wait 一次只收一个,回头一看还剩俩僵尸站在那儿,怎么等都等不到下一次通知,所以处理函数得写成循环。
1 | void on_child(int sig) { |
第二个坑,处理函数里不能调 printf。它不是异步信号安全的,printf 内部会拿锁、会分配内存,而信号可能正好打断 malloc 拿到一半的状态,这时候再进去就是在往一个坏掉的堆上写,结果是一堆完全看不懂的崩溃。真要打日志用 write(2, ...),第三个坑,errno 会被覆盖。处理函数返回之后,main 里那个正在失败的系统调用,errno 已经被改了,所以开头存 errno,结尾恢复,这两行看着像仪式,其实是唯一能让你后面那行 perror 打出正确信息的手段。
僵尸的几种解法
| 方案 | 怎么做 | 代价 |
|---|---|---|
| 同步等 | wait() 阻塞 |
程序卡住,一次只能等一个 |
| 非阻塞轮询 | while (waitpid(-1, &s, WNOHANG) > 0) |
得自己找合适的调用时机 |
SIGCHLD + 处理函数 |
上面那段循环 | 注意信号不排队 |
双 fork |
子进程再 fork 一次然后自己退,孙子交给 init |
拿不到退出码 |
SIGCHLD 设为 SIG_IGN |
signal(SIGCHLD, SIG_IGN) |
子进程自动被回收,但拿不到退出码 |
SA_NOCLDWAIT |
sigaction 带这个标志 |
同上,但更明确 |
这个坑值得单独停一下。
SIG_IGN 最省事,而且它早就不是 Linux 的私货了,POSIX.1-2001 就规定 SIGCHLD 设成 SIG_IGN(或 SIGCHLD 带 SA_NOCLDWAIT)时子进程不产生僵尸,wait 最终返回 -1 / ECHILD,Linux 2.6 起才真正符合这条,2.4 及更早确实没跟上,很多老资料里的 Linux 特有说法说的是那个年代,现在再照抄那句话就是过时的。写成守护进程时双 fork 那个方案值得了解,它的目的之一就是让孙子的父进程变成 init,这样孙子的退出天然没有僵尸问题,代价是你再也拿不到它的退出码,因为 wait 只能等自己的孩子,等不到孙子,这也是为什么 systemd 要插手管这件事,它用 cgroup 和 Type= 绕过了父子的限制,后面的守护进程那篇会讲。
让 8 位截断真的截一次
1 | for code in 0 1 127 128 255 256 257 300 511; do |
1 | exit 0 -> 0 |
这里我当初真的算错过一次。
规律就是 $? = code & 0xFF,300 & 255 = 44,注意 128 到 255 这一段看起来完全正常,因为它们的第 8 位本来就是 0,砍掉高位对它们没有影响,所以你要测这个行为不能用小数字,得用 256 以上的。shell 自己还会做一层换算,它的原话是被信号杀死返回 128 加信号号,所以 kill -TERM 杀掉的进程 $? 是 143。但 bash -c 'exit 200' 报的是 200,bash 只对真正的信号终止做这个换算,不会把你自己退出的 200 当成 128 + 72,这两件事在数字上看起来一样,来源完全不同,把 $? 等于 143 当成程序自己 exit 143 了,是排查里最常见的误判之一,实际那个进程是被 SIGTERM 打死的,它自己压根没机会决定返回什么。
再看 wait 返回的 status 长什么样。
1 |
|
1 | 原始 status = 0x2a00 (10752) |
0x2a00 就是 42 << 8,这就是为什么不能直接读 status,那 42 被塞在了第二个字节里,第一字节是给信号留的位置,因为这次是正常退出,那个字节全是 0。编译跑一下,gcc -o t t.c && ./t。
下面这段是跑出来的真事,我建议你也照着敲一遍,僵尸这东西光看描述是不会有感觉的。
1 | python3 -c " |
会看到 <defunct>。现在看它到底占了多少东西。
1 | ZPID=$(ps -eo pid,stat,comm | awk '$2 ~ /Z/ {print $1}' | head -1) |
1 | ls: cannot access '/proc/12345/fd': No such file or directory |
fd 目录都不存在了,fd 早就全关掉了。留下的只有 status 里那几行记账。
最后看被动回收。
1 | bash -c 'sleep 1 & disown; exit 0' |
disown 之后子进程被 init 收养,它退出时由 init 收尸,不会留僵尸,这就是父进程不退会产生僵尸这条规则的例外,父进程先死,问题反而解决了,我第一次看到这个结论的时候觉得它在胡说,直到自己跑了一遍 disown 才想通,收尸本来就是父进程的义务,换了个爹就换了个义务人,init 的义务是收所有它名下的孩子。(/proc/$ZPID/status 那个文件对僵尸仍然存在,这一直让我觉得有点怪,进程都死透了,身份文件还在,你甚至能读到它的 PPid 和 Uid。)
退出码和僵尸,两个坑
有人说exit(256) 就是退出码 256,其实只有低 8 位有效,256 & 255 = 0,你要是真需要用大于 255 的码区分故障类型,只能自己往上塞一层,比如把 256 写成 1 然后附一条日志,系统层面没有别的办法。有人说$? 直接就是退出码,其实shell 会做转换。正常退出给原值,被信号杀死给 128 + 信号号。所以 $? 是 143 意味着 SIGTERM,不是你自己 exit 143,你要区分这两种情况只能靠 WIFSIGNALED,$? 一个数字分不出来。
有人说僵尸占内存,其实占 PID 和一点记账数据。真危害是 PID 耗尽,PID 打满之后新进程创不出来,报的是 fork: Cannot allocate memory,跟内存毫无关系,第一次见这个报错基本都会去查 free,查完发现内存还剩一半。有人说SIGCHLD 会来 N 次,其实标准信号不排队,可能只来一次。处理函数必须循环 waitpid,一次收干净,不能指望信号来几个。
有人说 signal(SIGCHLD, SIG_IGN) 是 Linux 的私货,其实 POSIX.1-2001 就写了这一条,代价是拿不到退出码。要退出码就老老实实循环 waitpid。
有人说exit 在信号处理函数里没问题,其实不是异步信号安全的,只能用 _exit。
有人说fork 之后子进程用 exit 一样,其实会重复刷 stdio 缓冲,子进程必须 _exit。有人说退出码 0 表示成功,其实这是约定,不是法律。很多程序用 0 表示正常跑完了,但如果业务上没找到也算正常结束,可以 exit 1 表示查无此项。grep 就是这么干的,0 找到、1 没找到、2 出错,你写 grep -q 加 && 的时候其实就在依赖这个约定。
收尸这件事
退出码只留低 8 位,wait 返回的 status 是位域打包的,必须用 WIFEXITED/WEXITSTATUS 那一套宏解。
进程退出需要父进程来收尾,没人收就是僵尸,僵尸占 PID,不占内存。
SIGCHLD 不排队,处理函数里必须循环 waitpid(-1, ..., WNOHANG)。
exit() 会刷缓冲,_exit() 不会,fork 之后只能用 _exit。
父进程先死,子进程被 init 收养,反而不会有僵尸。
进程收尾这件事讲完,还有一个目录值得进去翻,/proc。为什么 ls /proc/1 能看到一个进程的全部内脏,那些文件究竟是谁生成的,为什么读某些文件会让进程卡住一下。











