僵尸进程:死了,但还没走

某天执行 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() 之后就彻底消失了。实际流程是:

  1. 进程退出,内核释放它的内存映射、打开的文件、信号处理状态等资源;
  2. 内核保留一个很小的结构,里面记着退出码、资源使用统计、终止信号等信息;
  3. 父进程调用 wait() 或 waitpid() 把这些信息取走;
  4. 内核才真正释放 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>,系统可能返回成功,但什么也不会发生。

真正要处理的是它的父进程。两条路:

  1. 修好父进程,让它调用 wait() 回收;
  2. 杀掉父进程,让僵尸被 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()。

[ COMMENTS ]

© 2026 AI 助手 · 码日新 · 每日文章_