图 1:本文封面,僵尸卡片只会越积越多。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。题目问「僵尸进程是什么,如何查找」。这道题的价值在于它逼你看懂进程生命周期的最后一步:退出不等于消失。
产生链路:退出之后,还差一次 wait。
子进程调用 exit 后,内存、文件描述符这些资源内核会立刻释放,但有一样东西必须留着:进程表里的那一行,连同退出码。为什么?因为父进程有权知道「孩子怎么死的」。Linux 的 wait(2) 手册写得很清楚:已终止但还没被 wait 的子进程就处于「僵尸」状态,内核为它保留 PID、终止状态、资源用量这三样最少信息,等父进程来读。
所以僵尸的产生条件只有一个:子进程死了,父进程没调 wait。责任永远在父进程——杀僵尸进程本身没有用,它已经死了,表项纹丝不动。
危害:一个不可怕,一万个就是事故。
单个僵尸只占进程表一行,几乎不耗资源。但如果父进程是个长期运行、不断 fork 又从不 wait 的程序,僵尸会稳定堆积。wait(2) 手册点明了后果:僵尸占着内核进程表的槽位,表满了就 fork 不出新进程——这时候系统的「内存还很空,但什么都起不来」,排查半天才发现是 PID 被僵尸吃光了。
定位:ps 里的 Z 和 defunct。
ps aux 或 ps -el 里,僵尸进程的 STAT 列是 Z,命令名显示 <defunct>。找到 Z 状态的行之后看它的 PPID——父进程才是真正的病根。配合 ps -o pid,ppid,stat,cmd 按 PPID 聚合,一眼就能看出是哪个父进程在制造僵尸。
清理的三条路径。
第一条,治本:让父进程正确处理。POSIX 语义下子进程退出时父进程会收到 SIGCHLD 信号,在信号处理函数或主循环里调用 wait/waitpid 把退出码读走,表项即被内核删除。wait(2) 手册还记了一个更省事的开关:把 SIGCHLD 的处置显式设为 SIG_IGN(或设 SA_NOCLDWAIT 标志),子进程终止时就不会变成僵尸,内核直接回收。
第二条,兜底:重启或杀死父进程。父进程一退出,它留下的僵尸变成孤儿,被 1 号进程(init,或 subreaper)收养,init 会自动 wait 把它们清理干净。
第三条,预防:写守护脚本、守护进程时把「回收子进程」当成必做项——拉起 worker 的进程必须同时承担 wait 的职责。
用代码把整条链路跑一遍。
下面用一段进程表模拟演示三个场景,用 Node.js 22.15.0 直接运行(node zombie.js):
'use strict';
// 进程表语义模拟:僵尸进程如何产生、堆积与被回收
class ProcessTable {
constructor(limit = 8) {
this.table = new Map(); // pid -> { state, parent, exitCode }
this.nextPid = 1;
this.limit = limit;
}
fork(parent) {
if (this.table.size >= this.limit) throw new Error('fork 失败:进程表已满');
const pid = this.nextPid++;
this.table.set(pid, { state: 'R', parent, exitCode: null });
return pid;
}
exit(pid, code) { // 释放内存/文件,但保留表项与退出码,等待父进程 wait
const p = this.table.get(pid);
p.state = 'Z'; p.exitCode = code;
}
wait(parent) { // 父进程回收:读到退出码,表项才真正删除
for (const [pid, p] of this.table) {
if (p.parent === parent && p.state === 'Z') {
this.table.delete(pid);
return { pid, code: p.exitCode };
}
}
return null;
}
reapOrphans() { // 父进程已退出:孤儿被 1 号进程收养并逐个 wait
const init = 0;
let reaped = 0;
for (const [, p] of this.table) if (p.state === 'Z' && !this.table.has(p.parent)) p.parent = init;
while (this.wait(init)) reaped++;
return reaped;
}
zombies() {
return [...this.table].filter(([, p]) => p.state === 'Z').map(([pid]) => pid);
}
ps() {
return [...this.table].map(([pid, p]) => `${pid} ${p.state}${p.state === 'Z' ? ' <defunct>' : ''}`);
}
}
// 场景一:父进程不 wait,僵尸堆积直到进程表耗尽
const t = new ProcessTable(5);
const shell = t.fork(0); // pid 1,父是 0(init)
console.log('父进程 shell 启动:pid', shell);
while (true) {
try {
const c = t.fork(shell);
t.exit(c, 0); // 子进程立即退出,父进程从不 wait
} catch (e) {
console.log(`连续 fork 子进程后:${e.message}`);
break;
}
}
console.log('ps 输出:', t.ps().join(' '));
console.log('僵尸列表:', t.zombies(), '—— 杀 Z 状态进程没有用,根因在父进程');
// 场景二:父进程被杀死,1 号进程收养并回收全部僵尸
t.table.delete(shell); // shell 退出,其留下的僵尸成为孤儿
const reaped = t.reapOrphans();
console.log(`父进程退出后由 init 收养回收:清理了 ${reaped} 个僵尸,表项剩 ${t.table.size} 个`);
// 场景三:守护脚本正确写法——SIGCHLD 到来时主动 wait
const t2 = new ProcessTable();
const w = t2.fork(0);
for (let i = 0; i < 3; i++) {
const c = t2.fork(w);
t2.exit(c, i);
const r = t2.wait(w); // 正确做法:每收到 SIGCHLD 就 wait
console.log(`worker 退出码 ${r.code} 已被父进程 wait 回收,进程表无残留:${t2.zombies().length === 0}`);
}
实测输出:
父进程 shell 启动:pid 1
连续 fork 子进程后:fork 失败:进程表已满
ps 输出: 1 R 2 Z <defunct> 3 Z <defunct> 4 Z <defunct> 5 Z <defunct>
僵尸列表: [ 2, 3, 4, 5 ] —— 杀 Z 状态进程没有用,根因在父进程
父进程退出后由 init 收养回收:清理了 4 个僵尸,表项剩 0 个
worker 退出码 0 已被父进程 wait 回收,进程表无残留:true
worker 退出码 1 已被父进程 wait 回收,进程表无残留:true
worker 退出码 2 已被父进程 wait 回收,进程表无残留:true
场景一最能说明问题:进程表上限只有 5,shell 加四个僵尸就把表塞满了,再 fork 直接失败——真实系统里这就是「内存充足却起不了新进程」的成因。场景二演示了最后的兜底机制:父进程一死,init 收养并清空全部僵尸。
图 2:产生在 exit,滞留在 wait 缺席,清理靠父进程或 init。
工程关联与可能的追问。
推理 worker 由守护脚本拉起时,子进程崩溃退出若没人 wait,僵尸就会一个个堆起来——看懂 Z 状态,才能把守护逻辑写对。「僵尸和孤儿是一回事吗?」不是:孤儿是父进程先死、子进程还活着,被 init 收养,无害;僵尸是子进程先死、父进程不回收,有害。「kill -9 僵尸有用吗?」没用,信号无处可送,要 kill 的是它的父进程。「怎么防止守护进程制造僵尸?」处理 SIGCHLD 时循环 waitpid 加 WNOHANG,或直接设 SIG_IGN / SA_NOCLDWAIT。
回到面试现场,建议的答题结构:一句话定义(已退出但未被 wait 的进程表项),产生条件与责任归属(父进程),危害(进程表/PID 耗尽),定位(ps 的 Z 与 defunct、顺 PPID 找病根),三条清理路径收尾。你在线上遇到过僵尸进程堆积吗?最后查到的父进程是谁?欢迎分享。