Python 多进程 Hang 问题复盘与排查指南
2026/7/2 17:40:08
网站开发
Python 多进程 fork 死锁排查与修复实录一、问题背景在一个基于 OR-Tools CP-SAT 的车辆调度优化系统中批量执行求解任务时程序在拼车求解阶段出现 hang主进程不退出CPU 0%日志停在某一行之后无新输出py-spy dump无法看到任何线程该问题在生产环境K8s 容器中也有概率出现表现为任务超时无响应。二、定位过程2.1 初始现象日志最后停在某个子进程的数据处理函数中打印了一些数据结构后之后无任何输出。2.2 关键排查命令# 查看卡死子进程的内核栈cat/proc/pid/stack# 输出: futex_wait_queue_me → futex_wait → do_futex → sys_futex# 确认等待通道cat/proc/pid/wchan# 输出: futex_wait_queue_me# 确认进程状态和线程数cat/proc/pid/status|grep-i-EState|PPid|Threads# State: S (sleeping)# PPid: parent_pid# Threads: 1# 确认只有一个线程ls/proc/pid/task/# 只有一个目录主线程自身# 确认是 fork 出来的cmdline 与父进程相同cat/proc/pid/cmdline|tr\0 # python my_batch_script.py2.3 决定性证据证据含义Threads: 1子进程里只有 1 个线程futex_wait该唯一线程在等一把锁无其他线程能释放锁锁来自 fork 继承持锁线程在子进程里不存在cmdline 与父进程相同确认是 fork不是 spawn一个只有 1 个线程的进程却卡在等一把锁上——这把锁永远不会被释放。这是 fork 继承锁死锁的铁证。三、原理分析3.1 fork 的语义Linux 的fork()系统调用只复制调用 fork 的那个线程到子进程所有锁的状态原样复制包括被其他线程持有的锁那些持锁的线程在子进程里不存在了3.2 死锁形成过程时序 1. 父进程有多个线程主线程、logging handler 线程、后台通知线程、C 求解器线程等 2. 某一瞬间线程 T 正持有 logging 的 RotatingFileHandler 锁 3. 主线程此时调用 fork() 创建子进程ProcessPoolExecutor 默认用 fork 4. 子进程 - 只有 1 个线程主线程的副本 - 锁状态 已被 T 持有原样复制 - 但线程 T 不存在了 → 锁永远不会被释放 5. 子进程主线程尝试 log.info() → 需要获取该锁 → 永久阻塞在 futex3.3 为什么不是每次都 hangfork 时是否恰好有线程持锁是时序竞争race condition。大多数时候锁空闲fork 成功偶尔撞上持锁瞬间就死锁。这解释了有时正常、有时 hang的随机性。3.4 为什么 py-spy 看不到线程fork 后子进程的 GIL / 线程状态可能处于不一致状态Python 解释器预期多线程环境但实际只剩一个线程py-spy 无法正确枚举线程栈。四、涉及的代码模式以下是项目中使用多进程的三种写法全部默认使用 fork# 模式 1: concurrent.futures.ProcessPoolExecutorwithconcurrent.futures.ProcessPoolExecutor(max_workers10)asexecutor:resultslist(executor.map(worker_func,task_list))# 模式 2: multiprocessing.Process 手动管理pmultiprocessing.Process(targetworker_func,args(...))p.start()active_processes.append(p)# ... while True 轮询 is_alive() ...# 模式 3: multiprocessing.Process joinprocesses[mp.Process(targetfunc,args(...))for...]forpinprocesses:p.start()forpinprocesses:p.join()五、解决方案方案改用 spawn 启动方式spawn会为每个 worker 全新启动一个 Python 解释器不继承父进程的任何锁和线程状态从根本上消除 fork 死锁。修改方式模式 1ProcessPoolExecutorimportmultiprocessingasmp _spawn_ctxmp.get_context(spawn)withconcurrent.futures.ProcessPoolExecutor(max_workers10,mp_context_spawn_ctx)asexecutor:resultslist(executor.map(worker_func,task_list))模式 2 3multiprocessing.Processpmultiprocessing.get_context(spawn).Process(targetworker_func,args(...))p.start()spawn 的前提条件条件说明worker target 是模块级函数spawn 子进程通过 import 找到 target不能是 lambda/闭包/局部函数参数可 picklespawn 通过管道传参参数必须能序列化入口脚本有if __name__ __main__保护避免 spawn 重新 import 主模块时递归启动spawn 的代价启动比 fork 慢几秒每个 worker 重新 import 所有模块参数通过 pickle 传输大对象有序列化开销内存不共享每个 worker 独立完整内存比 fork 的 copy-on-write 占用更多对计算密集型任务如 CP-SAT 求解每次几十秒到几分钟可以接受六、验证方法6.1 确认 spawn 生效运行时检查 worker 进程的 cmdlinecat/proc/worker_pid/cmdline|tr\0 forkcmdline 与父进程相同python xxx.pyspawncmdline 为python -c from multiprocessing.spawn import spawn_main; spawn_main(...)6.2 确认不再 hang批量跑 10 任务观察无进程卡死全部正常退出无defunct僵尸进程残留CPU 正常波动求解时高空闲时低不会出现 worker 0% 但不退出6.3 ps 查看进程树ps-opid,ppid,stat,wchan,cmd--forest-u$USER|greppython正常情况下 worker 完成后消失不应有长时间 S 状态 futex 等待的子进程。七、附带发现类属性共享可变对象问题classDataProcessor:task_list[]# ← 类属性所有实例共享同一个 listdef__init__(self,data):# self.task_list 没有被实例属性遮蔽self.task_list.append(data)# 实际修改的是类级别那个 list在同进程多次实例化时如批量测试循环数据会跨实例累积第 1 次task_list [data1]第 2 次task_list [data1, data2]第 N 次task_list [data1, …, dataN]导致任务数虚高、耗时膨胀、结果错误。修复classDataProcessor:def__init__(self,data):self.task_list[]# ← 实例属性每次独立self.task_list.append(data)八、经验总结Python 多进程在 Linux 上默认用 fork——在有多线程logging、后台通知、C 扩展库线程的程序中fork 是不安全的。fork 死锁是概率性的不是必现——取决于 fork 瞬间是否有线程持锁通过常规功能测试几乎无法发现只有在高并发/批量场景才暴露。py-spy / strace 对 post-fork 损坏的进程可能无效——最可靠的诊断手段是cat/proc/pid/stack# 看内核栈futex 锁等待cat/proc/pid/wchan# 一个字段确认等待类型cat/proc/pid/status# Threads: 1 铁证spawn 是银弹——彻底消除 fork 继承锁的问题。代价是启动慢一点和内存多用一些对计算密集型任务可以接受。类属性共享可变对象如list、dict是经典反模式——在同进程多次实例化场景下造成状态泄漏应改为实例属性。参考Python 官方文档: multiprocessing — Process-based parallelismIssue: fork() in multi-threaded programsPython logging is not fork-safe