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 更小,但只适合点对点通信,不适合多进程共享。
二、进程创建与生命周期
一个进程从创建到回收,大致经历“创建 → 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 减少复制开销。