深蓝黑背景封面:左侧上下两块对比示意面板——上面青色面板「父进程先退出 → 孤儿」:父进程 exit 的芯片经箭头指向「子进程仍在跑」,再经箭头指向「PID 1 收养」,注释说明子进程 PPID 变为 1、继续正常跑,退出时由 init 负责回收、基本无害;下面紫色面板「子进程先退出 → 僵尸」:子进程 exit 的芯片经箭头指向「父不 wait」,再指向「Z defunct 僵尸」,注释说明子已死但进程表项删不掉,父 wait() 后才真正消失。右侧大标题「孤儿进程 vs 僵尸进程」,副标题「父先退与子先退,一对镜像」,补充语「一个被收养,一个赖着不走」;左上标注「面试经典 · 操作系统」,右上圆角胶囊标注「Day 05 · 11-12」 图 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,表项随之清除。

上方左右两条三阶段时间线对照——左侧青色面板「孤儿进程:父先退」:阶段一父子都在跑(父 PID 3201、子 PID 3202),阶段二父 exit 后子的 PPID 变为 1、被 init 收养,阶段三子正常跑完、由 init wait 回收、无残留;注释指出收养发生在父退出瞬间、子进程全程无感,想让孤儿随父一起死可用 prctl(PR_SET_PDEATHSIG)。右侧紫色面板「僵尸进程:子先退」:阶段一父子都在跑,阶段二子 exit 而父还没 wait、子状态变 Z,阶段三父 wait() 后表项清除,父若先退则由 init 兜底;注释指出僵尸只剩进程表项、kill 杀不掉它,排查时 ps 里 STAT 为 Z、CMD 显示 defunct。下方是三行两列对比表,维度为成因、危害、处理:孤儿是父进程先退出而子进程还在运行,基本无害但失控孤儿会一直占着资源跑,被 PID 1 收养、退出时自动回收;僵尸是子进程先退出而父进程没有 wait,残留进程表项、量大可耗尽 PID 空间,父 wait 即清除、父也退则由 init 兜底 图 2:两条时间线与三行对比表,一对镜像各自的生命周期。

工程上的两个抓手。

第一,不想让子进程变成失控孤儿,Linux 下可以在子进程里调用 prctl(PR_SET_PDEATHSIG, SIGKILL),父进程一死内核就给子进程发信号,实现「父子同归于尽」——任务管理器拉起训练任务时,这行调用能避免父进程崩溃后 GPU 被孤儿任务白白占着。第二,不想制造僵尸,父进程要么及时 wait,要么处理好 SIGCHLD;把收尸逻辑写进事件循环,是长生命周期服务的基本素养。

回到面试现场,建议的答题结构:先一句话点出镜像关系(父先退 vs 子先退),再分别讲产生条件与终局(孤儿被 1 号收养并兜底回收,基本无害;僵尸残留表项,kill 无效、必须 wait),最后用「危害与处理」三行对照收尾,能带出 PR_SET_PDEATHSIG 就是加分项。你在生产上遇到过失控孤儿或僵尸堆积吗?最后是怎么清理的?欢迎分享。