一、从一次线上事故说起:先厘清两个被混用的概念
那天要跑一批网页抓取,几万条,单线程要两三个小时,我顺手用 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() 所有孤儿:
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:
2.3 SIGCHLD 与 join 的本质
Process.join() 底层是 waitpid,阻塞等子进程终止。若父进程把 SIGCHLD 设成默认且被信号打断(如 Ctrl+C 触发 EINTR),wait 可能失败、join 提前返回,子进程变僵尸。稳妥做法是用 ProcessPoolExecutor 的上下文管理(内部已处理好 shutdown/join),或裸 Pool 严格 close()→join()。
三、子进程"退不出"的五大工程根因
- 网络调用无超时,代理半开连接(最常见)。 爬虫给 requests 配代理却不设 timeout,代理链路半开(连接建立但不回数据)就永久挂起。结合亿牛云动态短效代理场景:它提供按量计费的 HTTP/HTTPS 出口 IP,适合轮换规避风控,但代理不可用必须当作正常错误分支,而非默认永远可用:
- fork 后多线程死锁(持锁 fork),见 2.2。
- 子进程 stdout 写满管道。 multiprocessing 把子进程 stdout 经 pipe 重定向父进程;疯狂 print 而父未读,pipe buffer 满后子进程 write() 阻塞。排查时 /proc//wchan 常显示 pipe_write。改用 logging+QueueHandler 或重定向 devnull。
- 共享 fd 泄漏:数据库/文件/代理隧道未 close(),fork 继承进一步放大;/proc//fd 数量只增不减即为征兆。
- 信号模型错误:主进程收 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 --native 看 C 栈,定位代理/C 扩展阻塞。strace -p -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 强制回收,杜绝无限等待
优雅退出:注册 SIGTERM handler,置 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,僵尸与泄漏再没回来过。