什么是僵尸进程和孤儿进程?Python 里怎么避免?
简化版
僵尸进程(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.Process 要 join()(不 join 也可以,它内部会在创建新进程时顺带清理已结束的)。容器里有个特殊坑:你的应用作为 PID 1 时,没有默认的 SIGCHLD 处理,不会自动回收被过继来的孤儿——所以推荐用 tini(docker 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 语句(最推荐,退出时自动 wait)、subprocess.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 1(docker 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 特有的补充:multiprocessing 的 daemon=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 一律用 with 或 subprocess.run;用 Popen 时确保所有路径(含异常路径)都会 wait;kill/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,连ssh和ps都执行不了,往往只能重启。 - 误区:
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,所以用with或subprocess.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 1(docker run --init自带 tini),它负责转发信号 + 回收僵尸两件事。 - 追问:
multiprocessing需要担心僵尸问题吗? 比subprocess省心,但仍建议显式join()。multiprocessing内部维护了一个_children集合,并在每次start()新进程、调用active_children()或模块退出时顺带调用_cleanup()——它会对已结束的子进程执行waitpid(WNOHANG)完成回收。所以在「不断创建新进程」的程序里,僵尸通常会被顺带清理掉。但有两种情况仍会积累:① 创建一批子进程后长时间不再创建新的、也不调用active_children()—— 那些已结束的子进程会一直保持僵尸状态;② 用os.fork()手工创建的进程完全不受multiprocessing管理。而且显式join()还有两个额外好处:能拿到p.exitcode(判断子进程是正常退出还是被信号杀死,负数表示信号)、能确认任务真的执行完了(而不是主进程先退出)。至于Pool和ProcessPoolExecutor,它们自己管理 worker 的完整生命周期,用with或显式shutdown()即可,不必操心僵尸。
八、加强记忆
僵尸进程 = 子进程已退出但父进程没有 wait:exit() 时内核已经释放了它的内存、文件描述符和锁,只保留 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 一律用 with 或 subprocess.run(内部一定会 wait);② kill()/terminate() 只是发信号,之后仍然必须 wait()(超时处理里最常漏);③ 装 SIGCHLD 处理器时必须用 while 循环配合 waitpid(-1, WNOHANG),因为标准信号不排队(10 个子进程同时结束可能只投递 1 次 SIGCHLD);④ 把 SIGCHLD 设成 SIG_IGN 能让内核自动回收,但代价是彻底拿不到退出码,会让 subprocess/multiprocessing 出问题。容器里的特殊坑:你的应用往往就是 PID 1,而 PID 1 既没有默认信号处理、又要负责回收所有被过继来的孤儿——用 docker run --init 或 tini/dumb-init 做 PID 1 是最省事的解法(它负责转发信号 + 回收僵尸),同时设置 pids.max 让问题早暴露。最后辨析一个易混点:D 状态(不可中断睡眠)同样 kill -9 无效,但那是「活着却卡在内核 IO 里」(磁盘/NFS 故障),和僵尸的「已经死了」完全不同。