朕喜技术(ZhenxiJS)
2026.08.28
1 分钟阅读

僵尸进程(Zombie/Defunct)堆积导致 PID 耗尽:原因、复现与修复

问题背景

Linux 系统中批量任务调度程序(Python subprocess.Popen)反复启动子进程处理任务,但从不调用 wait()/waitpid() 回收子进程退出状态,导致系统出现大量 <defunct> 僵尸进程,最终报错 Resource temporarily unavailable,无法 fork 新进程。

常见报错信息:

  • ps aux 显示大量 <defunct> 进程
  • Resource temporarily unavailable / OSError: [Errno 11] Resource temporarily unavailable
  • fork() failed: Cannot allocate memory(实际是 PID 耗尽而非内存)

技术栈:Linux / Python 3 / subprocess / 进程管理 / SIGCHLD 信号

复现步骤

# 1. 启动容器环境
cd scenario/2026-08-28-1600
docker compose up -d --build

# 2. 在容器内运行问题版本
docker exec zombie-scenario bash -c 'cd /workspace/scenario/2026-08-28-1600 && python3 parent.py'

# 3. 另开终端观察僵尸进程
docker exec zombie-scenario bash -c 'ps -eo pid,ppid,stat,cmd | grep defunct'

预期观察到的现象:

  1. 僵尸进程数量持续增加(每批 10 个)
  2. 进程状态为 Z,命令行标记为 [python3] <defunct>
  3. kill -9 无法清除僵尸进程(因为它已经死了)
  4. 父进程退出后,僵尸进程被 init 收养并自动清除

根本原因

Linux 进程生命周期:子进程退出后,内核保留其进程表项(含退出状态码)等待父进程读取。若父进程从未调用 wait()/waitpid()/communicate() 来回收子进程退出状态,子进程就永久停留在僵尸状态。

关键特征:

  • 僵尸进程不消耗 CPU/内存
  • 僵尸进程占用 PID(内核需要为它保留 PID 以便父进程查询)
  • PID 空间有限(cat /proc/sys/kernel/pid_max,默认 32768)
  • kill -9 对僵尸进程无效(信号只能发送给运行中的进程)
  • 只有父进程 wait() 回收,或父进程退出后被 init(PID=1)收养,才能清除

解决办法

方案一:SIGCHLD 信号处理器 + 非阻塞 waitpid(推荐)

适用于批量调度/并发子进程场景,不阻塞主循环:

import os
import signal

def sigchld_handler(signum, frame):
    """回收所有已退出的子进程,非阻塞"""
    try:
        while True:
            pid, status = os.waitpid(-1, os.WNOHANG)
            if pid == 0:
                break  # 没有更多已退出的子进程
    except ChildProcessError:
        pass

signal.signal(signal.SIGCHLD, sigchld_handler)

方案二:使用 subprocess.run()

最简单直接,适合同步执行单个任务:

import subprocess
subprocess.run(["python3", "child.py"], capture_output=True)
# run() 内部会调用 wait(),自动回收

方案三:调用 proc.wait()

适合明确知道子进程何时完成的场景:

proc = subprocess.Popen([...])
proc.wait()  # 阻塞等待子进程完成并回收

方案四:使用 proc.communicate()

如果需要读取子进程输出并同时回收:

proc = subprocess.Popen([...], stdout=subprocess.PIPE)
stdout, stderr = proc.communicate()  # 自动 wait()

验证

修复后运行 30 批(300 个子进程),僵尸进程数始终为 0:

[parent] 第 30 批:启动了 10 个子进程
        当前僵尸进程数: 0
ps -eo stat | grep -c "^Z"   # 输出: 0

经验与避坑

  1. 只要用 Popen 就必须 wait():Python subprocess.Popen() 不会自动回收子进程,任何用 Popen 启动子进程的代码都必须显式 wait/communicate,或注册 SIGCHLD 处理器。
  2. 别依赖 GC 隐式 wait():subprocess.Popen 对象被垃圾回收时可能隐式调用 wait(),但这不可靠。本问题代码中甚至故意保存了 Popen 引用防止 GC 隐式 wait,以暴露 bug。
  3. kill -9 杀不掉僵尸进程:僵尸进程已经不是运行中的进程,信号对它无效。唯一清除方式是父进程 wait() 或父进程退出。
  4. PID 耗尽 vs 内存不足要区分:Resource temporarily unavailable 既可能是 PID 耗尽也可能是线程/内存限制。用 cat /proc/sys/kernel/pid_max 和 ps -eo stat | grep -c "^Z" 排查。
  5. SIGCHLD 处理器的陷阱:如果已有子进程退出但处理器还没来得及执行,waitpid(-1, WNOHANG) 会在循环中全部回收;ChildProcessError 表示没有子进程,需要捕获。
  6. Docker 中 Python 环境注意 PEP 668:Debian bookworm 及更高版本的系统 Python 受 externally-managed-environment 保护,pip3 install 需要加 --break-system-packages 或使用 venv。
  7. 运维监控建议:定期检查僵尸进程数量(ps -eo stat | grep -c "^Z"),并监控 PID 使用率(sysctl kernel.pid_max + /proc/sys/kernel/ns_last_pid),及时发现异常堆积。

可复现命令

# 完整复现与修复
git checkout 2026-08-28-1600
cd scenario/2026-08-28-1600
docker compose up -d --build
docker exec zombie-scenario bash -c 'cd /workspace/scenario/2026-08-28-1600 && python3 parent.py'
# 观察修复后僵尸进程数为 0
docker exec zombie-scenario bash -c 'ps -eo stat | grep -c "^Z"'

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/20260828-1612-pid

分享这篇文章