2024 年一台服务器半夜告警:新进程起不来了。我 ssh 上去想看看情况,敲什么命令都报同一句话——fork: retry: Resource temporarily unavailable。连 ps 都跑不起来,因为跑 ps 本身也需要 fork 一个新进程,而系统已经 fork 不出来了。我换了好几次才挤进去一个 ps,一看吓一跳:屏幕上密密麻麻几百行,每一行后面都跟着一个刺眼的 <defunct>。那是几百个僵尸进程,把整个系统的进程表给占满了。诡异的是,这些僵尸进程,我用 kill -9 一个都杀不掉。这件事逼着我把 Linux 的进程状态、信号机制、僵尸进程这一整套彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,一个会频繁 fork 子进程的自研服务
事故现象:
- 半夜告警:新进程起不来
- 敲任何命令都报 fork: retry: Resource temporarily unavailable
- 好不容易挤进一个 ps,看到几百个
- kill -9 杀不掉这些僵尸进程
现场排查:
# 1. 看进程状态统计
$ ps -eo stat | sort | uniq -c | sort -rn
412 Z # ★ 412 个 Z 状态 —— 僵尸进程!
180 S
23 R
# 2. 看这些僵尸进程长什么样
$ ps aux | grep defunct | head
root 8801 ... Z ... [worker]
root 8802 ... Z ... [worker]
# —— 都是 worker,Z 状态,带 标记
# 3. 这些僵尸的【父进程】是谁(关键!)
$ ps -eo pid,ppid,stat,comm | grep ' Z '
8801 6500 Z worker
8802 6500 Z worker
# ★ PPID 全是 6500 —— 它们的父进程是同一个
# 4. 6500 是什么
$ ps -p 6500 -o pid,stat,comm
6500 S myapp-master
# —— 是我们服务的主进程 myapp-master
# 5. 试着杀僵尸 —— 杀不动
$ kill -9 8801
$ ps -p 8801 # 它还在,还是 Z
根因(后来想清楚的):
1. 僵尸进程(Z)是【已经结束运行】的子进程 ——
它的代码早跑完了,但进程表里还留着一个"尸体"。
2. 为什么留着?子进程一退出,内核会保留它的退出状态,
【等父进程来读取(wait)】。父进程读了,尸体才被清除。
3. 我们的 myapp-master 不停 fork worker 子进程,
worker 干完活退出了,master 却【从来不调用 wait】
去回收它们 —— 于是 worker 一个个变成僵尸,越积越多。
4. ★ 僵尸是"尸体",kill 是发信号给"活进程"的 ——
对一个已经死了的进程发信号,毫无意义,所以 kill -9 无效。
5. 几百个僵尸各占一个 PID 槽位,进程表被占满,
整机 fork 不出新进程。
清僵尸,不能杀僵尸本身,得去处理它的【父进程】。
修复 1:进程状态——R / S / D / Z / T
# === ps 的 STAT 列,一个字母告诉你进程在干什么 ===
$ ps -eo pid,ppid,stat,comm
# STAT 第一个字母是核心状态:
# R Running/Runnable —— 正在 CPU 上跑,或在排队等 CPU
# S Sleeping —— 可中断睡眠,在等某个事件(最常见的正常状态)
# D Uninterruptible Sleep —— ★ 不可中断睡眠,通常在等磁盘 IO
# 这个状态的进程【连 kill -9 都杀不掉】(见修复 5)
# Z Zombie —— ★ 僵尸,进程已结束,等父进程回收
# T Stopped —— 被暂停(收到 SIGSTOP,或在调试器里被停住)
# === STAT 后面还可能跟附加字母 ===
# s 是会话首进程(session leader)
# + 在前台进程组
# l 是多线程的
# < 高优先级 N 低优先级(nice 过)
# 例:Ss+ = 睡眠 + 会话首 + 前台
# === 快速统计全机进程都在什么状态 ===
$ ps -eo stat | sed 's/[^A-Z].*//' | sort | uniq -c | sort -rn
# 一眼看出:是不是 Z 特别多?是不是 D 卡了一片?
# === ★ 重点理解僵尸(Z)===
# 僵尸【不占 CPU、不占内存】(代码段早就释放了)。
# 它唯一占用的,是【进程表里的一个 PID 槽位】。
# 少量僵尸无害;但僵尸只增不减时,PID 槽位会被耗尽,
# 整机就 fork 不出新进程了 —— 这就是这次的事故。
# === 看进程的父子关系(排查僵尸必备)===
$ pstree -p # 树状显示进程的父子层级
$ ps -eo pid,ppid,stat,comm # 直接看 PID 和 PPID
修复 2:信号机制——进程间的"通知"
# === 信号:内核/进程发给另一个进程的一个"通知" ===
# kill 命令的本质,不是"杀",是"发一个信号过去"。
# 进程收到信号后,按这个信号【默认的动作】或它
# 【自己注册的处理函数】来响应。
# === 看系统支持的所有信号 ===
$ kill -l
# === 排查中最常打交道的几个信号 ===
# SIGHUP (1) 挂断。常被服务用来表示"重新加载配置"
# SIGINT (2) 中断。你按 Ctrl+C 发的就是它
# SIGKILL (9) ★ 强杀。【不可被捕获、不可被忽略】,内核直接干掉
# SIGTERM (15) ★ 终止。kill 默认发的就是它,是"礼貌地请你退出"
# SIGSTOP (19) 暂停进程(不可捕获)
# SIGCONT (18) 让被暂停的进程继续
# SIGCHLD (17) ★ 子进程状态变化时,内核发给【父进程】的信号
# —— 这个信号,是理解僵尸进程的钥匙(见修复 4)
# === ★ SIGTERM 和 SIGKILL 的本质区别 ===
# SIGTERM(15):给进程一个"善后"的机会 ——
# 进程可以捕获它,在退出前关连接、刷盘、删临时文件。
# 这是【优雅退出】。kill 默认就发这个,应当【优先】用它。
# SIGKILL(9):内核直接把进程从执行队列里抹掉,
# 进程【没有任何机会善后】—— 写一半的文件、没关的连接
# 就那样留下了。这是【强制退出】,是【最后手段】。
# === 给进程发信号 ===
$ kill PID # 默认发 SIGTERM(15),先礼
$ kill -15 PID # 同上,显式写
$ kill -9 PID # 发 SIGKILL,后兵 —— 别一上来就 -9
$ kill -HUP PID # 发 SIGHUP,很多服务用它热加载配置
$ kill -0 PID # ★ 发"空信号":只探测进程在不在,不真发
echo $? # 0 = 进程存在;非 0 = 不存在
修复 3:kill / killall / pkill 的正确用法
# === 停一个进程的【正确顺序】:先 TERM,再 KILL ===
# 第 1 步:发 SIGTERM,给它优雅退出的机会
$ kill 12345
# 第 2 步:等几秒,看它退了没
$ sleep 3; kill -0 12345 && echo "还在" || echo "已退出"
# 第 3 步:确实赖着不走,才动用 SIGKILL
$ kill -9 12345
# ★ 直接 kill -9 = 不给进程任何善后机会,可能丢数据、留垃圾。
# 把 -9 当"最后手段",不是"默认手段"。
# === 按【名字】杀:killall 和 pkill ===
$ killall nginx # 杀所有叫 nginx 的进程
$ killall -9 nginx # 强杀
$ pkill nginx # pkill 按名字(支持部分匹配/正则)
$ pkill -f '/opt/myapp' # ★ -f:匹配【完整命令行】,不只进程名
# pkill -f 很有用:同名进程,靠命令行参数区分该杀哪个。
# === 按名字杀之前,先用 pgrep "预演"一遍 ===
$ pgrep -a nginx # 列出会被匹配到的进程(-a 显示命令行)
# ★ 先 pgrep 看清楚要杀谁,再 pkill —— 别盲杀。
# pkill java 可能把你不想动的别的 Java 进程也带走了。
# === 杀一整个进程组 / 一棵进程树 ===
$ kill -- -6500 # PID 前加负号 = 杀【进程组】6500
# 想连父带子一锅端时有用(进程组 ID 通常是父进程 PID)。
# === 暂停 / 恢复一个进程(不杀掉)===
$ kill -STOP 12345 # 暂停(进程进入 T 状态)
$ kill -CONT 12345 # 恢复
# 排查时想"冻住"一个进程看现场,用这个。
# === 找出"是谁占着这个端口/文件",再决定杀不杀 ===
$ lsof -i :8080 # 谁占了 8080 端口
$ fuser -k 8080/tcp # 直接杀掉占用 8080 的进程(-k)
$ lsof /path/to/file # 谁打开着这个文件
修复 4:僵尸进程的本质与清除——这次的根因
# === ★ 僵尸进程是怎么产生的 ===
# 1. 父进程 fork 出一个子进程。
# 2. 子进程把活干完,退出(exit)。
# 3. 子进程退出时,内核【不会立刻】把它彻底清除 ——
# 内核会保留一小块信息:它的退出码、占用的资源统计。
# 为什么留?为了让父进程能"知道这个孩子是怎么结束的"。
# 4. 内核给父进程发一个 SIGCHLD 信号:"你有个孩子退出了"。
# 5. 父进程应当调用 wait() / waitpid() 来【读取】这块信息 ——
# 这个动作叫"回收"。父进程一回收,内核就清掉那块尸体。
# 6. ★ 如果父进程【从不调用 wait】,那块尸体就一直留着 ——
# 这个子进程就成了僵尸(Z)。
# === 所以僵尸的锅,永远在【父进程】身上 ===
# 僵尸进程本身已经死了,它不执行任何代码。
# "父进程没有尽到回收子进程的责任",才是僵尸的成因。
# === ★ 为什么 kill -9 杀不掉僵尸 ===
# kill 是给【活着的进程】发信号,让它执行某个动作。
# 僵尸进程根本没在执行 —— 它是个尸体,信号发给它,
# 没有任何代码会去响应。所以对僵尸 kill,无论几号信号,
# 都【没有意义】。僵尸不是"杀"得掉的。
# === 正确的清僵尸办法:处理它的【父进程】===
# 第 1 步:找出僵尸的父进程 PID(PPID)
$ ps -eo pid,ppid,stat,comm | awk '$3 ~ /^Z/ {print $2}' | sort -u
6500
# 第 2 步:给父进程发 SIGCHLD,提醒它"去回收孩子吧"
$ kill -CHLD 6500
# —— 如果父进程的代码写得还行,收到 SIGCHLD 会去 wait,
# 僵尸就被回收了。
# 第 3 步:父进程代码有 bug、压根不 wait,SIGCHLD 也没用,
# 那就只能【重启或杀掉父进程】:
$ kill 6500 # 先 TERM
# ★ 关键机制:父进程一死,它名下所有僵尸子进程,会被
# 【1 号进程(init/systemd)收养】。而 init/systemd
# 会【尽职地、自动地 wait】 —— 僵尸瞬间全部被清除。
# === 验证僵尸清掉了 ===
$ ps -eo stat | grep -c '^Z' # 应该降到 0 或个位数
修复 5:杀不死的进程——D 状态与孤儿进程
# === 除了僵尸,还有一种"杀不掉":D 状态进程 ===
$ ps -eo pid,stat,comm | awk '$2 ~ /^D/'
9912 D myapp
# D = 不可中断睡眠(Uninterruptible Sleep)。
# 进程通常是卡在一个【内核态的 IO 操作】里 ——
# 比如在等一块有问题的磁盘、一个挂死的 NFS 返回数据。
# === ★ 为什么 D 状态 kill -9 也没用 ===
# 信号要起作用,得等进程"回到用户态"才会被处理。
# D 状态的进程陷在内核里出不来,信号【递不进去】,
# 只能【排队等着】。所以 kill -9 一个 D 进程,
# 它不会立刻死 —— 要等它依赖的那个 IO 结束(或超时)。
# 真正该排查的是:那个 IO 为什么卡住了。
$ cat /proc/9912/stack # 看它卡在内核的哪个调用上
$ cat /proc/9912/wchan # 看它在等什么
$ dmesg | tail # 看是不是磁盘/NFS 报错了
# ★ 一片进程同时进 D 状态,八成是某块盘或某个 NFS 挂了。
# === 另一个常被混淆的概念:孤儿进程 ≠ 僵尸进程 ===
# 孤儿进程:父进程先死了,子进程还活着。
# 孤儿【完全无害】—— 它会立刻被 1 号进程收养,
# 将来它退出时,1 号进程会负责 wait 回收它,不会变僵尸。
# ★ 僵尸有害(占槽位),孤儿无害(有人管)。别搞混。
# === nohup / 守护进程 就是"主动当孤儿" ===
$ nohup ./long_task.sh &
# nohup + & 让任务脱离当前终端,终端关了它也不受影响 ——
# 它被 init 收养,成为一个"良性孤儿"。
# === 排查"这个进程怎么就是停不下来" ===
# 1. 是 Z(僵尸)? -> 杀不掉是正常的,去处理父进程(修复 4)
# 2. 是 D(不可中断)? -> 杀不掉也正常,去查它等的那个 IO
# 3. 是 S/R 却 kill 不掉? -> 它可能捕获并忽略了 SIGTERM,
# 用 kill -9 强杀
# 4. 杀掉又马上重现? -> 它被某个【父进程/supervisor 守护着】,
# 比如 systemd 配了 Restart=always —— 得先停那个守护者
$ systemctl stop myapp # 停服务,而不是去杀它的进程
修复 6:进程管理纪律
# === 这次事故暴露的进程管理问题,定几条纪律 ===
# === 1. ★ 停进程先 TERM 后 KILL,别一上来 -9 ===
$ kill PID; sleep 3; kill -0 PID && kill -9 PID
# SIGTERM 给进程善后机会,SIGKILL 是最后手段。
# === 2. 按名字杀之前,先 pgrep 看清楚要杀谁 ===
$ pgrep -a 关键字 # 先看
$ pkill -f 关键字 # 再杀
# 盲目 pkill 可能误伤同名的无辜进程。
# === 3. 服务要用 systemd 管,别手动 kill 它的进程 ===
# 手动 kill 一个由 systemd 守护的服务,它会被立刻拉起。
# 要停就 systemctl stop,要重启就 systemctl restart。
# === 4. 监控僵尸进程数量,把它做成告警项 ===
$ ps -eo stat | grep -c '^Z'
# 把这个数字接进监控,超过阈值(如 50)就告警 ——
# 别等它涨到几百、把进程表占满才发现。
# === 5. 监控 D 状态进程 ===
$ ps -eo stat | grep -c '^D'
# 持续有一批 D 状态进程,基本就是磁盘/存储出问题的信号。
# === 6. 给系统设进程数上限,防"fork 炸弹"式的失控 ===
$ ulimit -u # 看单用户进程数上限
$ cat /etc/security/limits.conf # 永久配置在这
# 单个用户的进程数有上限,某个服务失控狂 fork 时,
# 至少炸的是它自己,不会把整机的进程表占满。
# === 7. 排查"起不了新进程",按这个顺序 ===
$ ps -eo stat | sort | uniq -c | sort -rn # 先看状态分布
# Z 特别多 -> 找父进程回收/重启(修复 4)
# 进程总数撞上限 -> 查是谁在狂 fork,看 ulimit -u
$ dmesg | tail # 看内核有没有报 OOM 等
# === 8. fork 子进程的程序,必须负责回收 ===
# 这是写服务时的责任:fork 了孩子,就要 wait 它,
# 或者注册 SIGCHLD 处理函数去回收 ——
# 这次几百个僵尸,根子就在 master 没尽到这个责任。
命令速查
需求 命令
=============================================================
看进程状态分布 ps -eo stat | sort | uniq -c | sort -rn
找僵尸进程的父进程 ps -eo pid,ppid,stat,comm | grep ' Z '
优雅停止进程 kill PID(发 SIGTERM)
强制杀进程(最后手段) kill -9 PID
探测进程是否存在 kill -0 PID; echo $?
按名字预演要杀谁 pgrep -a 关键字
按完整命令行杀进程 pkill -f 关键字
提醒父进程回收僵尸 kill -CHLD 父PID
看进程树父子关系 pstree -p
看 D 状态进程卡在哪 cat /proc/PID/stack
口诀:杀不掉先看 STAT —— Z 去处理父进程,D 去查 IO
停进程先 TERM 后 KILL,父进程一死僵尸被 init 收走
避坑清单
- 僵尸进程(Z)是已结束的子进程,kill -9 对它无效,因为它已经死了
- 僵尸的成因永远在父进程:父进程没调用 wait 回收子进程
- 清僵尸要处理父进程,kill -CHLD 提醒它回收,或直接重启父进程
- 父进程一死,它名下的僵尸会被 1 号进程收养并自动清除
- 停进程先发 SIGTERM 给善后机会,SIGKILL 是最后手段不是默认手段
- D 状态进程卡在内核 IO,信号递不进去,kill -9 也要等 IO 结束
- 一批进程同时进 D 状态,通常是磁盘或 NFS 存储出了问题
- 孤儿进程无害会被 init 收养,和有害的僵尸进程是两回事
- pkill 前先 pgrep 看清要杀谁,-f 匹配完整命令行避免误伤同名进程
- 被 systemd 守护的服务要用 systemctl stop,手动 kill 会被立刻拉起
总结
这次"几百个僵尸塞满进程表"的事故,纠正了我对"杀进程"这件事一个特别朴素、却特别错误的直觉:我一直以为,只要一个进程碍事,kill -9 这个"必杀技"总能解决它——这个世界上不存在 kill -9 杀不掉的进程。这次,几百个我怎么 kill -9 都纹丝不动的僵尸进程,把这个直觉彻底打碎了。复盘到根上,我才真正想明白了僵尸进程到底是个什么东西。一个子进程被 fork 出来,把自己的活干完、调用 exit 退出之后,内核并不会在那一瞬间就把它从系统里彻底抹干净。内核会刻意地、有目的地保留它的一小块"残留信息"——它的退出码、它运行期间占用资源的统计数据。内核为什么要留着这块信息?是为了交代给它的父进程:父进程有权利、也有责任去"知道"自己 fork 出来的这个孩子,最后到底是正常结束的还是出错了。父进程读取这块信息的动作,有一个专门的名字,叫 wait,这个动作也被形象地称为"回收"。这里是整个机制的关键:子进程退出后,到父进程调用 wait 回收它之前的这一段时间里,这个子进程就处于"僵尸"状态——它的代码早就跑完了,它不占 CPU、不占内存,它在系统里唯一还占着的,是进程表里的那一个 PID 槽位,它就是一具等待父进程来"认领"的尸体。在一个写得正确的程序里,这段僵尸状态短暂得几乎无法察觉:子进程前脚刚退出,内核后脚就给父进程发去一个名叫 SIGCHLD 的信号,通知它"你有个孩子结束了",一个负责任的父进程收到这个信号,马上就会去调用 wait,尸体瞬间就被清掉了。可一旦父进程的代码有缺陷——它不停地 fork 出子进程,却从来不去 wait 回收它们——那么每一个退出的子进程,就都会变成一具无人认领的僵尸,永远地、静静地占着那个 PID 槽位不放。我那台服务器上的 myapp-master 进程,正是这样一个失职的父亲:它源源不断地 fork 出 worker 子进程去干活,worker 干完活规规矩矩地退出了,可 master 从头到尾就没写过一行回收子进程的代码。于是僵尸越积越多,几百个僵尸各自死死占住一个 PID 槽位,最终把整个系统的进程表占得满满当当——这才有了那句让我寸步难行的 fork: retry: Resource temporarily unavailable。想通了这一层,我那个"kill -9 为什么没用"的困惑也就涣然冰释了:kill 这个命令,它的本质根本不是"杀死",而是"向一个进程发送一个信号",信号需要由那个进程里正在运行的代码去接收、去响应。可僵尸进程是一具尸体啊——它里面没有任何代码在运行,你向它发送任何信号,9 号也好、15 号也罢,都不会有任何代码去搭理这个信号。对一个僵尸进程 kill,就像对着一具尸体喊话,本就是一件没有意义的事。所以清除僵尸的正确思路,根本不是去对僵尸本身动手,而是要去处理那个失职的父进程。这里还有一个设计得极其巧妙的机制兜了底:如果我把那个不回收孩子的父进程 myapp-master 杀掉,那么它名下所有的僵尸子进程,会在一瞬间被系统里的 1 号进程——也就是 init 或 systemd——自动"收养";而 1 号进程是一个绝对尽职的父亲,它会立刻、自动地去 wait 回收这些过继来的孩子。于是,我只要重启那个有缺陷的父进程,几百个僵尸就被 1 号进程一扫而空。这次从一个 fork 不出新进程的死局出发,我最大的收获,是终于摆正了对进程状态的认识:在用 kill 之前,我得先用 ps 看清楚那个进程到底处在什么状态——它是个还能响应信号的活进程,还是一具根本不该对它发信号的僵尸(Z),又或是一个陷在内核 IO 里、连信号都暂时递不进去的 D 状态进程。看不清状态就抡起 kill -9,不是排查,只是迷信。
—— 别看了 · 2026