2024 年,我接手维护一个跑了很久的后台服务。某天巡检,我习惯性地 ps 看了一眼进程,发现一个奇怪的现象:这台机器上,挂着一大串状态是 Z 的进程,名字后面都跟着一个刺眼的 <defunct>。我数了数,有三十多个,而且这个数字还在慢慢往上涨。我以前对 <defunct> 这个词只有个模糊印象,知道它叫"僵尸进程",但从没认真处理过。我第一反应是:进程嘛,杀掉就好。我挑了一个,kill 它——没反应;我换成 kill -9,这可是"必杀",连这个进程都不会放过的信号——结果还是没反应,那个 Z 状态的进程,纹丝不动地待在 ps 列表里。我连着 kill -9 了好几个,一个都杀不掉。一个连 kill -9 都"杀不死"的进程——这彻底颠覆了我的认知,在我朴素的理解里,kill -9 是没有任何进程能扛得住的。我盯着这一串杀不掉的僵尸想了很久,最后才意识到:我搞错了一件最根本的事——僵尸进程,它根本就不是一个"还活着、需要被杀掉"的进程;它早就死了。这件事逼着我把进程的生命周期、父子关系、wait() 回收这一整套彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,一个长期运行的后台服务
事故现象:
- ps 里挂着三十多个状态为 Z、名字带 的进程
- 这个数字还在缓慢上涨
- ★ kill 杀不掉,连 kill -9 也杀不掉
现场排查:
# 1. 看到一堆 Z 状态进程
$ ps -ef | grep defunct | grep -v grep
app 20131 9088 0 ... [worker] # ★ Z 状态
app 20148 9088 0 ... [worker]
app 20165 9088 0 ... [worker]
... (三十多个)
# 2. ★ kill / kill -9 都杀不掉
$ kill -9 20131
$ ps -p 20131
PID ... STAT
20131 ... Z # ★ 还在,还是 Z
# 3. ★ 关键:看这些僵尸的【父进程】是谁(PPID 那一列)
$ ps -eo pid,ppid,stat,comm | grep -w Z
20131 9088 Z worker
20148 9088 Z worker
20165 9088 Z worker
# ^^^^ ★ 父进程 PPID 全是 9088 —— 同一个爹
# 4. ★ 看 9088 这个父进程是什么
$ ps -p 9088 -o pid,stat,comm
PID STAT COMMAND
9088 Sl mydaemon # ★ 父进程活得好好的
根因(后来想清楚的):
1. ★ 僵尸进程(Z / )【不是活着的进程】。
它的代码早已停止运行、内存早已释放。它已经死了。
2. 一个子进程退出后,内核不会立刻把它彻底抹掉,
而是【保留一个很小的"尸体"】—— 里面记着它的
退出码等信息,等它的【父进程】来"收尸"。
3. ★ 父进程"收尸"的动作,就是调用 wait() 系列函数,
把子进程的退出状态读走。读走之后,内核才会把这个
"尸体"和它占的 PID 表项【彻底回收】。
4. 我这个服务的父进程 mydaemon,fork 出大量子进程
干活,子进程干完就退出了 —— 但父进程的代码
【忘了调 wait() 去回收】。于是子进程的尸体一具具
堆积,就成了一片僵尸。
5. ★ kill -9 杀不掉僵尸,因为 kill 是发给【活进程】
的信号 —— 而僵尸已经死了,没有代码在运行,信号
没有任何东西可以作用。你没法"杀死一个死人"。
僵尸 ≠ 失控的活进程,僵尸 = 没人收尸的死进程。要动的是它父进程。
修复 1:僵尸进程到底是什么——它已经死了
# === ★ 纠正最大的误解:僵尸不是"活着的坏进程" ===
# === 我以为的 vs 真实的 ===
# 我以为:Z 状态的僵尸进程,是一个【出了问题、还在
# 占着资源、需要我杀掉】的活进程。所以我去 kill 它。
# ★ 真相:僵尸进程【已经死了】。它的程序代码早已
# 停止执行,它占的内存、打开的文件,内核都已经
# 回收干净了。它不消耗 CPU,也不消耗内存。
# ★ 它唯一还占着的,是进程表里的【一个表项】——
# 也就是【一个 PID 号】,外加一点点退出信息。
# === ★ 一个进程退出时,到底发生了什么 ===
# 一个子进程调 exit() 退出,内核做的事【分两步】:
# 1. ★ 立刻回收"重的东西":内存、文件描述符、各种
# 资源 —— 这些当场就释放了。
# 2. ★ 保留"轻的东西":一个极小的结构,记着它的
# PID、退出码、运行了多久等。这就是"尸体"。
# 为什么要保留这个尸体?★ 因为它的【父进程】可能
# 想知道"我这个孩子是怎么退出的、退出码是几"。
# 内核得把这个信息留着,等父进程来取。
# === ★ "收尸":父进程调 wait() ===
# 父进程调用 wait() / waitpid() 这类函数,就是来
# "取走子进程的退出状态"。这个动作一完成:
# ★ 内核就把那个尸体、那个 PID 表项,彻底删掉。
# 僵尸,就这么"安葬"了。
# 一个进程从子进程退出、到父进程 wait 回收,中间
# 这段"已死、但还没被收尸"的时间,它就处于
# Z(Zombie)状态 —— 显示为 。
# ★ 正常情况下,这个状态只是【一闪而过】的。
# === ★ 僵尸"堆积",是父进程没在收尸 ===
# 如果你看到僵尸【大量堆积、长期不消失】,只有一个
# 原因:它们的父进程,【没有】调用 wait() 去回收。
# 子进程一个个死了,却一个个没人收尸 —— 越堆越多。
# === 认知 ===
# ★ 僵尸进程的问题,从来不在僵尸自己身上(它已经
# 死了,无所谓了)。问题在它的【父进程】—— 是
# 父进程失职,没尽到"收尸"的责任。
修复 2:为什么 kill -9 也杀不掉僵尸
# === ★ 想通一件事:你没法"杀死一个已经死了的进程" ===
# === kill 到底是什么 ===
# kill 这个命令,名字有误导性。它实际做的事是:
# ★ 向一个进程【发送一个信号】。
# kill 9527 -> 给 9527 发 SIGTERM(请求它退出)
# kill -9 9527 -> 给 9527 发 SIGKILL(强制它退出)
# 信号要起作用,前提是:★ 目标进程【还在运行】,
# 它(或内核替它)能【接收并处理】这个信号。
# === ★ 僵尸进程接收不了任何信号 ===
# 僵尸进程已经死了 —— 它没有代码在执行,它不是一个
# "可被调度、可运行"的实体了。
# ★ 你给它发 SIGKILL,这个信号没有任何"活的东西"
# 可以作用 —— 就像你对着一具尸体喊"你给我倒下"。
# 它早就倒下了。SIGKILL 对它【完全无效】。
$ kill -9 20131
$ ps -p 20131 -o stat # 还是 Z —— 信号根本没动它分毫
# === ★ 那僵尸到底怎么才能消失 ===
# 僵尸消失,只有一条路:★ 它的【父进程】调 wait()
# 把它的退出状态取走。除此之外,没有别的办法。
# - kill 僵尸自己 -> 无效(它已经死了)。
# - kill -9 僵尸自己 -> 同样无效。
# ★ 你能操作的,是它的【父进程】。
# === ★ 正确的着力点:找到父进程,让它去收尸 ===
# 思路有两个:
# 1. 给【父进程】发 SIGCHLD 信号 —— 提醒它"你有
# 孩子死了,快去 wait"。但★ 这招经常没用:如果
# 父进程的代码压根没写处理 SIGCHLD 的逻辑,
# 提醒了也白提醒。
$ kill -CHLD 9088 # 试试提醒父进程(常常无效)
# 2. ★ 真正可靠的一招:让【父进程】退出 / 重启。
# 见下一节 —— 这才是现场能立刻见效的办法。
# === 认知 ===
# ★ 对僵尸进程本身做任何事都是徒劳的。kill -9 杀不掉
# 它,不是它"强",是它"已经不在了"。所有动作,
# 都要对准它的【父进程】。
修复 3:现场怎么清掉一片僵尸
# === ★ 清僵尸:动父进程,分两种情况 ===
# === 第一步:先把"父进程"找出来 ===
# 僵尸的 PPID 那一列,就是父进程的 PID。
$ ps -eo pid,ppid,stat,comm | awk '$3 ~ /Z/'
20131 9088 Z worker
20148 9088 Z worker
# ^^^^ ★ 父进程是 9088
# 看看 9088 是个什么进程:
$ ps -p 9088 -o pid,ppid,stat,comm,args
# === ★ 情况 A:父进程还活着 —— 重启父进程 ===
# 这是最常见的情况,也是现场最快的解法。
# ★ 重启那个【父进程】(不是僵尸!)。父进程一退出,
# 会发生一件很妙的事 ——
$ systemctl restart mydaemon # 重启那个父服务
# 父进程死后,它名下那些僵尸子进程就"没爹"了。内核
# 会把这些孤儿(包括僵尸)统统【过继给 1 号进程】。
# ★ 1 号进程(init / systemd)是个尽职的"养父",
# 它有一个一直在 wait() 的循环,会立刻把过继来的
# 僵尸全部收尸。—— 僵尸瞬间清空。
# === ★ 情况 B:不能重启父进程 —— 先 kill 父进程的活子进程 ===
# 有时父进程不能停。可以试着【温和地提醒】它:
$ kill -CHLD 9088 # 提醒父进程去 wait
# ★ 但这招能不能成,完全取决于父进程的代码有没有写
# 收尸逻辑。多数"会堆僵尸"的程序,恰恰就是没写,
# 所以这招经常无效。无效的话,还是得回到重启。
# === ★ 一个关键澄清:僵尸"占" PID,会不会撑爆系统 ===
# 系统的 PID 总数有上限:
$ cat /proc/sys/kernel/pid_max
32768
# 每个僵尸占 1 个 PID。如果僵尸无限堆积,极端情况下
# 会把 PID 耗光 —— 那时整个系统【fork 不出新进程】,
# 连 ls 都可能跑不起来。
# ★ 所以僵尸虽然"已经死了、不占 CPU 内存",但任由
# 它堆积,依然是个真问题 —— 它在慢慢漏 PID。
# === 验证 ===
$ ps -eo stat | grep -c Z # Z 状态进程数,应归零
# ★ 重启父进程后,这个数字应该立刻变 0。
修复 4:Z 状态、D 状态、孤儿进程——别搞混
# === ★ 几个容易混淆的进程状态,一次理清 ===
# === ps 的 STAT 列,常见状态 ===
$ ps -eo pid,stat,comm
# R 运行中 / 可运行
# S 可中断睡眠(在等事件,绝大多数进程平时都是 S)
# D ★ 不可中断睡眠 —— 见下,这个才真"杀不掉"
# Z ★ 僵尸(已死,等父进程收尸)
# T 已停止(被 SIGSTOP 暂停)
# === ★ Z 状态:已经死了,kill 无效,要动父进程 ===
# 本文主角。不占 CPU / 内存,只占一个 PID 表项。
# === ★ D 状态:这个才是真正"kill -9 都杀不掉"的活进程 ===
# D = 不可中断睡眠。进程卡在某个【内核操作】里 ——
# 最典型的是【等一个很慢的 IO】(比如 NFS 卡住、
# 磁盘故障)。
# ★ D 状态的进程是【活的】,但它连信号都【暂时无法
# 响应】—— 所以 kill -9 也确实杀不动它,直到那个
# IO 完成或超时。
$ ps -eo pid,stat,comm | awk '$2 ~ /D/'
# ★ 区别要记牢:
# - Z 杀不掉,是因为它【已经死了】(动父进程)。
# - D 杀不掉,是因为它【卡在内核里】(查那个慢 IO)。
# 两者现象像,根因和处理完全不同,别搞混。
# === ★ 孤儿进程:和僵尸是两回事,而且【没有害】 ===
# 孤儿进程 = 父进程先退出了、它自己还活着的子进程。
# 它会被立刻【过继给 1 号进程(init/systemd)】。
# ★ 关键:1 号进程是个尽职的养父,会负责 wait 回收
# 它将来的退出。所以孤儿进程【完全无害】,它有
# 一个靠谱的新爹。
# ★ 别把"孤儿"当问题 —— 真正的问题是"僵尸",是
# "亲爹还在、却不收尸"。孤儿恰恰是因为爹换成了
# 靠谱的 init,反而没事。
# === 一条命令分清现状 ===
$ ps -eo stat | sort | uniq -c | sort -rn
# 看看 Z 有多少、D 有多少,心里就有数了。
修复 5:正确解法——根治在父进程的代码
# === ★ 解法:现场重启救急,根治在父进程收尸 ===
# === ★ 解法 1:现场救急 —— 重启父进程 ===
# 见修复 3。重启父进程,僵尸过继给 init 被秒收。
$ systemctl restart 那个父服务
$ ps -eo stat | grep -c Z # 确认归零
# ★ 但这只是"清扫现场"。父进程的代码不改,僵尸
# 还会再长出来。
# === ★ 解法 2:根治 —— 父进程必须 wait() 回收子进程 ===
# 病根永远在父进程的代码:它 fork 了子进程,却忘了
# 在子进程退出后调 wait()。两种正确写法:
# 写法一:专门处理 SIGCHLD 信号,在处理函数里 wait。
# 子进程一退出,内核给父进程发 SIGCHLD,父进程
# 就在这里把尸体收掉:
void on_sigchld(int sig) {
// ★ while + WNOHANG:一次性把所有已退出的子进程
// 都收掉(可能同时死了好几个)
while (waitpid(-1, NULL, WNOHANG) > 0) { }
}
signal(SIGCHLD, on_sigchld);
# 写法二:如果父进程根本不关心子进程的退出状态,
# 可以明确告诉内核"我不收尸了,你直接处理":
signal(SIGCHLD, SIG_IGN);
# ★ 设了 SIG_IGN,子进程退出后内核【不会】生成
# 僵尸,自动清理。适合"fork 完就不管"的场景。
# === ★ 解法 3:各语言里别忘了"收尸" ===
# - Shell 脚本:后台跑了 cmd &,记得在合适的地方
# wait # 等所有后台子进程
# - Python:subprocess / os.fork 之后,要
# p.wait() 或 os.waitpid(pid, 0)
# 用 multiprocessing 时,join() 子进程。
# - 用现成的进程管理器(supervisor、systemd)来拉起
# 服务,它们会负责回收 —— 比自己 fork 省心。
# === ★ 解法 4:容器里要当心 1 号进程 ===
# 容器里,你的应用常常【自己就是 1 号进程】。可一般
# 的应用程序,没有 init 那种"自动收尸"的循环 ——
# 于是容器里的僵尸没人管,会一直堆。
# ★ 解决:给容器加一个轻量 init 作为 1 号进程,
# 由它负责收尸:
$ docker run --init 你的镜像 # ★ --init 注入一个 init
# 或在 Dockerfile 里用 tini 之类作为 ENTRYPOINT。
# === 验证 ===
$ ps -eo stat | grep -c Z # 现在应长期为 0
$ watch -n5 "ps -eo stat | grep -c Z" # 盯一会儿,不再增长
# ★ 改了父进程代码、压测一段时间僵尸不再长,才算根治。
修复 6:进程状态排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ 僵尸进程(Z / )已经死了,不是活进程 ===
# 它不占 CPU / 内存,只占一个 PID 表项。
# === 2. ★ kill / kill -9 杀不掉僵尸,因为信号是发给活进程的 ===
# 你没法杀死一个已经死了的进程。
# === 3. ★ 僵尸的问题在它父进程 —— 父进程没调 wait() 收尸 ===
$ ps -eo pid,ppid,stat,comm | awk '$3 ~ /Z/' # 看 PPID 找父进程
# === 4. 现场清僵尸:重启父进程,僵尸过继给 init 被秒收 ===
# === 5. ★ 根治:父进程代码里 wait()/waitpid,或 SIGCHLD 设 SIG_IGN ===
# === 6. ★ 别把 D 状态当僵尸:D 是活进程卡在内核 IO 里,真杀不动 ===
$ ps -eo pid,stat,comm | awk '$2 ~ /D/' # D 状态去查慢 IO
# === 7. 孤儿进程无害,它被过继给 init,有靠谱养父会回收 ===
# === 8. 僵尸虽不占 CPU 内存,但堆积会漏 PID,极端时 fork 不出新进程 ===
# === 9. 容器里应用常是 1 号进程,没收尸能力,用 docker run --init ===
# === 10. 排查"一堆杀不掉的进程"的步骤链 ===
$ ps -eo pid,ppid,stat,comm | sort -k3 # ① 先看状态:Z 还是 D
$ Z -> awk 找 PPID -> 处理父进程 # ② 僵尸:动父进程
$ D -> 查这个进程卡在什么 IO 上 # ③ D 状态:查慢 IO
$ 根治 Z -> 父进程加 wait() 收尸逻辑 # ④ 改代码根治
# 按这个顺序,"杀不掉的进程"基本能分清、能根治。
命令速查
需求 命令
=============================================================
找所有僵尸进程 ps -ef | grep defunct | grep -v grep
看进程状态和父进程 ps -eo pid,ppid,stat,comm
只挑出 Z 状态进程 ps -eo pid,ppid,stat,comm | awk '$3 ~ /Z/'
统计各状态进程数 ps -eo stat | sort | uniq -c | sort -rn
数僵尸进程个数 ps -eo stat | grep -c Z
找某僵尸的父进程 ps -p -o pid,stat,comm,args
提醒父进程去收尸 kill -CHLD <父进程PID>
重启父进程清僵尸 systemctl restart <父服务>
看 D 状态进程 ps -eo pid,stat,comm | awk '$2 ~ /D/'
看系统 PID 上限 cat /proc/sys/kernel/pid_max
容器里防僵尸 docker run --init <镜像>
口诀:僵尸已经死了不是活进程,kill -9 杀不掉因为信号是发给活进程的
问题在父进程没收尸,现场重启父进程过继给 init,根治靠父进程代码 wait()
避坑清单
- 僵尸进程 Z 状态带 defunct 已经死了不是活进程,代码停了内存释放了只占一个 PID 表项
- 子进程退出后内核保留一个极小的尸体记着退出码,等父进程来取走,这就是僵尸的由来
- 父进程调 wait 或 waitpid 取走子进程退出状态后,内核才彻底回收尸体和那个 PID 表项
- kill 和 kill -9 杀不掉僵尸,因为信号是发给活进程的,你没法杀死一个已经死了的进程
- 僵尸大量堆积长期不消失只有一个原因,它们的父进程没有调 wait 去回收,问题在父进程
- 现场清僵尸靠重启父进程,父进程一死僵尸过继给 1 号进程 init,init 会立刻把它们收掉
- 根治在父进程代码,处理 SIGCHLD 时 while waitpid 收尸,或不关心退出码就设 SIG_IGN
- 别把 D 状态当僵尸,D 是活进程卡在内核不可中断 IO 里真杀不动,要去查那个慢 IO
- 孤儿进程和僵尸是两回事且无害,它被过继给 init 有靠谱养父会负责回收它将来的退出
- 容器里应用常自己就是 1 号进程没有收尸能力,僵尸会一直堆,用 docker run --init 注入 init
总结
这次"一堆进程连 kill -9 都杀不掉"的事故,纠正了我一个关于"进程"的、特别基础却特别要命的误解。在我的脑子里,凡是能在 ps 列表里出现的东西,就都是一个"进程",而进程,在我的理解里只有两种状态:活着,或者不存在。它要么在运行、占着资源、能被我操作;要么就彻底消失、从列表里清掉。我从来没有想过,在"活着"和"不存在"之间,还存在一个我完全陌生的中间态——一个进程,可以已经死了,却还没有从列表里消失。正因为我脑子里只有这个非黑即白的二元模型,所以当我看到那一串 Z 状态的进程时,我下意识地、不假思索地把它们归进了"活着"那一类——它们明明白白地列在 ps 里嘛。既然它们"活着"、又"有问题",那处理方式就只有一个:杀掉。于是我 kill,我 kill -9,我把我所知道的、最强硬的手段全用上了。而当 kill -9 这个我以为"无坚不摧"的信号也对它们毫无作用时,我感到的是一种认知崩塌般的荒谬:在我的世界观里,就不该存在 kill -9 杀不掉的进程。我把整件事的难点,理解成了"这进程怎么这么顽固"。复盘到根上,我才明白,我从一开始就站错了。kill -9 杀不掉那些僵尸,根本不是因为它们"顽固"、"强大",恰恰相反,是因为它们【太弱了】——弱到已经不存在了。它们早就死了。一个子进程调用 exit() 的那一刻,它的程序代码就停止了执行,它占用的内存、打开的文件,内核当场就回收得干干净净。它已经不是一个能运行、能被调度、能接收信号的实体了。kill 这个命令,我一直望文生义地以为它的意思是"杀死",可它真正做的事,只是"向一个进程发送一个信号"——而信号要起作用,前提是收信的那一方还活着、还有代码在运行、还能对这个信号做出反应。我对着一具尸体发送 SIGKILL,这个信号没有任何活的东西可以承接,它就那样落了空。我之前那个"kill -9 无所不能"的信念,其实暗含了一个我从未察觉的前提:目标必须是活的。对一个死人,再强的"杀"也只是徒劳。那么,僵尸为什么死了却还赖在列表里?因为一个进程的退出,不是它自己一个人的事——它的父进程,可能还想知道"我这个孩子,是正常退出的,还是出错了,退出码是几"。所以内核很贴心地,在子进程死后,替它保留了一具极小的"尸体",里面就记着这么点身后信息,静静地等着父进程来"收尸"——来调用一次 wait(),把这点信息取走。取走的那一刻,内核才会把尸体连同那个 PID 一起,彻底抹去。而我那个服务的悲剧在于:父进程是个不称职的家长,它一个接一个地 fork 出子进程去干活,子进程们尽职地干完、尽职地退出,可这个父进程,从头到尾,没有为它们中的任何一个收过尸。于是尸体越堆越多,堆成了我看到的那一片。这次最大的收获,是我意识到,我那个"非生即死、非有即无"的二元模型,太粗糙了,粗糙到容不下"已死、但尚未被清理"这样一个真实存在的中间状态。而当现实里出现了一个我的模型里没有的状态时,我不但认不出它,还会强行把它塞进我已有的某个类别里——我把一个"死进程"错认成了"顽固的活进程",于是后面所有的努力,方向从一开始就是反的。真正该问的问题,从来不是"我该用多大的力气杀掉它",而是退一步先搞清楚:我眼前这个东西,它现在究竟处于一种什么样的状态?它还活着吗?如果它已经死了,那么能改变现状的,根本不是我对它做什么,而是我去找那个本该为它负责、却失了职的人。所以下一次,当某样东西"怎么弄都弄不动"的时候,我会先停下来,不再加大力气,而是反过来问:它是不是根本就不在我以为的那个状态里?——很多解不开的死结,不是因为结打得太紧,而是因为我一直在拉错的那根绳子。
—— 别看了 · 2026