一、从一次线上事故说起:先厘清两个被混用的概念
那天要跑一批网页抓取,几万条,单线程要两三个小时,我顺手用 multiprocessing.Pool 开了 8 个进程并行。本地抽样 200 条没问题,上线后主进程很快打印"完成",可 top 里仍有进程滞留:部分是 Z(zombie),部分是 D(不可中断睡眠),内存从 1.2G 悄悄爬到 3.5G,文件句柄数逼近 ulimit -n。
很多人把"僵尸进程"和"资源泄漏"当成一回事,其实它们是两种独立故障,处置路径完全不同:
僵尸进程(zombie):子进程已
exit(),父进程尚未waitpid()回收,内核保留其task_struct(退出码、资源账目),占一个 PID 槽位。它不占 RSS、不占 CPU,危害是耗尽 PID 空间(默认上限通常几万)引发系统级故障。资源泄漏(leak):子进程根本没退出,持有的内存、文件描述符、数据库连接、代理隧道句柄一直存活,缓慢拖垮机器。
D/S态长期挂着多属此类。
排查第一步,先用 ps/pstree 判断眼前是"死透没收尸"还是"还活着赖着",再对症下药。
二、机制层:为什么回收会失败、为什么退出会被卡住
2.1 Linux 进程回收的底层契约
子进程 exit() 后进入僵尸态,等待父进程 waitpid()。内核只为僵尸保留极小的 task_struct,不占物理内存。父进程调用 waitpid(Python Process.join() 的底层)读取退出状态后,PID 才释放。
孤儿进程(父先死)被 PID 1 收养。关键陷阱在容器:若容器以 python main.py 直接启动,这个 Python 进程就是 PID 1,但它不会 wait() 自己派生的子进程——它不是真正的 init。于是子进程变僵尸后永久滞留,直到容器销毁。这是生产环境"僵尸清不掉"的头号真因。解法是用 tini 或 dumb-init 作为 PID 1,由它正确 wait() 所有孤儿:
# Dockerfile:用 tini 作 PID 1,自动回收僵尸孤儿进程 ENTRYPOINT ["/usr/bin/tini", "--", "python", "main.py"]
2.2 start method:fork 与 spawn 决定了"继承了什么"
multiprocessing 的启动方式直接决定子进程拿到什么资源,这也是多数泄漏的源头:
fork(Linux 默认):子进程继承父进程全部 fd——数据库连接、监听 socket、亿牛云代理隧道句柄统统继承。若父持有而子未显式关闭,fd 泄漏被放大;更严重的是fork只复制调用线程,其他线程凭空消失——若那些线程持有锁(logging、malloc 内部锁等),子进程再碰该锁即永久死锁。这是多进程 + 多线程混用时最隐蔽的卡死来源。spawn(Windows 默认、macOS 新版默认):重新 import 主模块、重建解释器与资源,不继承 fd,安全但启动慢,且子进程入参须可 pickle。
工程建议:IO/代理密集、或与线程混用时,用 multiprocessing.get_context("spawn"),或全局 set_start_method("spawn");若必须用 fork,用 os.register_at_fork(after_in_child=...) 在子进程里关闭无关 fd:
import os def _close_inherited_fds(): # 保留 0/1/2,关闭其余从父进程继承来的 fd,杜绝泄漏与死锁 import resource soft, _ = resource.getrlimit(resource.RLIMIT_NOFILE) for fd in range(3, soft): try: os.close(fd) except OSError: pass os.register_at_fork(after_in_child=_close_inherited_fds)
2.3 SIGCHLD 与 join 的本质
Process.join() 底层是 waitpid,阻塞等子进程终止。若父进程把 SIGCHLD 设成默认且被信号打断(如 Ctrl+C 触发 EINTR),wait 可能失败、join 提前返回,子进程变僵尸。稳妥做法是用 ProcessPoolExecutor 的上下文管理(内部已处理好 shutdown/join),或裸 Pool 严格 close()→join()。
三、子进程"退不出"的五大工程根因
1. 网络调用无超时,代理半开连接(最常见)。 爬虫给 requests 配代理却不设 timeout,代理链路半开(连接建立但不回数据)就永久挂起。结合亿牛云动态短效代理场景:它提供按量计费的 HTTP/HTTPS 出口 IP,适合轮换规避风控,但代理不可用必须当作正常错误分支,而非默认永远可用:
import requests
from requests.adapters import HTTPAdapter
def fetch(url, proxy):
s = requests.Session()
s.mount("https://", HTTPAdapter(pool_connections=10, pool_maxsize=10))
try:
# (连接超时, 读取超时),单位秒;按亿牛云 IP 存活周期设略宽松的读取超时
return s.get(url, proxies={"http": proxy, "https": proxy}, timeout=(5, 20)).text
except requests.RequestException:
return None # 快速失败,绝不阻塞2. fork 后多线程死锁(持锁 fork),见 2.2。
3. 子进程 stdout 写满管道。 multiprocessing 把子进程 stdout 经 pipe 重定向父进程;疯狂 print 而父未读,pipe buffer 满后子进程 write() 阻塞。排查时 /proc/<pid>/wchan 常显示 pipe_write。改用 logging+QueueHandler 或重定向 devnull。
4. 共享 fd 泄漏:数据库/文件/代理隧道未 close(),fork 继承进一步放大;/proc/<pid>/fd 数量只增不减即为征兆。
5. 信号模型错误:主进程收 SIGINT/SIGTERM 退出,子进程陷在 C 扩展(加密/解压库)阻塞,收不到也不退出。
四、排查工具箱:从外壳到内核
ps -eo pid,ppid,stat,etime,cmd | grep python pstree -p <父PID> # 看清进程关系 cat /proc/<pid>/status # State / FDSize / VmRSS 判断是否泄漏 cat /proc/<pid>/wchan # 内核态等什么(pipe_write? futex?) ls /proc/<pid>/fd | wc -l # fd 数异常增长 = 泄漏 lsof -p <pid> # 打开的 socket/文件明细
Python 层:py-spy dump --pid <pid> 看 Python 栈;py-spy dump --pid <pid> --native 看 C 栈,定位代理/C 扩展阻塞。strace -p <pid> -e trace=network,futex 实时看系统调用——死锁表现为反复 futex(..., FUTEX_WAIT),网络挂起表现为 recvfrom 不返回。faulthandler.dump_traceback_later(60, exit=True) 给卡死装"黑匣子"。编程式监控用 psutil:
import psutil parent = psutil.Process() for child in parent.children(recursive=True): # num_fds / memory_info().rss 持续上升即泄漏,可接入监控告警 print(child.pid, child.num_fds(), child.memory_info().rss)
五、根治:工程化最佳实践清单
网络层:所有请求带
(connect, read)双超时;连接池复用;指数退避重试;代理做健康检查与熔断(circuit breaker)。亿牛云代理按 IP 存活周期配超时,失败即快速返回。启动方式:IO/代理或线程混用场景用
spawn上下文,规避 fd 继承与 fork 死锁。fd 卫生:
os.register_at_fork(after_in_child=...)关闭无关 fd;resource.setrlimit(resource.RLIMIT_NOFILE, ...)设上限,超限即崩而非拖垮整机。进程池:优先
ProcessPoolExecutor上下文;裸Pool必须close()→join();join(timeout)超时则kill()(SIGKILL)兜底:
p.join(timeout=30) if p.is_alive(): p.kill(); p.join() # SIGKILL 强制回收,杜绝无限等待
优雅退出:注册
SIGTERMhandler,置multiprocessing.Event,工作循环协作退出;C 扩展阻塞用process.kill()强杀。资源
with化 +atexit兜底清理;少print,用logging。容器:以
tini/dumb-init作 PID 1 回收孤儿僵尸;stop_grace_period/stopwaitsecs超时转 SIGKILL。可观测性:
psutil周期采集子进程数、fd、RSS,超阈值告警,把排查从"事后救火"变成"事前预警"。
六、复盘
子进程不退出,根子在任务失控而非"进程没死"。僵尸是回收契约未履行(父未 wait、容器无真 init),泄漏是子进程压根未退出(超时缺失、fork 死锁、fd 未关、信号错配)。排查靠 pstree+/proc+py-spy+strace,根治靠"超时 + spawn 上下文 + fd 卫生 + 优雅退出 + 容器 init + 可观测"的组合拳。那次事故后,代理请求全部加超时与熔断、进程池统一 with、容器接入 tini,僵尸与泄漏再没回来过。







待会儿见
K哥馆
mayun
文鼎_应老师
课课家运营团队
liangchsh
启程软考
