Linux运维13 min read次阅读

kill 不掉、defunct 堆满:进程信号与僵尸回收这件常被误判的事

凌晨两点告警:某台业务机的进程数曲线一路爬到快八千,再往上 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 状态

psSTAT 列里出现 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 如果显示 nfsxfs_buf_iowaitfuse_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)'

常见根因:

  1. 父进程是普通应用,但有 bug,fork 出子进程后忘了 wait,或者 SIGCHLD 处理函数写了却没真正回收。这种父进程往往还活着,僵尸会一直累加。
  2. 父进程自己卡死了(比如也进了 D 状态),来不及 reap 儿子。先按上一节的法子把父进程从 D 状态里捞出来,它一恢复就会顺便收尸。
  3. 父进程是 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,结果被 systemdSIGTERM 关停时,清理逻辑压根没触发,因为 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 较劲。

还有 SIGKILLSIGSTOP 无效这点,调试死锁时偶尔会坑到人:你 kill -STOP 了一个进程想冻结它看现场,回头 kill -9 想清掉,发现清不掉——得先 kill -CONT 让它回到可调度状态,-9 才生效。

收个尾

信号这套机制,核心就一句话:它是内核替你递的口信,进程接不接、什么时候接,不完全由你 control。碰到「kill 不掉」,先 psSTATD 就去救存储,Z 就去找它爹重启父进程,普通卡住才考虑 kill -15 乃至 kill -9。对着僵尸狂发 -9 是最典型的无效操作——它已经死了,你只是在反复拍打一具空壳。真正要做的,是让还活着的父进程,或者 PID 1,把那具空壳收走。

分享:

相关文章

一台机器被一个进程拖垮:用 cgroup v2 和 systemd 把它关进笼子
Linux运维14 min read

一台机器被一个进程拖垮:用 cgroup v2 和 systemd 把它关进笼子

凌晨两点,一台共用机器 load 飙到 80,SSH 都卡。查下来是个批处理脚本 fork 爆炸,把整台宿主的 CPU 和内存吃光。这种「一个进程搞死全机」的事,ulimit 管不住、nice 降优先级也只是缓解。真正的答案是 cgroup v2——配合 systemd 的 resource control,给每个服务画好 CPU、内存、IO 的硬边界,谁越界谁先死,绝不连累邻居。

Too many open files:ulimit、LimitNOFILE 与 fd 泄漏的完整排查链路
Linux运维12 min read

Too many open files:ulimit、LimitNOFILE 与 fd 泄漏的完整排查链路

服务半夜开始报错 accept4() failed (24: Too many open files),或者 Nginx 频繁 502。这种故障十次有八次是文件句柄(fd)耗尽,但光改 ulimit -n 往往没用——因为 systemd 起的服务根本不吃 limits.conf 那套。从内核 file-max 到进程 LimitNOFILE,到定位到底是哪个进程在漏 fd,把每一层都讲透,并给出不停机临时救急和永久修复两条路。

评论区