2024 年,一台服务器的进程数监控开始一路向上爬,从几百涨到上千,还在涨。我登上去用 ps 一看,密密麻麻一大片进程,名字后面都挂着一个刺眼的标记:<defunct>。我以前零星见过这个词,没在意,这次它成片成片地出现,我才正眼打量它。第一反应当然是杀——我抓起一个 <defunct> 的 PID,kill 了一下,没反应。我加上 -9,kill -9,这可是号称"无法被忽略"的死刑信号,再 ps 一看——它还在。我又试了几个,无一例外:kill -9 对它们,像是打在了棉花上,毫无作用。这让我彻底懵了:kill -9 是我所知道的、对付一个进程最后、最硬的手段,它居然杀不动?一个连 kill -9 都无视的进程,到底是什么?后来我才明白,我犯了一个根本性的错误——我一直把这些 <defunct> 当成"活着的进程"在杀,可它们根本就不是。这件事逼着我把 Linux 的进程生命周期、僵尸进程、父子进程关系这一整套彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,一个会频繁 fork 子进程的服务
事故现象:
- 进程数监控持续上涨,从几百涨到上千
- ps 里成片的 进程
- ★ kill -9 这些 ,完全无效,杀不掉
现场排查:
# 1. 看到一堆
$ ps -ef | grep defunct
appuser 9011 8800 ... [worker] # ★ defunct = 僵尸
appuser 9012 8800 ... [worker]
appuser 9013 8800 ... [worker]
# ... 几百行
# 2. ★ kill -9 它 —— 没用
$ kill -9 9011
$ ps -p 9011
9011 ... [worker] # ★ 还在!
# 3. ★ 看进程状态:是 Z 状态
$ ps -eo pid,ppid,state,comm | awk '$3=="Z"'
9011 8800 Z worker # ★ Z = Zombie 僵尸
9012 8800 Z worker
# ★ 注意第二列 ppid:这些僵尸的父进程,都是 8800
# 4. ★ 看那个共同的父进程 8800
$ ps -p 8800 -o pid,state,comm
8800 S myservice # ★ 父进程还活着
根因(后来想清楚的):
1. ★ (Z 状态)进程,根本【不是活着的进程】。
它是一个【已经死了】的子进程,留下的一条"退出记录"。
2. Linux 的规矩:子进程退出后,不会立刻彻底消失,
它要【等父进程来"确认"一下】它的退出状态(收尸)。
3. ★ 父进程"收尸"的动作,叫 wait();收完,这条记录
才真正从系统里删掉。
4. 我这个服务(8800)疯狂 fork 子进程干活,子进程
干完就退出了,★ 但父进程有 bug,从不调用 wait() ——
于是每个死掉的子进程,都变成一具没人收的尸体,堆着。
5. ★ kill -9 杀不掉僵尸,因为它【已经死了】——
你没法"杀死"一个死人。kill 信号是发给活进程的。
6. 真正该处理的,是那个不收尸的父进程 8800。
僵尸不是进程,是尸体;清僵尸要找它的父进程,不是杀它。
修复 1:僵尸进程不是进程——它是一具没人收的尸体
# === ★ 先纠正最核心的误解: 不是"活着的进程" ===
# === 一个进程正常的一生 ===
# 被 fork 出来 -> 运行(R/S 状态)-> 自己 exit 退出
# -> ★ 退出后【不是立刻消失】,先变成 Z(僵尸)状态
# -> 父进程来 wait() 收尸 -> 这才真正、彻底地消失。
# === ★ 关键:子进程退出后,为什么不立刻消失 ===
# 因为它退出时带着一份"遗书":退出码是几、是不是被信号杀的。
# 这份遗书,内核要【保留着】,等父进程来读 ——
# 父进程往往需要知道"我那个子进程,干成了还是干砸了"。
# ★ 在父进程来读这份遗书之前,内核就把子进程的"户口"
# 留着不销 —— 这个"只剩一份遗书、本体已死"的状态,
# 就是僵尸(Zombie / Z 状态 / )。
# === ★ 所以僵尸进程到底是什么 ===
# 它【不占用】CPU,【不占用】内存(本体的内存早释放了)。
# 它几乎什么资源都不占 —— 只占一样东西:
# ★ 一个【进程表项】(PID 槽位)。
# 它就是进程表里一行"还没注销的死亡登记"。
# === ★ 于是 kill -9 杀不掉它,天经地义 ===
# kill 发的是【信号】,信号是给【活着的进程】响应的。
# 僵尸进程的本体【已经死了】,没有任何代码在跑,
# 它根本【没有能力去响应任何信号】——
# 你没法用"杀"这个动作,去作用于一个已经死透的东西。
# ★ 把僵尸当活进程杀,方向从一开始就错了。
# === 僵尸多了,真正的危害是什么 ===
# 单个僵尸几乎无害。但它占着 PID 槽位 ——
# ★ 系统的 PID 总数有上限(/proc/sys/kernel/pid_max)。
# 僵尸成千上万地堆,会把 PID 耗尽 ->
# ★ 系统再也 fork 不出新进程 -> 新连接、新任务全失败。
# 我这次进程数飙到上千,就是在逼近这个上限。
修复 2:僵尸是怎么产生的——父进程不收尸
# === ★ 僵尸的成因,永远在【父进程】身上 ===
# === 正常情况:父进程负责收尸 ===
# 父进程 fork 出子进程后,有责任在子进程退出后,
# 调用 wait() / waitpid() 去"认领"它的退出状态。
# wait() 一调用 -> 内核读走那份"遗书" ->
# ★ 僵尸的进程表项被注销 -> 僵尸彻底消失。
# === ★ 僵尸产生 = 父进程【没有】调用 wait() ===
# 子进程死了、变僵尸了,可父进程:
# - 要么程序有 bug,压根没写收尸的逻辑
# - 要么父进程自己太忙,迟迟顾不上收
# -> 没人来 wait() -> 僵尸就一直挂着,清不掉。
# ★ 我这次就是父进程代码里漏了 waitpid —— 它只管
# fork 干活,从不回收,于是僵尸越堆越多。
# === 一个进程的几种状态,认清 Z ===
$ ps -eo pid,ppid,state,comm
# R 运行 / 可运行
# S 可中断睡眠(正常等事件)
# D 不可中断睡眠(通常在等磁盘 IO)
# ★ Z 僵尸 —— 已退出、等父进程收尸
# T 停止(被暂停)
# === ★ 关键区别:Z 状态 vs D 状态,别搞混 ===
# D 状态:进程【还活着】,只是卡在等 IO,kill 不动它,
# 但它【会动】—— IO 完成它就继续。
# Z 状态:进程【已经死了】,kill 不动它,
# 因为它【根本不会再动】—— 它在等的是父进程收尸。
# ★ 两个都"kill 不掉",但一个是活的卡住,一个是死的没销户。
# === 信号 SIGCHLD:子进程退出时,内核给父进程的通知 ===
# 子进程一退出,内核会给父进程发一个 SIGCHLD 信号,
# 等于敲门提醒:"你有个孩子没了,记得来收尸"。
# ★ 写得好的父进程,会在 SIGCHLD 的处理函数里调 wait()。
# 父进程把这个信号忽略了 / 没处理 -> 僵尸就堆起来了。
修复 3:怎么找出僵尸和它的父进程
# === ★ 排查僵尸,核心是【顺藤摸瓜找到父进程】 ===
# === 第一步:把所有僵尸找出来 ===
$ ps -eo pid,ppid,state,comm | awk '$3=="Z"'
9011 8800 Z worker
9012 8800 Z worker
9013 8800 Z worker
# ★ 第 2 列 ppid 是关键 —— 看这些僵尸的父进程是不是同一个。
# === 数一数到底有多少僵尸 ===
$ ps -eo state | grep -c '^Z'
427 # ★ 427 个僵尸,不少了
# === top 也会直接告诉你僵尸数量 ===
$ top
Tasks: 1053 total, 2 running, 624 sleeping, 427 zombie
# ^^^^^^^^^^^ ★ 一眼看到
# === ★ 第二步:锁定那个"不收尸"的父进程 ===
$ ps -eo pid,ppid,state,comm | awk '$3=="Z"' \
| awk '{print $2}' | sort | uniq -c | sort -rn
427 8800 # ★ 427 个僵尸,父进程全是 8800
# 把僵尸按 ppid 归类一统计 —— 真凶父进程立刻浮出水面。
# === 第三步:看清这个父进程是什么 ===
$ ps -p 8800 -o pid,ppid,state,comm,args
8800 1 S myservice /opt/myservice --worker-pool
# ★ 现在明确了:8800 这个 myservice,fork 了一堆子进程,
# 子进程干完都退了,而 8800 从不收尸。
# === 看这个父进程的子进程树,直观感受 ===
$ pstree -p 8800 | head
myservice(8800)─┬─worker(9011) <- 这些 worker 全是僵尸
├─worker(9012)
└─...
# ★ pstree 能把"一个父进程拖着一大串僵尸子进程"画出来。
修复 4:怎么清掉僵尸——别杀它,处理它的父进程
# === ★ 既然 kill 僵尸没用,正确的清理思路只有两条 ===
# === 思路 1:让父进程去收尸 ===
# 僵尸的存在,是因为父进程没 wait()。
# 那就【提醒父进程】去 wait —— 给父进程发 SIGCHLD:
$ kill -s SIGCHLD 8800
# ★ 如果父进程的代码里其实写了 SIGCHLD 处理逻辑,
# 只是一时没触发,这一下能促使它去收尸,僵尸就清了。
# ★ 但如果父进程【根本没写】收尸逻辑(我这次就是)——
# 发 SIGCHLD 也没用,它收不了。那就只能用思路 2。
# === ★ 思路 2:处理掉父进程本身 ===
# 父进程一旦退出 / 重启,它名下那些僵尸子进程会发生什么?
# ★ 它们会被【1 号进程(init/systemd)】"收养"。
# 而 1 号进程有个雷打不动的好习惯:它会【立刻 wait()】
# 收养来的所有僵尸 —— 于是僵尸瞬间被清干净。
# === 所以最直接的清理:重启那个有 bug 的父进程 ===
$ systemctl restart myservice # 父进程是个服务,重启它
# 或者(确认安全后)杀掉父进程,让它的僵尸被 init 收养清掉:
$ kill 8800
# ★ 父进程一走,它的 427 个僵尸全被 init 收养并秒清。
# ★ 注意:杀父进程是有副作用的(它正在提供的服务会断),
# 生产上优先用 systemctl restart,别直接 kill 关键服务。
# === ★ 但这只是"清场",不是"根治" ===
# 重启父进程,把已有僵尸清了 —— 可只要父进程的代码
# 还是不收尸,它跑一阵子,僵尸又会重新堆起来。
# ★ 真正的根治,是【修父进程的程序】:
# - 在 fork 子进程后,正确地 wait() / waitpid() 回收
# - 或在 SIGCHLD 处理函数里循环 waitpid 收掉所有退出的子进程
# - 有些语言/框架:把 SIGCHLD 显式设为 SIG_IGN,
# 内核就会自动回收子进程,根本不产生僵尸
# 这是开发要改的代码,运维只能先重启止血。
# === 处理前先评估:僵尸多但没逼近 PID 上限,可以先不动 ===
$ cat /proc/sys/kernel/pid_max # 看 PID 上限
$ ps -e | wc -l # 看当前进程总数
# ★ 离上限还远 -> 排期让开发修代码;快到上限 -> 立刻重启父进程止血。
修复 5:孤儿进程与 init 收养机制
# === ★ 和僵尸成对出现的另一个概念:孤儿进程 ===
# === 孤儿进程:父进程先死了,子进程还活着 ===
# 父进程退出时,它的子进程可能还在正常运行。
# 这些"没了父亲"的活进程,就叫【孤儿进程】。
# ★ 孤儿进程和僵尸【完全不同】:孤儿是【活的、好好的】。
# === ★ 孤儿进程会被 1 号进程"收养" ===
$ ps -eo pid,ppid,comm | awk '$2==1' | head
1234 1 nginx # ★ ppid 变成了 1
5678 1 some-daemon
# 父进程一死,内核立刻把它的孩子的 ppid 改成 1 ——
# 让 init/systemd(1 号进程)当它们的新父亲。
# ★ 这就是为什么前面说"父进程一退,僵尸被 init 收养清掉":
# init 收养后会尽职地 wait(),孤儿将来退出也不会变僵尸。
# === ★ 这给了我们一个故意制造孤儿的用途:守护进程 ===
# 想让一个程序脱离终端、长期后台运行?
# 经典手法就是 fork 出子进程后,让父进程立刻退出 ——
# 子进程就成了孤儿,被 init 收养,从此独立运行。
$ nohup ./mytask & # nohup + & 是更简单的做法
$ setsid ./mytask # setsid 让进程脱离会话独立
# === ★ 把几个"kill 不动"的状态彻底理清 ===
# Z 僵尸 :已死,等父进程收尸 -> 处理它【父进程】
# D 不可中断睡眠:活着,卡在等 IO -> 等 IO 完成 / 治 IO
# T 停止 :活着,被 SIGSTOP 暂停 -> kill -CONT 唤醒它
# ★ 这三种 ps 里都可能"kill 不掉",但成因和解法各不相同。
# === 看一个进程到底卡在哪种状态 ===
$ ps -o pid,ppid,state,wchan,comm -p PID
# wchan 那列:进程在内核里"睡在哪个函数上",
# 能帮你判断 D 状态的进程到底在等什么。
修复 6:进程状态排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ / Z 状态不是活进程,是没收的尸体 ===
# 它不占 CPU/内存,只占一个 PID 进程表项。
# === 2. ★ kill -9 杀不掉僵尸,因为它已经死了 ===
# 信号是发给活进程的,死进程无法响应任何信号。
# === 3. ★ 清僵尸要找它的父进程,不是杀僵尸本身 ===
$ ps -eo pid,ppid,state | awk '$3=="Z"{print $2}' | sort | uniq -c
# 按 ppid 归类,锁定那个不收尸的父进程。
# === 4. 清场:重启/退出父进程,僵尸被 init 收养秒清 ===
$ systemctl restart 父服务 # 优先用重启,别直接 kill 关键服务
# === 5. ★ 根治靠改代码:父进程要正确 wait() 回收子进程 ===
# 重启只是止血,代码不改僵尸还会再堆。
# === 6. 区分 Z / D / T:都"kill 不掉",成因解法各不同 ===
# === 7. 排查僵尸问题的命令链 ===
$ top # ① 看 zombie 计数
$ ps -eo pid,ppid,state,comm|awk '$3=="Z"' # ② 列出所有僵尸
$ ... | awk '{print $2}'|sort|uniq -c # ③ 按 ppid 找出真凶父进程
$ ps -p 父PID -o comm,args # ④ 看清父进程是什么
$ systemctl restart 父服务 # ⑤ 重启父进程清场
# 按这个顺序,僵尸问题基本能定位。
命令速查
需求 命令
=============================================================
看僵尸进程总数 top 看 zombie 那项
列出所有僵尸进程 ps -eo pid,ppid,state,comm | awk '$3=="Z"'
数一共多少僵尸 ps -eo state | grep -c '^Z'
找出不收尸的父进程 上面结果按 ppid 列 sort | uniq -c
看清某父进程是什么 ps -p PID -o pid,ppid,comm,args
看父进程的子进程树 pstree -p 父PID
提醒父进程收尸 kill -s SIGCHLD 父PID
清场(重启父进程) systemctl restart 父服务
看 PID 上限 cat /proc/sys/kernel/pid_max
看当前进程总数 ps -e | wc -l
口诀:defunct 是尸体不是进程,kill -9 杀不动很正常
清僵尸不杀僵尸本身,找它父进程,重启父进程让 init 收养秒清
避坑清单
- defunct 即 Z 状态进程不是活进程,是已退出但没被父进程回收的尸体
- 僵尸进程不占 CPU 和内存,只占一个 PID 进程表项,堆多了会耗尽 PID
- kill -9 杀不掉僵尸,因为它已经死了,信号只能作用于活着的进程
- 僵尸的成因永远在父进程身上:父进程没有调用 wait 回收子进程
- 清僵尸要找它的父进程,把僵尸按 ppid 归类就能锁定真凶父进程
- 父进程退出后僵尸会被 1 号进程 init 收养,init 会立刻 wait 清掉它们
- 重启父进程只是清场止血,代码不改正确 wait,僵尸还会重新堆起来
- Z 僵尸和 D 不可中断睡眠都 kill 不掉,但 Z 是死的、D 是活着卡在等 IO
- 孤儿进程是父死子还活,它是活的好进程,会被 init 收养不会变僵尸
- 排查时离 PID 上限还远可排期修代码,逼近上限就立刻重启父进程止血
总结
这次"一屏幕 <defunct> 进程、kill -9 杀不动"的事故,纠正了我一个埋得极深的、从未被审视过的本能——在我的认知里,ps 这条命令列出来的每一行,都是一个"进程",而每一个进程,都是一个活着的、能被我指挥、也能被我终结的对象。顺着这个本能,我的处理逻辑简单到不假思索:进程不对劲,就杀掉它;一刀不行,就用最重的那一刀——kill -9。kill -9 在我心里,几乎就是进程世界里的"终极手段",是那个无论如何都能生效的、不容置疑的句号。可这一次,这个句号失灵了。我对着那些 <defunct> 一遍遍地 kill -9,它们却纹丝不动,像是我的命令打在了空气里。这个"kill -9 居然无效"的事实,把我整个处理框架都击穿了——在"ps 里的都是进程、进程都能被 kill -9 杀死"这套逻辑里,眼前的景象是无法存在的。复盘到根上,我才终于看清自己错在哪:我错就错在,把 <defunct> 也当成了一个"进程"。可它根本不是。一个真正的进程,是一段正在运行的、活着的程序,它占着 CPU,占着内存,它能接收信号、能对信号作出反应——也正因为它"活着"、能反应,你才"杀得动"它。而 <defunct>,僵尸,Z 状态,它身上没有任何一样这些东西。它的本体——那段真正运行过的程序——早就已经退出了、结束了、死透了,它的内存早被回收,它没有一行代码在跑。它之所以还赖在 ps 的列表里没走,只是因为它留下了一份小小的"遗书":一个退出状态码,静静地等着它的父进程来读取、来确认。<defunct> 不是一个进程,它是一个进程死亡之后、户口还没注销之前,留在系统登记册上的那一行字。我之前那些 kill -9 之所以全部失效,原因朴素到近乎可笑:kill 发出的是信号,而信号,是说给"活人"听的——你没有办法去"杀死"一个已经死了的东西,这件事不需要任何技巧,它在逻辑上就不成立。我做的事,本质上是举着一把再锋利不过的刀,对着一具尸体反复地砍,然后困惑于"它怎么还不死"。真正的问题,从来不在这些尸体身上。一具尸体迟迟没有被注销,要追究的,是那个本该来为它"收尸"、却始终没有露面的人——它的父进程。是那个父进程,在 fork 出一个又一个子进程去干活之后,忘了(或者根本没写)一个收尾的动作:等子进程死去时,去读一读它的遗书,替它把户口注销掉。父进程的这一次次失职,才是那满屏尸体堆积如山的唯一原因。这次最大的收获,是我学会了在动手"处理"一个东西之前,先退一步,确认它到底"是什么"。我之前的失败,不是因为手段不够狠——kill -9 已经是最狠的了——而是因为我对处理对象的认知,从第一秒起就是错的。一个错误的认知之上,你用尽多大的力气,都是白费,甚至会让你在错误的方向上越陷越深。看清"它是一具尸体,而不是一个活人",这个认知本身,就已经包含了答案:不要再去砍尸体,去找那个该来收尸、却迟迟不来的人。先看清,再动手——这个顺序,一步都不能颠倒。
—— 别看了 · 2026