问题背景
公司有一个图像处理服务,Python 写的,对外提供缩略图、水印和格式转换接口。请求进来后,耗时操作交给 multiprocessing.Pool 里的 8 个子进程去做,主进程负责收发请求和返回结果。这套结构在 Python 3.8 上跑了两年,一直稳定。
上个月做两件事:一是把运行时升级到 Python 3.11,二是接入了 Prometheus,在主进程里起了一个后台线程,每 15 秒推送一次指标。上线后压测正常,但运行一周后开始出现“任务提交后永远不返回”的情况:接口超时,进程池里的子进程还活着,却不再处理任何任务,只能重启服务恢复。
故障现象
- 偶发,通常发生在流量较高、后台线程频繁推送指标的时候。
- 出问题时接口大量超时,
Pool.apply_async提交的任务没有结果,回调也不触发。 - 主进程正常,子进程也都还在,
ps看状态是S,CPU 接近零。 - 子进程的日志停在“worker started”之后再无输出,连任务开始的日志都没有。
- 重启服务立即恢复,但过几天又会出现;压测时如果只跑进程池、不开后台线程,几乎复现不了。
- 故障率随并发上升,8 个 worker 可能同时卡住,也可能只卡一两个。
排查过程
第一步确认卡在哪里。用 strace -f -p <子进程> 跟了一会儿,发现它停在 futex(0x..., FUTEX_WAIT_PRIVATE, 2, NULL),没有任何读写系统调用。这说明子进程在等一把用户态锁,不是卡在计算,也不是在等 IO。
第二步看 Python 栈。子进程里提前注册了 faulthandler.register(signal.SIGUSR1),给它发信号后拿到所有线程的栈:主线程停在 logging 模块内部,具体位置是获取日志锁的地方。也就是说,子进程启动后第一件事是打日志,而日志锁已经被别人占着,并且永远不会释放。
第三步查锁的归属。父进程里除了主线程,还有一个 Prometheus 推送线程,它也会写日志。fork 只复制调用 fork 的那个线程,其他线程在子进程里直接消失,但它们当时持有的锁会被原样复制过去——锁的状态是“已加锁”,而持有它的线程已经不存在了。子进程一旦去拿这把锁,就永远等不到释放。
第四步做最小复现。写一个二十行的脚本:主线程起一个后台线程,后台线程拿到一把锁后休眠;主线程 os.fork(),子进程尝试拿同一把锁。子进程必然卡死。把锁换成 logging 的锁,现象完全一致:子进程第一条日志就打不出来。概率低是因为 fork 必须恰好发生在另一个线程持有日志锁的瞬间,高并发下这个窗口被不断撞到。
第五步对照版本与代码。Python 3.12 起,多线程进程里调用 fork 会给出 DeprecationWarning;3.14 更把 Linux 上的默认启动方式改成了 forkserver。但这套服务跑在 3.11,没有任何提示,代码里也没显式指定 start method,默认就是 fork。升级前没有这个后台线程,所以问题一直潜伏着;加了线程之后,窗口才被打开。
第六步验证修复。把进程池改成 multiprocessing.get_context("spawn").Pool(8),spawn 会启动全新的解释器、重新 import 模块,不继承父进程的锁;同时在入口加上 if __name__ == "__main__" 保护,确保子进程不会重复执行启动逻辑。改完在“8 线程压测 + 后台线程持续写日志”的条件下跑了两万次任务,没有再出现卡死,代价是进程池启动慢了约 1 秒,对长驻服务可以接受。
解决方案
- 显式指定 spawn 或 forkserver:
ctx = multiprocessing.get_context("spawn"),用ctx.Pool()创建进程池;对启动速度敏感的场景可以用forkserver,它从一个干净的单线程服务进程 fork,既有安全性又比 spawn 快。 - 保证 fork 发生在所有线程之前:如果暂时不能改启动方式,至少把进程池的创建放到最前面,等池子建好再启动后台线程。顺序反过来,就等于主动制造锁继承的风险。
- 补上入口保护:spawn/forkserver 会重新 import 主模块,必须用
if __name__ == "__main__":包住启动逻辑,否则子进程会递归创建进程池。 - 检查参数可序列化:spawn 通过 pickle 传参,lambda、打开的文件句柄、数据库连接都不能直接传;把任务参数收敛成简单类型,或在子进程内重新建立连接。
- 把警告变成错误:测试环境用
python -W error::DeprecationWarning跑一遍,Python 3.12+ 会把“多线程进程 fork”直接暴露成失败,而不是等到线上偶发。 - 压测覆盖组合场景:不要只压进程池或只压线程,要按真实形态同时开后台线程和进程池,并持续足够长的时间,让 fork 撞上锁的概率显现出来。
根因分析
直接根因是 在多线程父进程里调用 fork 创建进程池;fork 只复制当前线程,其他线程持有的锁在子进程里保持“已加锁”状态,子进程第一次写日志时等待一把永远无人释放的锁,启动即死锁。
更深一层,是把进程和线程当成了可以随意混用的两种并发工具。fork 的语义是“复制当前执行流”,不是“复制整个进程的所有线程”;锁是状态,不是代码,复制过去的是一把打不开的锁。Python 长期把 fork 作为 Linux 默认启动方式,让这个陷阱隐藏得很好,直到多线程、日志、高并发三个条件同时满足才暴露。
还有一层是升级与变更的验证盲区。升级 Python、增加后台线程,两件事单独看都没问题,压测也过了;但压测没有覆盖“后台线程正在写日志时 fork”的时间窗口,功能测试更不会去构造这种竞态。问题不是没测,而是测的形态和线上不一致。
预防措施
- 服务里有线程时,进程池一律使用 spawn 或 forkserver,禁止默认 fork;在代码规范里写成硬性条款。
- 进程池必须在任何线程启动之前创建;启动顺序写进模块注释,代码评审时检查。
- CI 用
-W error::DeprecationWarning跑测试,Python 3.12+ 能提前拦住多线程 fork。 - 压测脚本固定包含“后台线程 + 进程池”组合,并统计任务排队时间、worker 重启次数,而不只看 QPS。
- 给进程池加可观测性:任务从提交到完成的耗时分布、活跃 worker 数、卡住超过阈值的任务数,出现异常先告警再自动重建池。
总结
fork 复制的是“当前这一个线程”,不是整个进程;父进程里别的线程连同它们手里的锁,在子进程里只剩下一个上锁的状态。多线程和进程池放在一起时,真正危险的不是代码写错,而是运行时默认值替你选了一个不安全的组合。把启动方式显式写出来,把创建顺序固定下来,把警告当错误对待,这类偶发死锁才会从“过几天挂一次”变成“根本跑不起来”。