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
2
3
if [ "$(some_command)" = "ok" ]; then
exit 1
fi

直到有一次我在上面加了别的判断,顺手改成了 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
2
3
4
 15   14 13 12 11 10  9  8 | 7 6 5 4 3 2 1 0
[ 退出码 ]| C [ 信号编号 ]
↑
只在被信号杀死时有效

低 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
2
3
4
5
6
7
8
9
10
11
int status;
pid_t pid = waitpid(child, &status, 0);

if (pid == -1) {
perror("waitpid");
} else if (WIFEXITED(status)) {
printf("正常退出,码 = %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("被信号 %d 杀死\n", WTERMSIG(status));
if (WCOREDUMP(status)) printf("(产生了 core)\n");
}

直接读 status 是经典错误,exit(1) 的时候 status 是 0x0100,也就是 256,不是 1,你拿它去跟 1 比当然不相等,然后你会以为程序压根没退出成功,接着开始查 waitpid 的返回值,查了半天发现返回值是对的,问题在于你读错了字段,这种错我犯过不止一次,而且每次都要愣几秒才想起来。

exit 和 _exit 是两个东西

C 库的 exit() 不是系统调用,它先做一堆清理。

1
2
3
4
5
void exit(int status) {
atexit_registered_handlers(); /* 按注册的逆序跑 */
flush_stdio(); /* 刷 stdio 缓冲 */
_exit(status); /* 真正的系统调用 */
}

_exit() 才是系统调用(exit_group 或 exit),它什么都不刷,直接把当前进程的内存丢掉,atexit 注册的函数一个都不跑,stdio 缓冲里那些还没写出去的东西也一起没了,这就是为什么在信号处理函数或者 fork 出来的子进程里,你必须用 _exit,用 exit 会把父进程攒在缓冲区里的数据再刷一遍,这个区别在 fork 之后很要命,因为缓冲区是随内存一起被复制的。

1
2
3
4
5
6
pid_t pid = fork();
if (pid == 0) {
printf("我会被打印几次?\n");
exit(0);
}
wait(NULL);

如果 stdout 重定向到了文件(全缓冲),fork 之前缓冲区里可能已经有内容,父子各持一份缓冲副本,各自 exit 时各刷一次,同一行打两遍,这个现象在终端里看不到,因为终端是行缓冲,每次 printf 结束就刷了,只有重定向到文件或者通过管道接给别的程序时才会冒出来,所以它总是在生产环境的日志里出现,而不在你自己的实验里,我第一份工作写的一个备份脚本就是这样把同一行日志写了两遍,运维看日志以为是跑了两次,其实只跑了一次,多出来的那行来自一个缓冲区。

子进程要么用 _exit(0),要么在 fork 前 fflush(stdout)。

场景 用什么
正常程序结束 exit() 或 return from main
fork 出来的子进程 _exit()
信号处理函数里 只能 _exit()(exit 不是异步信号安全的)

(这张表我抄在便签上贴了半年,因为每次写到 fork 都会顺手打 exit,编辑器补全也是先给 exit,它就是更容易被敲出来。)

exit 之后,内核还给谁留着东西

进程调 exit_group 之后,内核按这个顺序做事。

  1. 退出码写进 task_struct->exit_code;
  2. 关闭所有 fd(引用计数减一,可能顺带删掉已经 unlink 的文件);
  3. 释放 mm_struct,这一步可以立刻做;
  4. 把子进程挂到 init 名下,孤儿收养;
  5. 状态改成 EXIT_ZOMBIE;
  6. 给父进程发 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
2
pid_t wait(int *status);              /* 任意一个子进程 */
pid_t waitpid(pid_t pid, int *status, int options);

waitpid 的第一个参数有四种含义。

pid 等谁
> 0 指定的那个子进程
-1 任意一个子进程(等价于 wait)
0 同一个进程组里的任意子进程
< -1 进程组 ID 为 `

第三个参数常用三个。

常量 作用
WNOHANG 不阻塞,没死透就返回 0
WUNTRACED 也报告被 SIGSTOP 停住的
WCONTINUED 也报告被 SIGCONT 恢复的

非阻塞收尸的标准写法,外层套一个循环,每次只收一个,一直到没有可收的为止。这样写的原因是 waitpid(-1, ...) 一次只能报一个孩子的死讯,剩下那些还在队列里排着。

1
2
3
4
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
/* 收掉一个 */
}
/* pid == 0 表示还有活的子进程,pid == -1 表示没子进程了(EINTR 除外) */

-1 要分两种情况看,ECHILD 是没子进程可等了(正常结束),EINTR 是被信号打断(要重试)。混着不看会写出死循环,而且这个死循环很讨厌,它只在收尸的那一刻才转起来,平时完全正常,等到流量上来了才露头。

SIGCHLD 的三个坑

父进程收到 SIGCHLD 就 wait 一下这条建议在实践中会翻车,而且翻得很难看。

第一个坑,SIGCHLD 不排队。 标准信号(SIGCHLD 是第 17 号)在内核里是位标志,不是队列,三个子进程同时死,父进程可能只收到一次 SIGCHLD,剩下两个的死讯就压在那个位上了,你 wait 一次只收一个,回头一看还剩俩僵尸站在那儿,怎么等都等不到下一次通知,所以处理函数得写成循环。

1
2
3
4
5
6
7
8
9
void on_child(int sig) {
int saved = errno;
int status;
pid_t pid;
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
/* 收一个算一个 */
}
errno = saved;
}

第二个坑,处理函数里不能调 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
2
3
4
for code in 0 1 127 128 255 256 257 300 511; do
bash -c "exit $code"
echo "exit $code -> $?"
done
1
2
3
4
5
6
7
8
9
exit 0 -> 0
exit 1 -> 1
exit 127 -> 127
exit 128 -> 128
exit 255 -> 255
exit 256 -> 0
exit 257 -> 1
exit 300 -> 44
exit 511 -> 255

这里我当初真的算错过一次。

规律就是 $? = 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void) {
pid_t pid = fork();
if (pid == 0) {
_exit(42);
}
int status;
waitpid(pid, &status, 0);
printf("原始 status = 0x%04x (%d)\n", status, status);
printf("WIFEXITED = %d\n", WIFEXITED(status));
printf("WEXITSTATUS = %d\n", WEXITSTATUS(status));
return 0;
}
1
2
3
原始 status = 0x2a00 (10752)
WIFEXITED = 1
WEXITSTATUS = 42

0x2a00 就是 42 << 8,这就是为什么不能直接读 status,那 42 被塞在了第二个字节里,第一字节是给信号留的位置,因为这次是正常退出,那个字节全是 0。编译跑一下,gcc -o t t.c && ./t。

下面这段是跑出来的真事,我建议你也照着敲一遍,僵尸这东西光看描述是不会有感觉的。

1
2
3
4
5
6
7
python3 -c "
import subprocess, time
p = subprocess.Popen(['/bin/true'])
time.sleep(60)
" &
sleep 1
ps -eo pid,ppid,stat,comm | grep -E 'Z|true'

会看到 <defunct>。现在看它到底占了多少东西。

1
2
3
ZPID=$(ps -eo pid,stat,comm | awk '$2 ~ /Z/ {print $1}' | head -1)
ls -l /proc/$ZPID/fd 2>&1 | head -3
cat /proc/$ZPID/status | head -5
1
ls: cannot access '/proc/12345/fd': No such file or directory

fd 目录都不存在了,fd 早就全关掉了。留下的只有 status 里那几行记账。

最后看被动回收。

1
2
3
bash -c 'sleep 1 & disown; exit 0'
sleep 2
ps -eo stat,pid,comm | grep -c '^Z'

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 能看到一个进程的全部内脏,那些文件究竟是谁生成的,为什么读某些文件会让进程卡住一下。