2. Python多进程基础与数值任务适配

在数值计算场景中,Python 的全局解释器锁(GIL)决定了纯 Python 字节码在同一时刻只能由一个线程执行。对于 NumPy 这类会把重计算下沉到 C 层并释放 GIL 的库,多线程仍可能获得加速;但对于大量纯 Python 循环、逐元素自定义运算、蒙特卡洛模拟等任务,多线程往往无法真正并行。此时需要引入 multiprocessing 模块,用独立进程绕开 GIL,让多个 CPU 核心同时工作。

本节围绕四个核心组件展开:Process、Pool、Queue、Pipe,并说明进程的创建与生命周期,最后给出数值任务中进程与线程的选型判断。

一、四个核心组件的定位

Process 是最基础的进程封装。通过继承 multiprocessing.Process 并重写 run(),或直接传入 target 函数,可以创建一个子进程。调用 start() 后子进程真正启动,join() 等待其结束,is_alive() 查看存活状态,exitcode 判断退出结果(0 表示正常)。Process 适合任务数量少、每个任务粒度大、需要精细控制的场景。

Pool 是进程池,一次性创建固定数量的工作进程,通过 map、starmap、apply_async 等接口把任务分发给空闲进程。它省去了反复创建销毁进程的开销,是数值批量任务最常用的入口。典型写法是 Pool(processes=4).map(func, chunks),其中 chunks 是可迭代的任务参数序列。

Queue 是跨进程的先进先出队列,基于管道加锁实现,支持多生产者多消费者。它适合把大任务拆成许多小任务动态分发,或把各进程的计算结果汇总回主进程。

Pipe 提供两个进程之间的双向或单向连接,conn.send() 与 conn.recv() 成对使用,开销比 Queue 更小,但只适合点对点通信,不适合多进程共享。

multiprocessing 核心组件与进程生命周期 主进程 创建 / 分发 / 汇总 if __name__ == "__main__" Pool 进程池 Pool(processes=4) · map / apply_async Worker 1 chunk[0] Worker 2 chunk[1] Worker 3 chunk[2] Worker 4 chunk[3] Queue 多生产者 / 多消费者 put() / get() Pipe 点对点双向连接 send() / recv() 进程生命周期 创建 start() 就绪 运行 run() 结束 / 退出码 join() 回收 数值任务选型:纯 Python 重计算 → 多进程;NumPy 释放 GIL 的向量化运算 → 多线程亦可; 小任务多 → Pool + chunksize;点对点结果回传 → Pipe;动态任务分发 → Queue。

二、进程创建与生命周期

一个进程从创建到回收,大致经历“创建 → start() 就绪 → 运行 run() → 结束并产生退出码 → join() 回收”几个阶段。理解这条链路,能避免很多常见错误。

首先,Windows 与 macOS 默认使用 spawn 启动方式,子进程会重新导入主模块。因此所有进程创建代码必须放在 if __name__ == "__main__": 保护块内,否则会无限递归创建进程。Linux 默认使用 fork,虽然不强制,但为了可移植性也应统一加上保护块。

其次,子进程拥有独立的内存空间。主进程中的全局变量、大数组不会被子进程的修改所影响,参数和返回值都要经过序列化(pickle)传递。这意味着传给子进程的函数和参数必须可 pickle,lambda、局部闭包、打开的文件句柄通常不能直接传递。

再次,进程结束后必须 join() 回收,否则可能留下僵尸进程。使用 Pool 时,推荐用 with Pool(...) as p: 上下文管理器,退出时自动 close() 并 join()。若子进程抛出异常,异常不会自动传回主进程,需要通过 exitcode 或 apply_async 返回的 AsyncResult.get() 捕获。

下面是一个把大数组分块求平方和的示例,演示 Pool 的典型用法:

import multiprocessing as mp
import numpy as np

def chunk_sum(arr):
    return float(np.sum(arr ** 2))

if __name__ == "__main__":
    data = np.random.rand(4_000_000)
    chunks = np.array_split(data, 8)          # 切成 8 块
    with mp.Pool(processes=4) as pool:        # 4 个工作进程
        partials = pool.map(chunk_sum, chunks)
    total = sum(partials)
    print("平方和 =", total)

这里 np.array_split 把数据切成 8 个任务块,Pool 用 4 个进程并行处理,map 会按顺序返回每块的结果,最后在主进程求和。任务块数略多于进程数是合理的,可以让负载更均衡。需要注意,chunks 中的每个数组都要被序列化后发送给子进程,如果单块过大,传输开销会抵消并行收益,因此块大小要适中。

三、进程与线程的选型对比

选型的核心判断依据是:计算是否受 GIL 限制、任务是 CPU 密集还是 I/O 密集、数据是否需要频繁共享。

维度 多进程 multiprocessing 多线程 threading
并行能力 真正多核并行,绕开 GIL 受 GIL 限制,纯 Python 计算无法并行
适用任务 CPU 密集型:纯 Python 循环、模拟、逐元素自定义运算 I/O 密集型:文件读写、网络请求、等待型任务
内存与开销 独立内存空间,创建与通信开销较大 共享内存,创建开销小
数据共享 需序列化传递,或用共享内存、Queue、Pipe 直接共享变量,但需加锁保护
NumPy 向量化 可用,但通常单进程已足够快 底层释放 GIL,多线程也可能加速

实践中的判断可以更具体:如果一段代码主要是 Python 层的 for 循环、递归、条件分支,且单次运行时间较长,优先考虑多进程;如果瓶颈在 requests、数据库查询、磁盘读写,多线程或异步 I/O 更轻量;如果计算已经写成 NumPy 向量化形式,先评估单进程性能,再决定是否值得引入进程池。对于超大规模数组,还可以考虑 multiprocessing.shared_memory 减少复制开销。

数值任务使用进程池时,建议先用小数据量测出单进程耗时,再对比 2、4、8 个进程的耗时曲线。若加速比明显低于进程数比例,通常是序列化传输或任务粒度过细导致的,应调整分块大小而不是盲目增加进程。
不要在模块顶层直接创建 Pool 或 Process;不要向子进程传递不可 pickle 的对象;不要忘记 join 或使用上下文管理器。这三点是数值并行脚本最常见的失败原因。