有生就有死.进程退出这条路上挤满了经典问题:退出码怎么编码,僵尸到底占不占资源,孤儿谁管,exit 和 pthread_exit 什么区别.这篇把这些行为全部实测,内核侧的 do_exit 流程对着讲,顺便捡到一个意外收获:僵尸也会被过继.
这篇回答几件事:
- wait 拿到的那个整数怎么解码,退出和被杀有什么区别
- exit 到底退的是什么,线程组怎么算总账
- 僵尸进程占什么不占什么,谁负责收尸
- 父亲先死孩子怎么办
先解码.wait 系统调用拿回一个 int,两种人生两种格式.程序自己 exit(42),raw 值 0x2a00 — 42 左移八位;被信号打死(SIGSEGV),raw 值 0x000b — 信号号 11 直接放低位,压根没有退出码的位置,因为进程没机会跑退出逻辑.宏解码对着看:
exit(42): raw=0x2a00 WIFEXITED=1 WEXITSTATUS=42
SIGSEGV: raw=0x000b WIFSIGNALED=1 WTERMSIG=11 WCOREDUMP=0
WEXITSTATUS 就是个右移八位,WTERMSIG 就是个低七位掩码,没有任何魔法.注意低七位里还藏着一位 core dump 标志,板上 ulimit 关了 core,WCOREDUMP 为 0.这套编码是用户态的约定,内核只负责把 status 塞进 wait 的出参 — 顺手一提 shell 里 $? 看到的就是 WEXITSTATUS 或 128 加信号号的那个值.
然后是"exit 退的到底是什么".系统调用层有两个:exit(NR 93)退当前线程,exit_group(NR 94)退整个线程组,glibc 的 exit() 和 _exit() 都走 exit_group.实测最有说服力的对照是反着来 — 主线程调 pthread_exit:
main 线程 pthread_exit 后 1 秒: State: S, Threads: 1
进程活着!状态 S,线程数从 2 掉到 1:pthread_exit 只终结主线程自己,spinner 线程接棒把进程撑住,直到最后一个线程退出内核才调 do_exit.所以"进程死亡"的准确条件是"线程组最后一个活线程退出",exit_group 只是个快进键:内核给组里每个线程挨个注入"该退了".反过来这也是多线程程序的退出陷阱:主线程 return 下去以为完事了,有个 detached 线程死循环,进程就吊着不退 — 没有 init 那样的兜底,线程组的规则就是活到最后一根蜡烛.
do_exit 里面的顺序值得记:先 exit_mm 放掉地址空间(别的线程可能还在用?所以多线程下 exit_group 先同步干掉大家,单个线程 do_exit 时共享的 mm 有引用计数),exit_files 关文件表,exit_fs 退 cwd/root,信号处置恢复默认,最后 exit_notify:给父进程发 SIGCHLD,把自己的退出状态打包挂在 task_struct 里,状态置 EXIT_ZOMBIE,调度器再也不选它.至此它什么都不占 — 没有内存,没有页表,没有文件 — 只剩 task_struct 骨架和 PID,这就是僵尸的全部占地面积.所以僵尸"无害"是真的,但 PID 是 400 万顶格的稀缺资源,fork 炸弹式泄漏照样能锁死系统.真正的回收在父进程 wait 时:release_task 把 task_struct 还给 slab,children 链上摘除,父子恩怨两清.所以"防僵尸"只有一个办法 — 父进程记得 wait,或者显式 SIG_IGN SIGCHLD,或者把父亲杀了.
杀父亲这招引出孤儿问题.父进程先死,孩子的 real_parent 指向一个死人,内核在父进程 exit_notify 里给每个孩子重新找家:优先最近的 subreaper(主动声明愿意收孤儿的祖先,prctl(PR_SET_CHILD_SUBREAPER) 可设),没有就 PID 1.实测:
orphan child=4315 (sleep 30s), 父进程先死
ps: 4315 PPID=1
孩子被 systemd 接走,PPID 变 1,以后由它收尸.这套机制是容器和 systemd 服务树的基石:服务进程意外死了,它的孤儿孙子孙女不会变野孩子,一路向上有祖先兜底.
意外收获来自实验残留:一个僵尸进程挂在已死父亲名下,ps 里看到它 PPID=1 State=Zl — 僵尸也被过继了.内核处理父进程死亡时,连它名下的僵尸一起转交新家长,等新家长 wait(或再死再转交)才销账.systemd 一般手快,肉眼抓僵尸过继的窗口很短,这个漏网之鱼纯属运气,但把机制演活了:僵尸不是钉死在生父名下,是跟着"父亲的父亲"链走的.
最后留几个容易忽略的细节:
- exit_mm 之后进程就不再有用户地址空间,OOPS 打印里看一个进程的内存信息失败,多半它在退出半路.
- SIGKILL 不能被忽略不能捕获,但它的送达也要等进程回到用户态;D 状态进程收不到它,"kill -9 杀不动"九成是 D.
- waitpid 的 WNOHANG 是轮询式收割,标准姿势是 SIGCHLD 处理器里循环 waitpid(-1,...,WNOHANG) 直到返回 0,防止一次信号合并多个孩子死亡.
- 父进程 double-fork 加孤儿法是老派 daemonize 的标准动作:第一次 fork 立刻退出父亲,孩子变孤儿由 init 收尸,自己永远不会产生僵尸 — systemd 之前的服务全这么写的.
- pthread_exit 主线程后,那个线程的栈会被回收,但它持有的锁不会释放,死锁风险自己担.
- EXIT_DEAD 是比 ZOMBIE 更深的一层:wait 已经把它从僵尸转为待释放,即将销毁,ps 抓到它算你眼疾.
- /proc/PID/stat 字段 52 起是 exit_code,活进程读是 0,僵尸身上能提前读到退出码,不等 wait 也能知道死因.