← 返回题目列表

什么是僵尸进程和孤儿进程?Python 里怎么避免?

中等 第 23 / 27 题 更新于 2026/08/01
僵尸进程孤儿进程waitsubprocess

简化版

僵尸进程(zombie)是「已经退出、但父进程还没来收尸」的进程:子进程调用 exit() 后,内核会释放它的内存、文件描述符等几乎所有资源,但保留一小块「进程表项」,里面记着退出码、CPU 用时等信息——因为父进程可能还想知道「孩子是怎么死的」。这块信息必须由父进程调用 wait()/waitpid() 来取走(这个动作叫「回收 / reap」),取走后进程表项才被删除。如果父进程一直不 wait,这些僵尸就会一直挂着——它们不占内存、不占 CPU,但每个都占用一个 PID,积累到系统上限(/proc/sys/kernel/pid_max,默认 32768 或 4194304)就会导致整台机器再也创建不了新进程fork: Resource temporarily unavailable)。孤儿进程(orphan)正相反:父进程先死了,子进程还活着——内核会立刻把它「过继」给 PID 1(init/systemd),而 PID 1 会自动 wait 回收它,所以孤儿进程本身没有危害Python 里的关键点subprocess.Popen 创建的子进程必须调用 wait()/poll()/communicate() 或用 with 语句才会被回收;multiprocessing.Processjoin()(不 join 也可以,它内部会在创建新进程时顺带清理已结束的)。容器里有个特殊坑:你的应用作为 PID 1 时,没有默认的 SIGCHLD 处理,不会自动回收被过继来的孤儿——所以推荐用 tinidocker run --init)做 PID 1。核心记忆:僵尸 = 子死父没 wait(占 PID)孤儿 = 父死子还在(被 PID 1 收养,无害)Popen 一定要 wait,容器里用 tini

详细版

两种「异常」进程状态对照

僵尸进程(Zombie)孤儿进程(Orphan)
定义子进程已退出,父进程未 wait父进程先退出,子进程仍在运行
状态码ps 里显示 Z / defunct正常 S/R
占用资源只占进程表项和 PID(不占内存/CPU)正常占用
危害⚠️ 累积会耗尽 PID无害(被 PID 1 收养并回收)
谁负责清理父进程 wait()PID 1 自动回收
父进程死后僵尸被过继给 PID 1 → 立刻被回收——
import os, time, signal, subprocess, sys

# ① ★制造一个僵尸★
pid = os.fork()
if pid == 0:
    os._exit(0)              # ★子进程立刻退出★
time.sleep(30)               # ★父进程不 wait → 这 30 秒里子进程是僵尸★
# 此时 ps aux | grep defunct 能看到:[python] <defunct>
os.waitpid(pid, 0)           # ★回收,僵尸消失★

# ② ★subprocess:三种正确的回收方式★
p = subprocess.Popen(["sleep", "1"])
p.wait()                                    # ✓ 阻塞等待并回收

with subprocess.Popen(["sleep", "1"]) as p: # ✓ ★with 退出时自动 wait★
    pass

out, err = subprocess.Popen(["ls"], stdout=subprocess.PIPE).communicate()  # ✓ 内部会 wait

subprocess.run(["ls"])                      # ✓ ★run 内部就是 Popen + communicate★

# ✗ 反面:创建了但从不 wait
for i in range(1000):
    subprocess.Popen(["true"])              # ★1000 个僵尸★
# ★注意:Popen 对象被 GC 时,Python 会尝试 poll 一次(3.6+ 有 __del__ 警告),
#   但★不保证及时★,且会发 ResourceWarning

# ③ ★非阻塞地回收已结束的子进程★
children = [subprocess.Popen(cmd) for cmd in cmds]
while children:
    for p in children[:]:
        if p.poll() is not None:            # ★poll 返回退出码 = 已结束并被回收★
            print(f"pid {p.pid} 退出码 {p.returncode}")
            children.remove(p)
    time.sleep(0.1)

# ④ ★用 SIGCHLD 自动回收(守护进程的经典写法)★
def reap_children(signum, frame):
    while True:
        try:
            pid, status = os.waitpid(-1, os.WNOHANG)   # ★-1 = 任意子进程,WNOHANG 非阻塞★
            if pid == 0:
                break                        # ★没有更多已结束的子进程★
        except ChildProcessError:
            break                            # 没有子进程了
signal.signal(signal.SIGCHLD, reap_children)
# ★必须用 while 循环★:多个子进程同时结束时,SIGCHLD 可能★只投递一次★(信号不排队)

# ⑤ 最省事的做法:告诉内核"我不关心退出状态,你自己回收"
signal.signal(signal.SIGCHLD, signal.SIG_IGN)
# → ★子进程结束后由内核直接回收,不会变僵尸★
# ✗ 代价:★再也拿不到退出码★(wait 会抛 ChildProcessError)
#   → subprocess/multiprocessing 会因此出错,★不要在用它们的程序里这么设★

# ⑥ multiprocessing
p = multiprocessing.Process(target=work)
p.start()
p.join()                                    # ✓ ★join 内部会 wait 并回收★
# 不 join 也能被清理:multiprocessing 在 start 新进程时会顺带 _cleanup 已结束的
# ★但仍然推荐显式 join(能拿到 exitcode、能确保执行完)★

# ⑦ 查看僵尸
# ps aux | awk '$8 ~ /^Z/ {print}'          # STAT 列以 Z 开头
# ps -eo pid,ppid,stat,comm | grep -w Z
# cat /proc/<pid>/status | grep State       # State: Z (zombie)

⚠️ 三个必须记住的机制:① 僵尸进程不占内存也不占 CPU,唯一的危害是占 PID——它的内存、文件描述符、栈全都在 exit() 时就被内核释放了,只留下一个 task_struct 里的退出状态。但每个僵尸占一个 PID,系统的 PID 是有上限的(/proc/sys/kernel/pid_max),一旦耗尽,整台机器(不只是你的程序)都无法创建新进程——表现为 fork: Resource temporarily unavailable、连 ssh 都登不上去。② 杀不掉僵尸kill -9 <僵尸 PID> 完全无效,因为它已经死了——信号是发给活着的进程的。清理僵尸的唯一办法是让父进程去 wait;如果父进程有 bug 不 wait,那就杀掉父进程——僵尸会被过继给 PID 1,由 PID 1 立刻回收。③ SIGCHLD 信号不排队:如果 10 个子进程几乎同时结束,父进程可能只收到 1 次 SIGCHLD(POSIX 的标准信号不排队,重复的会被合并)。所以信号处理函数里必须用 while 循环配合 waitpid(-1, WNOHANG) 反复回收,直到返回 0 或抛 ChildProcessError 为止——只 wait 一次会留下一堆僵尸。

完整版教学

一、进程退出的完整流程

一个子进程从退出到彻底消失,要走两步:

  ① 子进程调用 exit(code)(或被信号杀死)
     内核做的事:
       ✓ 释放内存(地址空间)
       ✓ 关闭所有文件描述符
       ✓ 释放锁、信号量等资源
       ✗ ★保留 task_struct 里的一小块:退出码、CPU 时间、资源使用统计★
       → 给父进程发 ★SIGCHLD★ 信号
     此刻状态:★Z(zombie / defunct)★

  ② 父进程调用 wait() / waitpid()
     → 取走退出状态("收尸"/reap)
     → ★内核删除进程表项,PID 被释放★
     → 僵尸彻底消失

  ┌──────────┐  exit()   ┌──────────┐  父 wait()  ┌──────────┐
  │ 运行中(R) │ ────────► │ 僵尸(Z)  │ ──────────► │ 消失      │
  └──────────┘           └──────────┘             └──────────┘
                          ★只占 PID★

★ 为什么要有"僵尸"这个状态(设计意图):
  父进程往往需要知道"孩子是怎么结束的":
    - 退出码是 0 还是非 0(成功还是失败)
    - 是正常退出还是被信号杀死(哪个信号)
    - 用了多少 CPU 时间
  → 这些信息必须在子进程完全消失前★保留一段时间★
  → 所以内核的设计是:"我先留着,等父进程来取"
  → ★僵尸不是 bug,是机制的一部分★;只有"父进程永远不来取"才是 bug

★ 退出状态的解码:
  pid, status = os.waitpid(child_pid, 0)
  os.WIFEXITED(status)    → 是否正常退出
  os.WEXITSTATUS(status)  → 退出码(0~255)
  os.WIFSIGNALED(status)  → 是否被信号杀死
  os.WTERMSIG(status)     → 哪个信号
  ★ subprocess 已经帮你解码好了:
    p.returncode  ≥ 0 表示退出码;★负数表示被信号杀死(-N = SIGNAL N)★
    例:p.returncode == -9 → 被 SIGKILL 杀死

孤儿进程的处理(内核自动完成):
  父进程先退出 → 内核把它所有还活着的子进程★重新挂到 PID 1 名下★
  → PID 1(init/systemd)会持续 wait,所以孤儿结束后会★立刻被回收★
  → ★孤儿进程本身完全无害★,只是"换了个爹"
  ★ 这也是"守护进程(daemon)"的经典实现手法:
    fork → 父进程立刻退出 → 子进程成为孤儿被 init 收养 → 脱离终端

进程退出要走两步才彻底消失:第一步子进程 exit()——内核释放它的内存、文件描述符和锁,但保留 task_struct 里的退出码、CPU 时间等统计信息,并给父进程发 SIGCHLD,此刻状态是 Z(zombie/defunct)第二步父进程 wait() 取走退出状态(「收尸」),内核才删除进程表项、释放 PID。理解设计意图很重要:僵尸不是 bug 而是机制的一部分——父进程往往需要知道「孩子是怎么死的」(退出码、是否被信号杀死、用了多少 CPU),这些信息必须在子进程消失前保留,所以内核先留着等父进程来取;只有「父进程永远不来取」才是 bug。退出状态的解码在 subprocess 里已经做好了:p.returncode 非负是退出码、负数表示被信号杀死-9 就是被 SIGKILL)。孤儿进程则由内核自动处理:父进程先退出时,它所有还活着的子进程会被重新挂到 PID 1 名下,由 PID 1 持续 wait 回收——孤儿本身完全无害,「fork 后父进程立刻退出」正是守护进程脱离终端的经典手法。

二、僵尸的危害与排查

★ 僵尸到底占用什么:
  ✗ 不占内存(地址空间已释放)
  ✗ 不占 CPU(已经不运行了)
  ✗ 不占文件描述符(已关闭)
  ✓ ★占一个 PID★
  ✓ ★占一个进程表项(几 KB 内核内存)★

  → 少量僵尸完全无害;★问题在于"持续累积"★

★ PID 耗尽的后果(真实故障):
  cat /proc/sys/kernel/pid_max        # 默认 32768(老系统)或 4194304(新内核)
  → 僵尸攒满之后:
    fork() 返回 EAGAIN
    Python: BlockingIOError: [Errno 11] Resource temporarily unavailable
    Shell:  bash: fork: retry: Resource temporarily unavailable
    → ★整台机器都创建不了新进程★:ssh 登不上、ps 都跑不了、只能重启
  ★ 而且这是"全系统"的资源,你的程序会拖垮同机的其他服务

  ★ 还有一个更早触发的限制:
    ulimit -u(每用户最大进程数),通常比 pid_max 小得多
    → 你的服务账号先撑爆,表现为"这个用户创建不了进程"

★ 排查手法:
  # 1) 数一下有多少僵尸
  ps -eo stat | grep -c '^Z'
  ps aux | awk '$8 ~ /^Z/ {print $2, $11}'

  # 2) 找出僵尸的父进程(★真正要修的是父进程★)
  ps -eo pid,ppid,stat,comm | awk '$3 ~ /^Z/ {print "zombie", $1, "parent", $2}'
  → 然后 ps -p <ppid> -o comm= 看是谁

  # 3) 看某个进程有多少僵尸孩子
  ls /proc/<pid>/task/*/children 2>/dev/null

  # 4) Python 里自查
  import subprocess
  # 记录所有 Popen 对象,定期检查 returncode is None 的数量

★ 常见的"制造僵尸"的代码模式:
  ✗ subprocess.Popen(cmd)  然后再也不管它
  ✗ os.fork() 后父进程不 waitpid
  ✗ 定时任务里循环起子进程,只在"成功时"才 wait(★异常路径漏了★)
  ✗ 把 Popen 对象丢掉(不保存引用),指望 GC 处理
     → ★GC 时机不确定★,而且 Python 3.6+ 只会发 ResourceWarning
  ✗ 用信号处理器回收但★只 wait 一次★(SIGCHLD 不排队,见下节)

★ 杀不掉僵尸(面试常考):
  kill -9 <僵尸 pid>     → ★完全无效★(进程已经死了,信号发给谁?)
  ✓ 唯一办法:让父进程 wait
  ✓ 父进程有 bug 不 wait → ★kill 父进程★
    → 僵尸被过继给 PID 1 → ★PID 1 立刻回收★ → 僵尸消失
  ✓ 或给父进程发 SIGCHLD(如果它有正确的处理器,能触发一次回收)

僵尸不占内存、不占 CPU、不占 fd,唯一占用的是「一个 PID + 一个进程表项」——所以少量僵尸完全无害,问题在于持续累积。PID 耗尽是真实故障:fork() 开始返回 EAGAIN,Python 抛 BlockingIOError: Resource temporarily unavailable整台机器都创建不了新进程(ssh 登不上、连 ps 都跑不了),而且这是全系统资源,会拖垮同机的其他服务;实际上更早触发的往往是 ulimit -u(每用户进程数上限)。排查的关键是找出僵尸的父进程ps -eo pid,ppid,stat,comm 里 STAT 为 Z 的那些行的 PPID)——真正要修的是父进程。最后一个高频考点:kill -9 杀不掉僵尸(它已经死了,信号发给谁?),唯一的办法是让父进程 wait;父进程有 bug 就杀掉父进程,僵尸会被过继给 PID 1 并立刻被回收。

三、Python 里的正确回收方式

★ subprocess:四种正确写法★
  ① with 语句(★最推荐★)
     with subprocess.Popen(cmd) as p:
         ...
     # ★退出时自动 wait()★(3.2+ 支持上下文管理器)

  ② subprocess.run(★最省事★)
     r = subprocess.run(cmd, capture_output=True, timeout=30)
     # 内部 = Popen + communicate + wait,★一定会回收★

  ③ 显式 wait
     p = subprocess.Popen(cmd)
     try:
         p.wait(timeout=30)
     except subprocess.TimeoutExpired:
         p.kill()
         p.wait()               # ★kill 之后仍然要 wait 才能回收!★

  ④ communicate(★需要读输出时必用★)
     out, err = p.communicate(timeout=30)   # 内部会 wait

  ★ 最容易漏的一点:
    p.kill() / p.terminate() 只是★发信号★,
    子进程死后仍然是僵尸 → ★必须再 wait() 一次★

★ 非阻塞轮询多个子进程:
  procs = [subprocess.Popen(c) for c in cmds]
  while procs:
      for p in procs[:]:
          rc = p.poll()          # ★None = 还在跑;有值 = 已结束且已回收★
          if rc is not None:
              procs.remove(p)
      time.sleep(0.05)
  ★ poll() 内部就是 waitpid(WNOHANG),返回非 None 时已经完成了回收

★ SIGCHLD 处理器(长期运行的守护进程):
  def reap(signum, frame):
      while True:                       # ★必须循环★
          try:
              pid, status = os.waitpid(-1, os.WNOHANG)
          except ChildProcessError:
              break                     # 没有子进程了
          if pid == 0:
              break                     # ★没有更多"已结束"的子进程★
          logging.info("回收子进程 %d, status=%d", pid, status)
  signal.signal(signal.SIGCHLD, reap)

  ★ 为什么必须 while 循环:
    ★标准信号不排队★——10 个子进程同时结束,父进程可能只收到 1 次 SIGCHLD
    → 只 waitpid 一次 = 回收 1 个,剩下 9 个变僵尸
  ★ 注意:装了 SIGCHLD 处理器后,subprocess 的 wait 可能被打断
    (PEP 475 之后会自动重试,一般无碍;但和 subprocess 混用仍需谨慎)

★ 最省事但有代价的做法:
  signal.signal(signal.SIGCHLD, signal.SIG_IGN)
  → 明确告诉内核"我不关心退出状态"→ ★子进程结束后内核直接回收★
  ✗ 代价:★彻底拿不到退出码★
    - os.wait() 会抛 ChildProcessError
    - subprocess 的 returncode 可能不准
    - ★multiprocessing 会出问题★
  → 只适合"纯粹 fire-and-forget、完全不关心结果"的场景

★ multiprocessing:
  p = mp.Process(target=work); p.start()
  p.join()              # ✓ 内部 waitpid,回收 + 拿到 p.exitcode
  ★ 即使不 join,multiprocessing 也会在下次 start/active_children() 时
    顺带清理已结束的子进程(内部维护了 _children 集合)
  ★ 但显式 join 更好:能确认执行完、能拿 exitcode、能设超时

★ 进程池:
  Pool / ProcessPoolExecutor 会自己管理 worker 的生命周期
  → 用 with 或显式 shutdown/close+join 即可,不用操心僵尸

subprocess 有四种正确写法:with 语句(最推荐,退出时自动 waitsubprocess.run(最省事,内部一定会回收)、显式 wait(timeout=)、以及 communicate()最容易漏的一点是:p.kill()/p.terminate() 只是发信号,子进程死后仍然是僵尸——必须再 wait() 一次。需要同时管理多个子进程时用 poll() 轮询(返回非 None 就说明已结束且已回收)。长期运行的守护进程可以装 SIGCHLD 处理器,但里面必须用 while 循环反复 waitpid(-1, WNOHANG)——因为标准信号不排队,10 个子进程同时结束可能只投递 1 次 SIGCHLD,只 wait 一次就会留下 9 个僵尸。还有个最省事但有代价的做法:signal.signal(signal.SIGCHLD, signal.SIG_IGN) 明确告诉内核「我不关心退出状态」,子进程结束后由内核直接回收——代价是彻底拿不到退出码,而且会让 subprocess/multiprocessing 出问题,只适合纯 fire-and-forget 的场景。

四、容器里的 PID 1 问题

★ 容器的特殊之处:你的应用往往就是 PID 1★

  正常的 Linux 系统:
    PID 1 = init/systemd → ★天生就会持续 wait,自动回收所有孤儿★
  容器里:
    PID 1 = ★你的 python app.py★
    → 它没有"回收孤儿"的职责意识

★ 内核对 PID 1 的两条特殊规则:
  ① ★PID 1 没有默认信号处理★
     普通进程收到 SIGTERM 默认终止;★PID 1 不会★(必须自己注册 handler)
     → 这就是"docker stop 要等满 10 秒才强杀"的原因(详见信号专题)
  ② ★所有孤儿进程会被过继给 PID 1★
     → 如果 PID 1(你的 Python)不 wait,★这些孤儿死后就成了永久僵尸★

  典型场景:
    你的 Python(PID 1)用 subprocess 起了 ffmpeg
    ffmpeg 又自己 fork 了辅助进程
    ffmpeg 退出 → ★辅助进程变孤儿 → 过继给 PID 1(你)★
    辅助进程结束 → 你从来没 wait 过它 → ★僵尸★
    → 长期运行的转码/爬虫容器里,僵尸慢慢堆积到几万个

★ 解决方案(按推荐度):
  ① ★用 tini / dumb-init 做 PID 1(最省事)★
     docker run --init ...                        # ★Docker 自带 tini★
     # 或 Dockerfile:
     ENTRYPOINT ["/sbin/tini", "--"]
     CMD ["python", "app.py"]
     → tini 做两件事:★转发信号★ + ★回收僵尸★
     → 你的 Python 变成 PID 2,行为回归"正常进程"

  ② ★自己在 Python 里装 SIGCHLD 处理器★
     如果确实要让 Python 当 PID 1,就必须自己承担 init 的职责:
       signal.signal(signal.SIGCHLD, reap)   # ★while 循环回收★
       signal.signal(signal.SIGTERM, on_term)  # ★还要自己处理信号★

  ③ ★K8s:shareProcessNamespace 时更要注意★
     开启后 pause 容器成为 PID 1 并负责回收,但同 Pod 内进程互相可见

★ 怎么发现容器里有僵尸:
  docker exec <container> ps aux | grep defunct
  kubectl exec <pod> -- ps -eo stat,pid,comm | grep '^Z'
  # 或监控 /proc 下的进程数:
  ls /proc | grep -c '^[0-9]'

★ 一个经典的容器故障链:
  应用(PID 1)不回收僵尸
  → 僵尸累积到几万
  → 容器内 PID 达到 cgroup 的 pids.max 限制(K8s 可配)
  → ★新的 subprocess 全部创建失败★
  → 服务功能异常但进程还活着(★健康检查可能还是通过的★)
  → 排查半天才发现是僵尸
  ✓ 预防:docker run --init / tini + 监控进程数

容器里有个特殊坑:你的应用往往就是 PID 1,而内核对 PID 1 有两条特殊规则——没有默认信号处理(所以 docker stop 要等满超时才强杀)和所有孤儿进程都会被过继给它。于是典型场景是:你的 Python(PID 1)用 subprocess 起了 ffmpeg,ffmpeg 又 fork 了辅助进程;ffmpeg 退出后辅助进程变成孤儿、被过继给你,它结束时你从来没 wait 过它——就成了永久僵尸。长期运行的转码、爬虫容器里,僵尸能堆积到几万个。解决方案首选是用 tini/dumb-init 做 PID 1docker run --init 就自带 tini),它负责转发信号 + 回收僵尸两件事,你的 Python 变成 PID 2、行为回归正常。经典的故障链值得记住:僵尸累积 → 容器内 PID 达到 cgroup 的 pids.max → 新的 subprocess 全部创建失败 → 服务功能异常但进程还活着(健康检查甚至还是通过的)

五、相关概念辨析

★ 僵尸 vs 孤儿 vs 守护 vs 挂起,别搞混:

  ★僵尸(Zombie / defunct)★
    子进程★已死★,父进程没 wait
    状态:Z    危害:★占 PID★    修法:父进程 wait 或杀父进程

  ★孤儿(Orphan)★
    父进程★先死★,子进程还活着
    状态:正常  危害:★无★(被 PID 1 收养并自动回收)
    ★孤儿 ≠ 僵尸★(这是最常见的混淆)

  ★守护进程(Daemon)★
    ★故意★制造的孤儿:fork → 父退出 → setsid() 脱离终端 → 长期后台运行
    → 是一种"设计",不是问题

  ★D 状态(不可中断睡眠)★
    进程卡在内核态 IO(通常是磁盘/NFS 故障)
    ★kill -9 也杀不掉★(和僵尸一样杀不掉,但原因完全不同)
    → 僵尸是"已经死了",D 状态是"活着但卡在内核里"

  ★T 状态(stopped)★
    被 SIGSTOP 暂停,SIGCONT 可恢复

★ 进程状态速查(ps 的 STAT 列):
  R  运行/就绪        S  可中断睡眠(正常等待)
  D  ★不可中断睡眠★  T  已停止
  Z  ★僵尸★          I  空闲内核线程
  后缀:s=会话首进程  l=多线程  +=前台进程组  <=高优先级

★ 一个容易混淆的问题:"父进程死了,僵尸会怎样?"
  僵尸 → 被过继给 PID 1 → ★PID 1 立刻 wait 掉它★ → 僵尸消失
  → 所以"杀掉父进程能清理僵尸"是对的
  → 但要注意:★这也会让父进程的其他正常子进程变成孤儿★(它们继续运行)

★ Python 特有的补充:
  - multiprocessing 的 daemon=True 子进程:★父进程退出时会被 terminate★
    (不是变孤儿,是被主动杀掉)
  - threading 的 daemon:线程,和进程无关,★别混淆★
  - atexit / __del__ 在 os._exit() 时★不会执行★

几个相关概念必须辨析清楚:僵尸是「子已死、父没 wait」(状态 Z、占 PID、要父进程 wait);孤儿是「父先死、子还活着」(状态正常、无害、被 PID 1 收养)——孤儿 ≠ 僵尸是最常见的混淆守护进程是「故意制造的孤儿」(fork → 父退出 → setsid() 脱离终端),是设计而非问题;D 状态(不可中断睡眠)同样 kill -9 杀不掉,但原因完全不同——僵尸是「已经死了」,D 状态是「活着但卡在内核 IO 里」(通常是磁盘或 NFS 故障)。还有个容易混淆的问题:「父进程死了,僵尸会怎样?」——僵尸被过继给 PID 1 并立刻被回收,所以「杀掉父进程能清理僵尸」是对的,但要注意这也会让父进程的其他正常子进程变成孤儿(它们会继续运行)。Python 特有的补充:multiprocessingdaemon=True 子进程在父进程退出时会被主动 terminate(不是变孤儿),而 threading 的 daemon 是线程概念、别和进程混淆

六、实践清单

★ 编码时:
  □ subprocess 一律用 ★with 语句★ 或 ★subprocess.run★
  □ 用 Popen 时确保★所有路径★都会 wait(含异常路径 → try/finally)
  □ ★kill/terminate 之后仍然要 wait★(发信号 ≠ 回收)
  □ 需要超时:p.wait(timeout=N) → TimeoutExpired → p.kill() → ★p.wait()★
  □ 循环创建子进程时,同步做回收(poll 轮询或 SIGCHLD)
  □ multiprocessing 用 join()(能拿 exitcode)
  □ 不要用 SIG_IGN 图省事(会让 subprocess/multiprocessing 拿不到退出码)

★ 部署时:
  □ 容器:★docker run --init★ 或 Dockerfile 里用 tini/dumb-init
  □ 如果 Python 必须当 PID 1:自己装 ★SIGCHLD(while 循环回收)+ SIGTERM★
  □ 设置 cgroup 的 pids.max(K8s 可配),★早暴露问题好过拖垮宿主机★
  □ 监控:容器/宿主机的进程数、僵尸数
    ps -eo stat | grep -c '^Z'

★ 排查时(★出现"创建不了进程"时的标准流程★):
  ① 数僵尸:ps -eo stat | grep -c '^Z'
  ② 找父进程:ps -eo pid,ppid,stat,comm | awk '$3 ~ /^Z/ {print $2}' | sort | uniq -c
  ③ 确认限制:cat /proc/sys/kernel/pid_max;ulimit -u;容器看 pids.max
  ④ 临时止血:★kill 那个不 wait 的父进程★(僵尸会被 PID 1 回收)
  ⑤ 根治:修代码(补 wait)或加 tini

★ 一段"始终正确"的子进程管理模板:
  def run_cmd(cmd, timeout=30):
      with subprocess.Popen(cmd, stdout=subprocess.PIPE,
                            stderr=subprocess.PIPE, text=True) as p:
          try:
              out, err = p.communicate(timeout=timeout)   # ★内部会 wait★
          except subprocess.TimeoutExpired:
              p.kill()                                     # ★SIGKILL★
              out, err = p.communicate()                   # ★再收一次,确保回收★
              raise
          if p.returncode != 0:
              raise RuntimeError(f"命令失败 rc={p.returncode}: {err}")
          return out
  ★ with + communicate + 异常路径也 communicate ⇒ ★任何情况下都不会留僵尸★

实践清单分三部分。编码时subprocess 一律用 withsubprocess.run;用 Popen 时确保所有路径(含异常路径)都会 waitkill/terminate 之后仍然要 wait;不要用 SIG_IGN 图省事。部署时:容器用 docker run --init 或 tini;如果 Python 必须当 PID 1 就自己装 SIGCHLD(while 循环回收)和 SIGTERM 处理器;设置 cgroup 的 pids.max——早暴露问题好过拖垮宿主机;并监控僵尸数量。排查时的标准流程是:数僵尸 → 按 PPID 聚合找出那个不 wait 的父进程 → 确认 pid_max/ulimit -u/pids.max 限制 → 临时止血就杀掉父进程(僵尸会被 PID 1 回收)→ 根治是补 wait 或加 tini。最后那段模板值得直接抄:with + communicate + 异常路径也 communicate,任何情况下都不会留下僵尸。

记忆钩子:「★僵尸 = 子进程已退出但父进程没 wait★:exit 时内核已经释放了内存/fd/锁,★只保留 task_struct 里的退出码和 CPU 统计★等父进程来取(这是机制不是 bug,因为父进程往往需要知道『孩子是怎么死的』);父进程 wait 后进程表项才删除、PID 才释放。★孤儿 = 父进程先死、子进程还活着★,会被内核★过继给 PID 1★由它自动回收,所以★孤儿完全无害★(守护进程正是故意制造的孤儿)——孤儿≠僵尸是最常见的混淆。★僵尸不占内存不占 CPU,唯一危害是占 PID★,累积到 pid_max / ulimit -u / cgroup 的 pids.max 就会导致★整台机器都 fork 不出新进程★(Resource temporarily unavailable,ssh 都登不上)。★kill -9 杀不掉僵尸★(它已经死了),唯一办法是让父进程 wait,或者★杀掉父进程让 PID 1 接手回收★。Python 里的四条铁律:①subprocess 用 ★with 或 subprocess.run★(内部一定 wait)②★kill/terminate 只是发信号,之后仍然必须 wait★ ③装 SIGCHLD 处理器时★必须用 while 循环配合 waitpid(-1, WNOHANG)★,因为★标准信号不排队★(10 个子进程同时结束可能只投递 1 次 SIGCHLD)④★signal.SIGCHLD 设成 SIG_IGN 能让内核自动回收,但代价是彻底拿不到退出码★,会让 subprocess/multiprocessing 出问题。★容器里的特殊坑:你的应用往往是 PID 1,而 PID 1 既没有默认信号处理、又要负责回收所有被过继来的孤儿★——用 ★docker run —init / tini★ 做 PID 1 是最省事的解法(它负责转发信号 + 回收僵尸)。最后辨析:D 状态(不可中断睡眠)同样 kill -9 无效,但那是『活着卡在内核 IO』,和僵尸的『已经死了』完全不同。」

七、常见误区与追问

  • 误区:僵尸进程会一直占用内存和 CPU,所以很危险。 恰恰相反——僵尸进程已经死了:它的地址空间、文件描述符、栈、堆在 exit() 的那一刻就被内核释放了,不占内存、不占 CPU、不占 fd。它只保留 task_struct 里的一小块信息(退出码、CPU 时间、资源统计),占用一个 PID 和几 KB 内核内存。所以少量僵尸完全无害。真正的危害是持续累积导致 PID 耗尽:系统的 PID 有上限(/proc/sys/kernel/pid_max),每用户还有 ulimit -u 限制、容器还有 cgroup 的 pids.max——一旦耗尽,整台机器(不只是你的程序)都无法创建新进程,表现为 fork: Resource temporarily unavailable,连 sshps 都执行不了,往往只能重启。
  • 误区:kill -9 能清理僵尸进程。 完全无效——信号是发给正在运行的进程的,而僵尸已经退出了,内核里只剩一个记录退出状态的表项,没有任何代码可以被信号打断或终止。清理僵尸的唯一途径是让它的父进程调用 wait()/waitpid() 取走退出状态。如果父进程有 bug 永远不 wait,实际可行的止血手段是杀掉父进程:父进程一死,它名下的僵尸会被过继给 PID 1,而 PID 1 会立刻 wait 掉它们——僵尸随之消失。(注意副作用:这同时会让父进程的其他正在运行的子进程变成孤儿,它们会继续运行并被 PID 1 收养。)另一个温和些的办法是给父进程发 SIGCHLD,如果它装了正确的处理器,能触发一次回收。
  • 误区:孤儿进程和僵尸进程差不多,都是要清理的问题进程。 两者完全不同,孤儿进程无害僵尸是「子进程已死、父进程没来收尸」——问题在父进程;孤儿是「父进程先死、子进程还活着」——子进程一切正常,只是「换了个爹」:内核会立刻把它过继给 PID 1(init/systemd),而 PID 1 天生就会持续 wait,所以这个孤儿将来退出时会被立刻回收,根本不会变成僵尸。事实上「制造孤儿」是一种标准技术手段——守护进程(daemon)的经典实现就是 fork 之后让父进程立刻退出,子进程成为孤儿被 init 收养,再调 setsid() 脱离终端,从而在后台长期运行。所以看到孤儿进程不必紧张,真正要排查的是 ps 里 STAT 为 Z 的那些。
  • 误区:在 SIGCHLD 处理器里调用一次 os.waitpid() 就能回收子进程。 会漏掉大量僵尸,因为 POSIX 的标准信号不排队:如果 10 个子进程在很短时间内相继结束,内核可能只向父进程投递 1 次 SIGCHLD(重复的信号在未处理前会被合并成一个)。只 waitpid 一次就只回收了 1 个,剩下 9 个全变僵尸。正确写法是在处理器里用 while 循环反复调用 os.waitpid(-1, os.WNOHANG),直到它返回 (0, 0)(表示没有更多已结束的子进程)或抛出 ChildProcessError(表示没有子进程了)为止。-1 表示「任意子进程」,WNOHANG 保证不阻塞(否则会卡在信号处理器里,那是极危险的)。这也是所有 Unix 守护进程的标准写法。
  • 误区:p.kill() 之后子进程就被彻底清理了。 kill()/terminate() 只是发送信号SIGKILL/SIGTERM),子进程收到后会终止——但它终止之后仍然是僵尸,因为你还没取走它的退出状态。必须再调用一次 p.wait() 才算真正回收。这个坑在超时处理的代码里特别常见:try: p.wait(timeout=30) except TimeoutExpired: p.kill() ——到这里就结束了,于是每次超时都留下一个僵尸。正确写法是 p.kill()p.wait() 一次(或者用 p.communicate(),它内部会 wait)。同理,with subprocess.Popen(...)__exit__ 也会负责 wait,所以withsubprocess.run 是最不容易出错的选择
  • 追问:为什么操作系统要保留僵尸状态,直接清理不好吗? 因为父进程往往需要知道「孩子是怎么结束的」:退出码是 0 还是非 0(成功还是失败)、是正常退出还是被信号杀死(哪个信号)、消耗了多少 CPU 时间和资源。这些信息保存在子进程的 task_struct 里,如果子进程一 exit() 就把表项彻底删除,父进程随后调用 wait() 时就无从得知任何结果——那么 subprocess.run(...).returncode、shell 的 $?make 判断编译是否成功、CI 判断测试是否通过,全都无法实现。所以内核的设计是「先留着退出状态,等父进程来取,取走后再删除」。换句话说,僵尸状态是「进程退出」这个事件的通知机制,不是 bug;只有「父进程永远不来取」才是 bug。这也解释了为什么可以用 SIGCHLD 设为 SIG_IGN 来让内核自动回收——那等于明确告诉内核「我不关心退出状态」,代价就是再也拿不到退出码
  • 追问:容器里为什么特别容易出现僵尸堆积? 因为容器里的 PID 1 通常是你的应用进程,而不是 init 系统,而内核对 PID 1 有两条特殊规则:① 所有孤儿进程都会被过继给 PID 1② PID 1 没有默认的信号处理行为。正常 Linux 系统上 PID 1 是 systemd/init,它天生就在循环 wait 回收所有过继来的孤儿;但你的 python app.py 作为 PID 1 时,完全没有这个职责意识。典型链路是:Python(PID 1)用 subprocess 启动 ffmpeg,ffmpeg 内部又 fork 了辅助进程;ffmpeg 退出后这些辅助进程变成孤儿、被过继给 Python,它们结束时 Python 从来没 wait 过——于是成为永久僵尸。长期运行的转码、爬虫、任务调度类容器里,僵尸能堆到几万个,直到撞上 cgroup 的 pids.max,此后所有 subprocess 创建失败、服务功能异常但进程还活着(健康检查甚至可能还是通过的),排查非常费时。标准解法是用 tini/dumb-init 做 PID 1docker run --init 自带 tini),它负责转发信号 + 回收僵尸两件事。
  • 追问:multiprocessing 需要担心僵尸问题吗?subprocess 省心,但仍建议显式 join()multiprocessing 内部维护了一个 _children 集合,并在每次 start() 新进程、调用 active_children() 或模块退出时顺带调用 _cleanup()——它会对已结束的子进程执行 waitpid(WNOHANG) 完成回收。所以在「不断创建新进程」的程序里,僵尸通常会被顺带清理掉。但有两种情况仍会积累:① 创建一批子进程后长时间不再创建新的、也不调用 active_children() —— 那些已结束的子进程会一直保持僵尸状态;② 用 os.fork() 手工创建的进程完全不受 multiprocessing 管理。而且显式 join() 还有两个额外好处:能拿到 p.exitcode(判断子进程是正常退出还是被信号杀死,负数表示信号)、能确认任务真的执行完了(而不是主进程先退出)。至于 PoolProcessPoolExecutor,它们自己管理 worker 的完整生命周期,用 with 或显式 shutdown() 即可,不必操心僵尸。

八、加强记忆

僵尸进程 = 子进程已退出但父进程没有 waitexit() 时内核已经释放了它的内存、文件描述符和锁,只保留 task_struct 里的退出码和 CPU 统计等父进程来取——这是机制而非 bug(父进程往往需要知道「孩子是怎么死的」,returncode$?、CI 判断成败都依赖它);父进程 wait 之后进程表项才删除、PID 才释放。孤儿进程 = 父进程先死、子进程还活着,内核会立刻把它过继给 PID 1 并由后者自动回收,所以孤儿完全无害(守护进程正是故意制造的孤儿)——孤儿 ≠ 僵尸是最常见的混淆僵尸不占内存、不占 CPU、不占 fd,唯一危害是占用 PID,累积到 pid_max/ulimit -u/cgroup 的 pids.max 就会导致整台机器都 fork 不出新进程Resource temporarily unavailable,连 ssh 都登不上)。kill -9 杀不掉僵尸(它已经死了),唯一办法是让父进程 wait,或者杀掉父进程让 PID 1 接手回收。Python 里的四条铁律:subprocess 一律用 withsubprocess.run(内部一定会 wait);kill()/terminate() 只是发信号,之后仍然必须 wait()(超时处理里最常漏);③ 装 SIGCHLD 处理器时必须用 while 循环配合 waitpid(-1, WNOHANG),因为标准信号不排队(10 个子进程同时结束可能只投递 1 次 SIGCHLD);④ 把 SIGCHLD 设成 SIG_IGN 能让内核自动回收,但代价是彻底拿不到退出码,会让 subprocess/multiprocessing 出问题。容器里的特殊坑:你的应用往往就是 PID 1,而 PID 1 既没有默认信号处理、又要负责回收所有被过继来的孤儿——docker run --inittini/dumb-init 做 PID 1 是最省事的解法(它负责转发信号 + 回收僵尸),同时设置 pids.max 让问题早暴露。最后辨析一个易混点:D 状态(不可中断睡眠)同样 kill -9 无效,但那是「活着却卡在内核 IO 里」(磁盘/NFS 故障),和僵尸的「已经死了」完全不同