TaterLi 个人博客

[内核学习]进程退出

有生就有死.进程退出这条路上挤满了经典问题:退出码怎么编码,僵尸到底占不占资源,孤儿谁管,exit 和 pthread_exit 什么区别.这篇把这些行为全部实测,内核侧的 do_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 一般手快,肉眼抓僵尸过继的窗口很短,这个漏网之鱼纯属运气,但把机制演活了:僵尸不是钉死在生父名下,是跟着"父亲的父亲"链走的.

最后留几个容易忽略的细节:

退出移动版