凌晨两点告警:某台业务机的进程数曲线一路爬到快八千,再往上 ssh 都开始抖。登上机器 ps -ef 一拉,满屏都是同一个名字、状态列标着 Z 的进程——僵尸。值班同学第一反应是 pkill -9,回车,再 ps,那批 Z 纹丝不动。
这件事有意思的地方在于,很多人对「kill 不掉」的直觉反应,恰好选了最没用的那一步。要破这个局,得先弄清楚信号这东西到底喂给了谁、又能不能真的让进程消失。
信号不是「强制关机」,而是「递个话」
进程收到信号,本质是内核往它的待处理信号集合里塞了个标记,真正「处理」要等进程重新被调度、回到用户态时。所以信号默认的语义分三类:终止、忽略、停下(以及极少数的「产生 core 转储」)。
常被混着用的几个:
SIGTERM(15):体面地请进程退出,进程可以捕获、可以清理、可以拒绝。服务关停的标准动作。SIGKILL(9):内核直接强制回收,进程没有机会跑任何清理代码。注意它不是「更狠的终止」,而是绕过了进程本身的信号处理。SIGINT(2):你在终端按 Ctrl-C 发出的,几乎等价于「用户想中断」,很多程序会把它当 SIGTERM 对待。SIGHUP(1):早年挂断电话线时通知,现在多被拿来做「重新加载配置」(nginx、sshd 都这么用)。SIGQUIT(3):Ctrl-\,默认终止并 core dump。SIGSTOP/SIGCONT(19/18):停与继续,这两个连 SIGKILL 都杀不掉它——它们不能被捕获或忽略,是内核级的。
一句话先放在这:kill -9 强,但它强不过两种状态——进程正处在内核态的不可中断等待里,或者它已经死了只是还没被父进程收尸。下面两节分别说。
kill -9 也干不掉的第一种:D 状态
ps 的 STAT 列里出现 D(有时是 D+),全称 uninterruptible sleep。进程卡在内核的某个不可中断等待上,典型是底层 I/O 长时间没返回——坏盘、NFS 服务端挂了、Ceph 客户端卡住、被 fc 光纤链路抖动。
这种状态下进程收不到任何信号,SIGKILL 也会被内核挂起,直到它从等待里出来。你 kill -9 它,命令返回成功,但 ps 里它还在——因为信号进了待处理集合,却永远没机会被处理。
判定的命令很直接:
# 看状态
ps -o pid,ppid,stat,wchan:24,cmd -p <pid>
# 内核在等什么,wchan 会告诉你
cat /proc/<pid>/wchan
# 更细的:卡在哪个内核函数
cat /proc/<pid>/stack 2>/dev/null
wchan 如果显示 nfs、xfs_buf_iowait、fuse_wait 这类,基本就是底层存储的问题,不是应用的问题。这时候你该去救存储、去恢复 NFS 连接、去把坏盘踢出阵列,而不是跟进程较劲。进程本身救不回来,等 I/O 回来它自然就醒了;存储真救不活,只能重启整机(这也就是为什么 NFS 客户端机子会「假死」到只能硬拔电源)。
一个真实教训:有次一台机器 D 状态堆了十几个进程,大家反复 kill -9,毫无反应,最后发现是后端一台老阵列的控制器电池挂了、写缓存被强制禁用、IO 吞吐掉到个位数。在那台阵列恢复之前,任何 kill 都是徒劳。记住——D 状态是症状,存储才是病根。
kill -9 也干不掉、而且本就该在的:僵尸进程
僵尸进程(STAT Z,有时显示 Z+ 或 Zs)已经死了。它的代码、内存、打开的文件全都释放了,只剩一个进程描述符留在内核表里,等父进程调用 wait() / waitpid() 把它「收尸」(reap)。在父进程 reap 之前,它必须以僵尸形态存在——这是 Unix 的设计,不是 bug。
所以对一个僵尸发 kill -9,内核只会耸耸肩:这进程已经死了,你要杀的是它残留的墓碑,而墓碑得由它爹来清。
定位僵尸要看两件事:它是谁的儿子,以及它爹为什么没收尸。
# 列出所有僵尸
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /Z/'
# 看单个僵尸的父进程
cat /proc/<zombie_pid>/status | grep -E '^(Name|Pid|PPid|State)'
常见根因:
- 父进程是普通应用,但有 bug,fork 出子进程后忘了
wait,或者SIGCHLD处理函数写了却没真正回收。这种父进程往往还活着,僵尸会一直累加。 - 父进程自己卡死了(比如也进了 D 状态),来不及 reap 儿子。先按上一节的法子把父进程从 D 状态里捞出来,它一恢复就会顺便收尸。
- 父进程是 PID 1(init / systemd),但某个服务异常退出留下一地僵尸——正常情况 PID 1 会兜底 reap 所有孤儿,但如果 PID 1 本身的某个逻辑卡住,也会堆积。
回收的办法,按「代价从小到大」:
- 如果父进程还活着、且只是暂时忙:等。多数情况下父进程下一轮事件循环就会
wait掉。 - 如果父进程就是那个有 bug 的服务:重启父进程。父进程退出后,僵尸会被 reparent 给 PID 1,由 PID 1 立即 reap。这是线上最常见的做法——
systemctl restart <父服务>即可连带清掉它生的僵尸。注意是重启父进程,不是对着僵尸本身发信号。 - 如果父进程不能重启(比如它就是核心业务),那就只能修代码或临时规避,僵尸本身无害(不占内存、不占 CPU),只是占着 PID 号。一台机器 PID 用尽(默认上限看
/proc/sys/kernel/pid_max,常见 4 万或更高)才会真的出事——这就是开头那次告警的来路。
孤儿进程则是另一头:父进程先死了,子进程被 reparent 给 PID 1,由 PID 1 接管并负责 reap。孤儿不可怕,反而「有人管」;僵尸才是「爹还在但不想管」。
日常排查信号,这几条命令够用
# 列出所有信号编号和名字
kill -l
# 只检查进程在不在,不真发信号(权限不够会报错的才是真不在)
kill -0 <pid>
# 按名字杀,支持正则;-f 匹配整条命令行
pkill -f 'worker.*\d+'
# 给整个进程组发信号(注意前面的减号)
kill -- -<pgid>
# 看进程当前状态、父进程、会话
ps -o pid,ppid,pgid,sid,stat,cmd -p <pid>
# 多线程程序,线程在内核里是独立 task
ls /proc/<pid>/task
kill -0 这条很实用:写监控脚本时想探活,别真发 SIGTERM,发 kill -0 就行,它只测「进程存在且你有权限发信号」,不产生任何副作用。
shell 脚本里想优雅退出,靠 trap 接信号:
cleanup() {
echo "收到退出信号,开始清理临时文件"
rm -f /tmp/myprog.$$
exit 0
}
trap cleanup SIGTERM SIGINT
不少人踩过这个坑:脚本里 trap 只接了 EXIT,结果被 systemd 发 SIGTERM 关停时,清理逻辑压根没触发,因为 SIGTERM 没接,默认动作是直接终止进程、EXIT 的 trap 也来不及跑。关停前要做清理的服务,SIGTERM SIGINT 都得接。
systemd 是怎么「发信号」的,容易被忽略
写 systemd 单元时,ExecStop 不写的话,默认就是给主进程发 SIGTERM;等 TimeoutStopSec(默认 90 秒)过了还在,才补一刀 SIGKILL。这就解释了为什么有些服务 systemctl stop 后会卡 90 秒才真正没——它在等 SIGTERM 之后的优雅退出超时。
[Service]
ExecStop=/opt/app/bin/shutdown.sh # 自己定义怎么退
TimeoutStopSec=30
# 想 stop 时直接 SIGKILL,不设 ExecStop 并把超时调到 1 秒也行,但数据可能丢
如果你的程序 SIGTERM 处理得不好(比如死循环不退出),systemctl stop 会干等一个超时再 SIGKILL——体验就是「stop 卡半天」。要么修程序的退出逻辑,要么在单元里显式 ExecStop 调一个靠谱的关停脚本。
还有多线程程序:kill 发给进程组或主线程时,信号默认投递给「任意一个没阻塞该信号的线程」。想精确打某个线程,得用 pthread_kill(C 层),shell 层面只能打到进程粒度。这也解释了为什么有时候你 kill 一个多线程服务,它没立刻退——信号落到了某个恰好在干活的线程上,而主线程的清理逻辑没被触发。
几个让我交过学费的场景
Java 应用别动不动 kill -9。有次为了「快」,直接 kill -9 了一个正在写数据文件的 Java 服务,结果下次启动时发现本地缓存索引损坏,得手动清缓存重灌。Java 的 SIGTERM 处理通常会做钩子清理,kill -15 等它优雅退出才是正道,-9 是逼不得已的最后一手。
Ctrl-C 关不掉程序,不一定是它卡死。有些程序显式捕获了 SIGINT 并选择忽略(比如某些长耗时的批处理工具,怕你误中断),这时候 Ctrl-C 确实没反应,得 Ctrl-\(SIGQUIT)或另开终端 kill -15。
systemd 某项 Restart=always,你 kill -9 主进程,它秒起一个新实例——你以为进程「杀不死」,其实是被守护进程又拉起来了。这种情况该 systemctl stop,而不是跟 PID 较劲。
还有 SIGKILL 对 SIGSTOP 无效这点,调试死锁时偶尔会坑到人:你 kill -STOP 了一个进程想冻结它看现场,回头 kill -9 想清掉,发现清不掉——得先 kill -CONT 让它回到可调度状态,-9 才生效。
收个尾
信号这套机制,核心就一句话:它是内核替你递的口信,进程接不接、什么时候接,不完全由你 control。碰到「kill 不掉」,先 ps 看 STAT:D 就去救存储,Z 就去找它爹重启父进程,普通卡住才考虑 kill -15 乃至 kill -9。对着僵尸狂发 -9 是最典型的无效操作——它已经死了,你只是在反复拍打一具空壳。真正要做的,是让还活着的父进程,或者 PID 1,把那具空壳收走。

