凌晨告警响起:一台生产服务器的 ps 里飘着几十个 Z 状态的进程,top 顶部赫然写着 zombie: 47。这就是僵尸进程(Zombie / defunct)——它已经退出,却仍占着进程表的一项,父进程迟迟不回收。本文从成因、定位到清理,手把手教你排查 Linux 僵尸进程。
一、什么是僵尸进程
在 Linux 中,一个进程退出后并不会立刻从内核的进程表里消失:它会进入 EXIT_ZOMBIE 状态,保留退出码、CPU 时间、资源占用等少量记账信息,等待父进程调用 wait() / waitpid() 来“收尸”。如果父进程始终不调用,这个进程就会一直以僵尸状态存在。僵尸进程不占用 CPU 和内存(地址空间已释放),但会占用进程号(PID);当 PID 耗尽时,系统将无法创建新进程,进而引发连锁故障。
二、僵尸进程是怎么产生的
2.1 父进程忘了回收子进程
最典型的原因:父进程 fork() 出子进程后,既没有调用 wait(),也没有注册 SIGCHLD 信号处理函数去异步回收。常见于写得不严谨的守护进程、脚本解释器、以及某些老旧的 C/S 服务。
2.2 父进程自己先崩溃了
如果父进程异常退出,子进程会被 init(PID 1)或 systemd 收养,理论上应由收养者回收。但当收养链上的某个中间进程同样不回收时,僵尸就会滞留。在容器环境里,若 1 号进程不是真正的 init(例如直接用 bash 或业务二进制做 ENTRYPOINT),僵尸往往无人接管——这正是排查 Docker 容器 异常时容易忽略的一环。
2.3 一段代码就能造出僵尸
理解成因最快的方式是亲手制造一个。下面这段 C 程序 fork() 出子进程后立刻退出,但父进程 sleep(30) 期间既不 wait() 也不处理 SIGCHLD,于是子进程会在这 30 秒里一直以僵尸态存在:
#include <unistd.h>
#include <stdio.h>
#include <sys/wait.h>
int main(void) {
pid_t pid = fork();
if (pid == 0) { // 子进程
printf("child exit now\\n");
_exit(0);
}
// 父进程不去 wait,子进程在此期间成为僵尸
printf("parent sleeping 30s, child is now zombie...\\n");
sleep(30);
wait(NULL); // 这一行才是“收尸”的关键
return 0;
}
在另一个终端用 ps -eo pid,ppid,stat,comm | awk '$3 ~ /Z/' 就能看到那个 Z 状态的子进程。它的资源早已释放,只是进程表里还留着一个条目——这直观说明了“僵尸占用的是 PID,不是内存”。
2.4 容器里为什么更容易堆积僵尸
很多镜像直接用业务二进制或 bash 当 ENTRYPOINT,导致容器内的 1 号进程不具备“收养并回收孤儿”的能力。一旦它派生的子进程退出,就会变成无人认领的僵尸。正确做法是用 tini 或 dumb-init 作为 1 号进程,它们会转发信号并自动 wait() 子进程:
# 用 tini 作为 1 号进程,自动回收孤儿/僵尸
FROM alpine:3.20
RUN apk add --no-cache tini
COPY start.sh /app/start.sh
ENTRYPOINT ["/sbin/tini", "--", "/app/start.sh"]
三、如何快速定位僵尸进程
一眼看出僵尸进程,看 STAT 列的 Z 即可:
# 列出所有僵尸进程
ps -eo pid,ppid,stat,comm | awk '$3 ~ /Z/'
# 更直观的 top 视图:按下 f -> 勾选 Z 查看 zombie 计数
top -b -n1 | head -5
找到僵尸进程的 PPID(父进程号)是关键:僵尸本身杀不掉,真正要处理的是它的父进程。查看僵尸进程的父进程与命令行:
ps -o pid,ppid,stat,cmd -p <僵尸PID>
# 查看父进程是否还活着、在做什么
cat /proc/<PPID>/status | head -5
cat /proc/<PPID>/cmdline | tr '\\0' ' '
四、为什么 kill -9 杀不掉僵尸进程
很多人的第一反应是 kill -9 <僵尸PID>,结果毫无反应。这是因为僵尸进程在内核里已经处于 TASK_DEAD 状态,它的 task_struct 虽然还挂在进程表里,但早已不调度、不响应任何信号——kill 信号根本没有接收者,自然毫无效果。从内核视角看,僵尸只是“等待父进程读取退出状态的尸体”,而非一个活着的进程。
正确做法是kill 它的父进程:父进程退出后,僵尸会被 PID 1 收养并立即回收。如果父进程是关键服务(数据库、消息队列、网关)不能贸然杀,那就需要修复父进程的逻辑或平滑重启——例如给它加上 SIGCHLD 的异步回收处理:
#include <signal.h>
#include <sys/wait.h>
void reap(int sig) {
// 循环回收,避免信号合并导致漏收
while (waitpid(-1, NULL, WNOHANG) > 0) {}
}
// 注册后,子进程退出时内核会自动回收,不再产生僵尸
signal(SIGCHLD, reap);
对应到脚本语言,Python 可以设置 signal.signal(signal.SIGCHLD, signal.SIG_IGN) 让内核在子进程退出时直接回收;Shell 里用 wait 或 set -m 配合 wait -n 也能避免僵尸堆积。核心思想只有一个:谁生的孩子谁负责收尸。
五、常见根因与排查清单
僵尸与孤儿进程常被混淆,二者本质不同:
| 维度 | 僵尸进程 (Zombie) | 孤儿进程 (Orphan) |
|---|---|---|
| 状态 | 已退出,等父回收 | 仍在运行,父已退出 |
| 占用资源 | 仅占 PID 表项 | 正常占用 CPU/内存 |
| 处理方式 | 回收父进程 | 被 init 接管,正常结束 |
快速排查命令速查:
| 目的 | 命令 |
|---|---|
| 统计僵尸数量 | ps -e -o stat | grep -c Z |
| 看父进程链 | pstree -p <PPID> |
| 看进程关系 | ps -ef --forest |
| 等不到回收 | 检查父进程是否忽略 SIGCHLD |
六、实战:定位并清理一个僵尸进程
假设发现 PID 2001 是僵尸,父进程是 1980:
# 1) 确认父子关系
ps -o pid,ppid,stat,cmd -p 2001
# 2) 看父进程 1980 是什么
cat /proc/1980/cmdline | tr '\\0' ' '; echo
# 3) 若父进程是安全可重启的服务
kill -TERM 1980
# 4) 父退出后僵尸被 init 回收,再次确认
sleep 1; ps -e -o stat | grep -c Z
如果父进程是核心服务(如数据库、消息队列),不要直接 kill,而是定位它为何不回收:是否吞掉了 SIGCHLD、是否死锁在 wait() 调用里。这与我们排查 数据库死锁、集群脑裂 时的思路一致——先定位根因,再决定动作。
七、自动化巡检脚本
把下面这段放进 GitHub Actions 定时任务或 cron,超过阈值就告警:
#!/usr/bin/env bash
# check_zombie.sh —— 僵尸进程数量巡检
THRESHOLD=5
COUNT=$(ps -e -o stat | grep -c Z)
if [ "$COUNT" -gt "$THRESHOLD" ]; then
echo "ALERT: zombie process count=$COUNT exceeds $THRESHOLD"
# 输出占用僵尸最多的父进程,便于根因定位
ps -eo ppid,stat,comm | awk '$2 ~ /Z/ {print $1}' | sort | uniq -c | sort -rn | head
exit 1
fi
echo "OK: zombie count=$COUNT"
八、避坑与最佳实践
1)不要指望 kill -9 僵尸本身,永远操作父进程。
2)容器 1 号进程要选对:用 tini 或 dumb-init 作为 ENTRYPOINT,它们会正确转发并回收信号,避免容器内僵尸堆积。
3)代码层规范:fork 后务必 wait,或用 signal(SIGCHLD, SIG_IGN) 让内核自动回收。
4)监控前置:把僵尸计数接入 MySQL 慢查询排查 这类巡检体系一致的监控面板,做到可观测。
5)偶尔的瞬时僵尸是正常的(子进程刚退出、父进程还没来得及收),只有长期不消退且数量增长才需要介入。
九、相关阅读
僵尸进程属于 Linux 进程与排障体系的一环,延伸阅读:Linux 性能调优实战:CPU、内存与 I/O 瓶颈定位、K8s 节点 NotReady 排障。掌握这套定位方法,你也能在告警响起时胸有成竹。




