图 1:本文封面,一对镜像:父先退是孤儿,子先退是僵尸。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「什么是孤儿进程」几乎总是和「僵尸进程」一起被问到,因为它们是同一枚硬币的两面:父子进程总有一个先退出,父先退产生孤儿,子先退而父不收尸就产生僵尸。把这条镜像关系讲清楚,比分别背两个定义更能打动面试官。
孤儿进程:父先退,子被 1 号进程收养。
子进程还在运行,父进程却先退出了,子进程就成了孤儿。操作系统不允许进程「没有爹」——内核会立刻把它过继给 1 号进程(传统上是 init,现代发行版多为 systemd),子进程的 PPID 随即变成 1。收养发生在父退出的那一瞬间,子进程全程无感,该怎么跑还怎么跑。
收养不只是挂个名。1 号进程有一项核心职责:循环调用 wait 回收所有终止的养子。所以孤儿进程将来退出时,退出状态有人接收、进程表项有人清理,不会留下任何残留。结论是:孤儿进程本身基本无害,内核的收养机制把它安排得明明白白。真正的风险在业务层面——失去监管的孤儿会继续占着内存、连接甚至 GPU 一直跑,没人知道它该什么时候停。
僵尸进程:子先退,父不 wait 的残留表项。
镜像的另一面:子进程先退出,父进程还活着却没有调用 wait 系列系统调用。此时子进程的代码、栈、堆早已释放,但内核必须保留一小条进程表项——里面存着 PID 和退出状态,等父进程来取。取走之前,这个「死而不僵」的条目就是僵尸进程,ps 里 STAT 显示 Z,命令名变成 defunct。
僵尸杀不掉——kill 对一个已死的进程毫无意义。正确的处理是让父进程 wait;如果父进程也退出了,僵尸会被过继给 1 号进程,由它兜底 wait 回收。单个僵尸占的资源极小,但父进程如果是个不断 fork 却从不 wait 的服务,成千上万条表项会耗尽 PID 空间,拖垮整台机器。
把两种场景都跑出来。
下面用 Python 3 在同一份脚本里复现两个场景(python3 demo.py orphan 与 python3 demo.py zombie):
import os, sys, time, subprocess
mode = sys.argv[1]
if mode == "orphan":
pid = os.fork()
if pid > 0:
print(f"[父] pid={os.getpid()} 马上退出,留下子进程 {pid}", flush=True)
os._exit(0) # 父进程立刻退出
time.sleep(1) # 给父进程退出留时间
print(f"[子] pid={os.getpid()} 父已退出,现在 PPID={os.getppid()}(被 1 号进程收养)", flush=True)
print("[子] 正常跑完退出,由收养者 wait 回收,无残留", flush=True)
elif mode == "zombie":
pid = os.fork()
if pid == 0:
os._exit(42) # 子进程立刻退出
time.sleep(1) # 父进程故意不 wait
out = subprocess.run(["ps", "-o", "pid,ppid,stat,comm", "-p", str(pid)],
capture_output=True, text=True).stdout
print(f"[父] 子进程 {pid} 已退出但我还没 wait,ps 看到:", flush=True)
print(out, flush=True)
_, status = os.waitpid(pid, 0)
print(f"[父] waitpid 收回退出码 {os.waitstatus_to_exitcode(status)},僵尸消失", flush=True)
实测输出(PID 以实际运行为准):
[父] pid=28778 马上退出,留下子进程 28779
[子] pid=28779 父已退出,现在 PPID=1(被 1 号进程收养)
[子] 正常跑完退出,由收养者 wait 回收,无残留
[父] 子进程 28781 已退出但我还没 wait,ps 看到:
PID PPID STAT COMM
28781 28780 Z <defunct>
[父] waitpid 收回退出码 42,僵尸消失
孤儿场景里,子进程一秒后打印出的 PPID 已经变成 1,收养是即时且透明的;僵尸场景里,ps 抓到了 STAT 为 Z、名字是 defunct 的表项,父进程一次 waitpid 就收回退出码 42,表项随之清除。
图 2:两条时间线与三行对比表,一对镜像各自的生命周期。
工程上的两个抓手。
第一,不想让子进程变成失控孤儿,Linux 下可以在子进程里调用 prctl(PR_SET_PDEATHSIG, SIGKILL),父进程一死内核就给子进程发信号,实现「父子同归于尽」——任务管理器拉起训练任务时,这行调用能避免父进程崩溃后 GPU 被孤儿任务白白占着。第二,不想制造僵尸,父进程要么及时 wait,要么处理好 SIGCHLD;把收尸逻辑写进事件循环,是长生命周期服务的基本素养。
回到面试现场,建议的答题结构:先一句话点出镜像关系(父先退 vs 子先退),再分别讲产生条件与终局(孤儿被 1 号收养并兜底回收,基本无害;僵尸残留表项,kill 无效、必须 wait),最后用「危害与处理」三行对照收尾,能带出 PR_SET_PDEATHSIG 就是加分项。你在生产上遇到过失控孤儿或僵尸堆积吗?最后是怎么清理的?欢迎分享。