Python 中使用多进程编程的“三两”问题

文章目录

一、简介

这里简单介绍在Python 中使用多进程编程的时候容易遇到的情况和解决办法,有助于排查和规避某类问题,但是具体问题还是需要具体分析,后续会补充更多的内容。

二、选择合适的启动方式

  1. Python多进程编程 非常需要注意启动方式,有些库会在fork模式下出现很诡异的阻塞或显存泄漏。如果需要跨平台使用,优先选择spawn方式,如果涉及到 GPU、PyTorch、OpenCV 视频流、TensorRT、OpenVINO ,同样优先选择spawn方式,避免继承GPU上下文。有的时候会存在 多线程多进程 混用的情况,可以选择spawnforkserver这两种方式。 spawn启动方式通常更安全、更稳定。

    启动方式 平台默认 机制 优点 缺点 适用场景
    fork Linux / macOS 子进程直接复制父进程的内存空间(写时复制),从 fork() 返回处继续执行 创建速度快,占用内存少 继承父进程线程状态可能死锁,继承 GPU/文件句柄可能出错,不支持 Windows 纯计算、多进程间状态共享简单、无 GPU/大文件句柄的场景
    spawn Windows 启动全新的 Python 解释器进程,从头执行 __main__ 模块的代码 跨平台一致,状态干净,不继承父进程线程/句柄/GPU context 启动慢,初始化开销大,需要 if __name__ == "__main__": GPU 深度学习、跨平台项目、多线程+多进程混合、需要隔离状态的场景
    forkserver Linux / macOS 先启动一个 "fork server" 进程,之后所有子进程由它 fork 出来 避免从主进程直接 fork(减少死锁风险),比 spawn 快 只在类 Unix 系统可用,需额外启动 server,复杂度高 多线程+多进程并存、需要避免主进程 fork 的高并发服务
  2. 可以通过调用multiprocessing.set_start_method('spawn')来指定子进程的启动方式,通常写在if __name__ == "__main__":里,force=True参数可以强制重置启动方式(但通常不建议随便用,除非确定没别的进程创建了子进程)。

    python 复制代码
    import multiprocessing as mp
    
    def worker(name):
        print(f"Worker {name} running...")
    
    if __name__ == "__main__":
    	# 强制统一 spawn
        mp.set_start_method("spawn", force=True)  # "fork" 或 "forkserver" 
        
        processes = []
        for i in range(3):
            p = mp.Process(target=worker, args=(i,))
            p.start()
            processes.append(p)
        for p in processes:
            p.join()
  3. 使用 PyAV 调用h264_qsv(Intel Quick Sync Video)硬件加速解码时,也会受多进程启动方式的影响,QSV 是基于英特尔硬件加速的编码解码技术,需要在进程中正确初始化硬件上下文。如果使用fork,子进程会复制父进程的内存空间,但硬件设备上下文(如 QSV 的硬件句柄、驱动状态等)通常不能被正确继承或共享,导致解码失败、死锁或崩溃。使用spawn,子进程是全新启动,独立初始化硬件上下文,能避免因上下文继承带来的问题,提高稳定性。

    多进程启动方式 对 PyAV + h264_qsv 硬解码的影响 推荐做法
    fork 可能导致硬件上下文冲突,解码异常 不推荐
    spawn 子进程独立初始化硬件上下文,稳定性高 推荐,尤其多进程并发的时候
  4. 在使用fork启动多进程的时候,部分常用的 计算机视觉 (CV)和 点云处理 相关库可能会出现问题,任何涉及 GPU上下文、线程池或多线程加速的库,在fork多进程启动方式下都可能出现资源继承异常、死锁、崩溃等问题。点云库中,Open3DPCL 较为复杂,fork时要特别注意。

    库/模块 说明与常见问题
    OpenCV (cv2) GPU模式(CUDA)在fork后可能资源异常或崩溃,多线程环境下死锁;CPU模式一般安全,但多线程仍需注意。
    Open3D 内部使用线程池和GPU(部分功能),fork启动时可能导致线程池状态异常或GPU资源不可用。
    PCL / python-pcl 底层多线程和GPU支持(部分平台),fork后可能出现线程死锁或资源冲突,尤其在GPU加速时。
    PyTorch fork后CUDA上下文继承出错,导致报错、卡死;多线程资源也可能异常。
    TensorFlow fork后session、资源无法正常初始化,多线程管理导致崩溃。
    OpenVINO 线程池和设备初始化受fork影响,可能导致设备资源冲突或初始化失败。
    TensorRT GPU上下文和优化缓存fork后不稳定,可能导致初始化失败或运行错误。
    matplotlib GUI线程在fork后可能卡死或异常。

三、手动终止所有的进程

  1. 多进程中 Ctrl+CSIGINT 信号)后程序没有全部退出,最常见原因就是信号没有传递给子进程,或者子进程没能响应退出请求。fork导致资源继承异常也经常是根源。列出当前系统中所有正在运行的 Python 进程及其详细信息。

    bash 复制代码
    ps aux | grep python
  2. 在多进程场景下,torch.utils.data.DataLoader是常见的导致进程无法退出的元凶之一,尤其是在num_workers > 0时。有的情况默认不监听结束信号,有的情况收不到结束信号。显式处理可能比较麻烦。

  3. 一个比较安全的写法是在主进程 捕获SIGINT,然后主动杀掉自己启动的所有子进程。直接一次性结束整个进程树。

    python 复制代码
    import multiprocessing as mp
    import signal
    import sys
    import os
    import psutil
    
    
    def kill_all_children(timeout=3):
        """
        	终止当前进程的所有子进程(递归),先尝试终止,再强制杀死超时未结束的子进程
        """
        proc = psutil.Process(os.getpid())
        children = proc.children(recursive=True)
    
        # 尝试终止
        for child in children:
            try:
                child.terminate()
            except Exception as e:
                print(f"[警告] 无法终止 PID={child.pid}: {e}", file=sys.stderr)
    
        # 等待超时,获取仍然存活的子进程
        gone, alive = psutil.wait_procs(children, timeout=timeout)
    
        # 强制杀死仍然存活的进程
        for child in alive:
            try:
                child.kill()
            except Exception as e:
                print(f"[错误] 无法强制终止 PID={child.pid}: {e}", file=sys.stderr)
                
    def signal_handler(sig, frame):   # 回调函数
        print(f"\n[退出] 捕获信号 {sig},终止子进程并退出...")
        try:
            kill_all_children()
        except Exception as e:
            print(f"[错误] 终止子进程时出错: {e}", file=sys.stderr)
        finally:
            sys.exit(130 if sig == signal.SIGINT else 143)  
            # 130 = Ctrl+C 退出, 143 = SIGTERM 退出
            
    def main(args):
        # 启动多个进程的示例
        processes = []
        for _ in range(4):
            p = mp.Process(target=worker_function)
            p.start()
            processes.append(p)
    
        for p in processes:
            p.join()
    
    if __name__ == "__main__":
        signal.signal(signal.SIGINT, signal_handler)   # 注册信号和回调函数
        signal.signal(signal.SIGTERM, signal_handler)
    
        mp.set_start_method('spawn', force=True)
        args = parse_args()
        main(args)
  4. 或者更简单直接的立即杀掉所有进程,终端更干净。可以根据实际情况考虑如何使用。

    python 复制代码
    import multiprocessing as mp
    import signal
    import sys
    import os
    import psutil
    
    def kill_all_processes():
        proc = psutil.Process(os.getpid())
        for child in proc.children(recursive=True):
            child.kill()
        proc.kill()
    
    def signal_handler(sig, frame):
        print("\n[退出] 捕获 Ctrl+C,终止所有进程...")
        kill_all_processes()
        sys.exit(0)
    
    def main(args):
        # 启动多个进程的示例
        processes = []
        for _ in range(4):
            p = mp.Process(target=worker_function)
            p.start()
            processes.append(p)
    
        for p in processes:
            p.join()
    
    if __name__ == "__main__":
        signal.signal(signal.SIGINT, signal_handler)
        signal.signal(signal.SIGTERM, signal_handler)
    
        mp.set_start_method('spawn', force=True)
        args = parse_args()
        main(args)

小结

以上内容来自相关资料和个人实践,具体情况还请具体分析,可以参考这里提到的几个关键点,有助于排查问题,如有问题欢迎在评论区指正,谢谢!!