僵尸进程:死了,但还没走
某天执行 ps aux,列表里多出几行奇怪的记录:STAT 一栏是 Z,COMMAND 后面跟着 <defunct>。这些进程不占 CPU,也不怎么占内存,但 kill -9 对它们毫无作用,数量还会慢慢变多。这就是僵尸进程。
它并不神秘,本质上只是一个“已经退出、但还没被回收”的进程留下的空壳。理解它,需要先理解 Linux 里一个进程是怎么死的。
先看看它长什么样
1$ ps -eo pid,ppid,stat,comm | awk '$3 ~ /^Z/'
2 48212 48211 Z zombie几个常见的 STAT 值:
| 状态 | 含义 |
|---|---|
R | 正在运行或处于可运行队列 |
S | 可中断睡眠,正在等事件 |
D | 不可中断睡眠,通常在等 IO |
T | 被暂停(收到 SIGSTOP 等) |
Z | 已退出,等待父进程回收 |
Z 只说明一件事:进程的代码已经不执行了,但内核还保留着它的一小部分信息,等父进程来取。
进程死亡是两步,不是一步
很多人以为进程调用 exit() 之后就彻底消失了。实际流程是:
- 进程退出,内核释放它的内存映射、打开的文件、信号处理状态等资源;
- 内核保留一个很小的结构,里面记着退出码、资源使用统计、终止信号等信息;
- 父进程调用
wait()或waitpid()把这些信息取走; - 内核才真正释放 PID 和剩余结构。
第 3 步没发生,进程就卡在第 2 步,变成僵尸。
写个小程序复现一下:
1/* zombie.c */
2#include <stdio.h>
3#include <unistd.h>
4#include <sys/types.h>
5
6int main(void) {
7 pid_t pid = fork();
8 if (pid == 0) {
9 printf("child pid=%d\n", getpid());
10 _exit(0); /* 子进程立刻退出 */
11 }
12
13 printf("parent pid=%d, child=%d\n", getpid(), pid);
14 sleep(60); /* 父进程既不 wait,也不退出 */
15 return 0;
16}编译运行:
1$ gcc -Wall -o zombie zombie.c
2$ ./zombie &
3parent pid=48211, child=48212
4$ ps -o pid,ppid,stat,comm -p 48212
5 PID PPID STAT COMMAND
6 48212 48211 Z zombie子进程只活了一瞬间,却在进程表里赖了 60 秒。等父进程退出,这个僵尸会立刻消失,因为此时它会被交给 init 或 systemd 收养并回收。
那副空壳为什么必须存在
既然进程已经死了,为什么不直接销毁?
因为父进程需要知道子进程“怎么死的”。退出码是 0 还是 1?是被信号杀死的吗?占用了多少 CPU 时间?这些信息只能在子进程死后交给父进程。如果内核在子进程退出时就把一切抹掉,父进程的 waitpid() 就永远拿不到结果,$? 也就失去了意义。
所以僵尸不是 bug,而是设计的一部分。问题出在父进程不肯收尸。
谁在制造僵尸
常见来源有几类:
- 父进程
fork()之后从来不调用wait()或waitpid(); - 父进程在循环里不断创建子进程,却只回收了其中一部分;
- 信号处理写漏了,
SIGCHLD来了但没做实际回收; - 容器里 PID 1 是应用本身,而应用没有回收孤儿的逻辑。
最后一种在容器环境里尤其常见,后面单独说。
为什么 kill -9 杀不掉
因为僵尸已经死了。kill 的作用对象是“活着的进程”,而僵尸没有可执行上下文,没有栈,也没有代码段,信号送到它身上没有任何意义。你执行 kill -9 <僵尸PID>,系统可能返回成功,但什么也不会发生。
真正要处理的是它的父进程。两条路:
- 修好父进程,让它调用
wait()回收; - 杀掉父进程,让僵尸被
init收养并回收。代价是父进程也得重启。
如果父进程是某个长期运行的服务,通常只能选择重启服务,或者在代码里补上回收逻辑。
SIGCHLD:默认忽略不等于自动回收
这是一个高频误解。SIGCHLD 的默认动作确实是“忽略”,但这种默认忽略和显式把处理函数设成 SIG_IGN 不是一回事:
1signal(SIGCHLD, SIG_IGN);显式忽略 SIGCHLD 时,内核会在子进程退出时直接回收,不产生僵尸。而如果你什么都不做,子进程照样会变成 Z。
用 sigaction() 表达同样意图更清楚:
1struct sigaction sa = {0};
2sa.sa_handler = SIG_IGN;
3sa.sa_flags = SA_NOCLDWAIT; /* 子进程退出后不进入僵尸状态 */
4sigaction(SIGCHLD, &sa, NULL);注意 SA_NOCLDWAIT 的副作用:之后 wait() 可能返回 ECHILD,你也拿不到子进程的退出状态。如果业务需要退出码,就不要用这一招。
正确的收尸方式
需要拿到退出状态时,标准做法是在 SIGCHLD 处理函数里循环调用 waitpid():
1#include <errno.h>
2#include <signal.h>
3#include <sys/wait.h>
4#include <unistd.h>
5
6static void on_sigchld(int signo) {
7 (void)signo;
8 int saved_errno = errno; /* 处理函数里要保住 errno */
9 int status;
10 pid_t pid;
11
12 while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
13 if (WIFEXITED(status)) {
14 /* 正常退出,退出码 WEXITSTATUS(status) */
15 } else if (WIFSIGNALED(status)) {
16 /* 被信号 WTERMSIG(status) 杀死 */
17 }
18 }
19
20 errno = saved_errno;
21}几个要点:
- 必须循环。一次
SIGCHLD可能对应多个子进程退出,信号不会排队。 - 必须带
WNOHANG,否则处理函数可能阻塞在已经没有子进程可等的状态。 - 处理函数里只能调用异步信号安全的函数,
printf()、malloc()都不合格。把结果写进管道或标记变量,交给主循环处理。 waitpid()返回-1且errno == ECHILD表示没有子进程了,可以退出循环。
如果不用信号,也可以在一个专门的线程里阻塞等待:
1for (;;) {
2 int status;
3 pid_t pid = waitpid(-1, &status, 0);
4 if (pid < 0) {
5 if (errno == EINTR) continue; /* 被信号打断,重试 */
6 break;
7 }
8 /* 处理 pid 的退出状态 */
9}容器里更容易撞上
在普通系统上,父进程退出后孤儿会被 PID 1 收养,而 systemd、init 都会老老实实回收。容器里不一定:PID 1 往往就是你的应用进程,它没有“给所有人收尸”的职责。应用 fork 出的子进程、shell 脚本启动的后台任务,退出后都可能堆成僵尸。
解决办法是加一个迷你 init:
1docker run --init your-image--init 会在容器里塞入 tini 之类的进程作为 PID 1,专门负责转发信号和回收孤儿。Kubernetes 里没有这个开关,通常的做法是让镜像的 ENTRYPOINT 使用 tini 或 dumb-init。
僵尸堆积的后果不只是“看着难受”。每个僵尸占一个 PID,而 PID 是有上限的:
1$ cat /proc/sys/kernel/pid_max
24194304容器里还多一层 cgroup 限制:
1$ cat /sys/fs/cgroup/pids.max
2512撞到上限之后,fork() 会失败并返回 EAGAIN,表现为 Resource temporarily unavailable,服务开始拒绝新连接。
一张排查清单
1# 1. 找出所有僵尸
2ps -eo stat,ppid,pid,comm | awk '$1 ~ /^Z/'
3
4# 2. 统计数量
5ps -eo stat | grep -c '^Z'
6
7# 3. 看某个僵尸的父进程
8cat /proc/48212/status | grep -E '^(State|PPid)'
9
10# 4. 确认父进程是谁
11ps -o pid,ppid,stat,cmd -p 48211处理顺序也很清楚:先找到父进程,再看它为什么没回收。是代码里漏了 wait(),还是信号处理写错了,还是容器缺少 init。僵尸本身不需要、也无法被单独清理。
写在最后
僵尸进程最反直觉的地方在于:它不是一个“还活着的坏进程”,而是一个“已经结束但没被确认的进程”。kill 对它无效,不是权限问题,也不是内核 bug,而是因为它已经不在能被信号打扰的世界里了。
下次在 ps 里看到 <defunct>,别急着找杀进程的命令。顺着 PPID 往上看,问题多半在父进程的某一行 fork() 后面,那里少了一个 wait()。