本节目标:量化多进程的三笔开销(启动、序列化、IPC),掌握 shared_memory 的零拷贝用法,并能在进程池与线程池之间做出有数据支撑的选择。
适用版本:Python 3.12+(实测 3.14.6)
5.3 多进程、共享内存与 IPC 选型
既然 GIL 把 CPU 密集多线程压成 1.0x(见 5.1),多进程就是今天的多核主力。但「换个 ProcessPoolExecutor」并不免费——进程启动、参数序列化、结果回传每一步都有代价。这一节用本机实测把这三笔开销逐一量化。所有测试在本机 10 核(os.cpu_count() = 10)上完成。
5.3.1 启动方式:fork / spawn / forkserver 实测
multiprocessing 有三种启动方式,本机(macOS)默认是 spawn:
import multiprocessing as mp
print(mp.get_start_method()) # spawn
print(mp.get_all_start_methods()) # ['spawn', 'fork', 'forkserver']
启动 20 个空进程,平均每个进程的启动耗时:
import multiprocessing as mp, time, os
def child():
return os.getpid()
def measure(method, n=20):
ctx = mp.get_context(method)
t0 = time.perf_counter()
ps = [ctx.Process(target=child) for _ in range(n)]
for p in ps: p.start()
for p in ps: p.join()
return (time.perf_counter() - t0) / n
for m in ("fork", "spawn", "forkserver"):
print(f"{m:10s}: {measure(m)*1000:.2f} ms/process")
fork : 6.84 ms/process (n=20)
spawn : 42.63 ms/process (n=20)
forkserver: 10.76 ms/process (n=20)
三者的差别来自子进程如何获得解释器状态:
| 方式 | 机制 | 优点 | 缺点 |
|---|---|---|---|
fork | 复制父进程内存(COW) | 启动最快,能直接继承父进程对象 | 与多线程混用不安全;子进程继承锁状态可能死锁 |
spawn | 全新解释器 + 重新 import | 干净、可跨平台 | 启动最慢(要重新导入依赖) |
forkserver | 先起一个干净的服务进程,再从它 fork | 兼顾干净与速度 | 首次启动有一次性开销 |
spawn 比 fork 慢约 6 倍(42.63ms vs 6.84ms),因为子进程要重新执行 import——这就是为什么 5.3.5 里 spawn 进程池会明显更慢。macOS 从 Python 3.8 起默认改用 spawn,正是因为 fork 与系统框架、多线程程序混用会崩溃。3.12+ 在多线程进程里调用 fork 还会发出 DeprecationWarning,未来默认行为会进一步收紧。
5.3.2 跨进程要付的税:pickle
进程之间不共享内存,传对象必须序列化。multiprocessing 的默认序列化器是 pickle。把「pickle 往返」和「裸内存拷贝」对比,代价一目了然:
import pickle, time, numpy as np
for n in (10_000, 1_000_000, 10_000_000):
a = np.arange(n, dtype=np.float64)
t0 = time.perf_counter(); pickle.loads(pickle.dumps(a, -1)); d1 = time.perf_counter()-t0
t0 = time.perf_counter(); b = np.empty_like(a); b[:] = a[:]; d2 = time.perf_counter()-t0
print(f"n={n:>10,} bytes={a.nbytes/1e6:6.1f}MB pickle={d1*1000:8.2f}ms memcpy={d2*1000:7.2f}ms ratio={d1/d2:5.1f}x")
n= 10,000 bytes= 0.1MB pickle= 0.76ms memcpy= 0.02ms ratio=45.4x
n= 1,000,000 bytes= 8.0MB pickle= 8.28ms memcpy= 0.69ms ratio=12.1x
n=10,000,000 bytes= 80.0MB pickle= 22.73ms memcpy= 6.67ms ratio= 3.4x
pickle 比内存拷贝慢 3.4x~45x,且这是一次完整副本(新对象、新内存),而不是视图。小对象时相对开销极高(45x),大对象时因为拷贝本身也变贵、比值收敛到 3.4x——但绝对时间仍在涨。结论:跨进程传大数组,pickle 是首先要消除的瓶颈。
5.3.3 shared_memory:把数组搬进共享内存
multiprocessing.shared_memory 让你在两个进程间共享同一块物理内存,传的是名字而不是数据。下面让子进程原地修改一个 1e6 元素的 float64 数组,父进程立刻能看到:
import multiprocessing as mp
from multiprocessing import shared_memory
import numpy as np
def worker(name, shape, dtype):
shm = shared_memory.SharedMemory(name=name)
a = np.ndarray(shape, dtype=dtype, buffer=shm.buf)
a += 1 # 原地修改,不复制回传
shm.close()
if __name__ == "__main__":
arr = np.arange(1_000_000, dtype=np.float64)
shm = shared_memory.SharedMemory(create=True, size=arr.nbytes)
shared = np.ndarray(arr.shape, dtype=arr.dtype, buffer=shm.buf)
shared[:] = arr[:] # 只拷贝一次进去
p = mp.Process(target=worker, args=(shm.name, arr.shape, arr.dtype))
p.start(); p.join()
print(int(shared[0]), int(shared[-1])) # 父进程看到子进程的修改
shm.close(); shm.unlink()
fork : shared_mem_roundtrip=5.2ms pickle_arg_roundtrip=2.5ms parent_sees=(1, 1000000)
spawn : shared_mem_roundtrip=237.2ms pickle_arg_roundtrip=294.3ms parent_sees=(1, 1000000)
关键读法:parent_sees=(1, 1000000) 证明子进程的原地修改对父进程可见——因为两者指向同一块内存。shared_memory 的收益不在「启动更快」,而在读写全程零拷贝:无论数组多大,父进程读到的都是同一块 buffer,不需要反序列化。
注意
spawn下两种方式都到 200ms+,因为spawn本身要重新import numpy(5.3.1 的启动税),把数据成本淹没了。要看数据成本,得在fork或长驻进程池里比较。
5.3.4 Queue vs Pipe:IPC 通道选择
Queue 和 Pipe 都能传对象,但 Queue 内部多了一层喂料线程 + 锁来支持多生产者多消费者,因此更慢。实测固定发送条数、改变消息大小:
# 消费者进程循环 recv 直到收到哨兵;生产者发送 n 条 payload
# 结果(fork 上下文):
# n= 10000 payload= 100B Queue= 55.1ms Pipe= 31.8ms Pipe/Queue=0.58
# n= 10000 payload= 100000B Queue= 50.5ms Pipe= 31.5ms Pipe/Queue=0.62
# n= 1000 payload= 1000000B Queue= 7.1ms Pipe= 4.7ms Pipe/Queue=0.66
Pipe 稳定比 Queue 快约 1.5x~1.7x。选型规则很直接:
| 通道 | 适用 | 不适用 |
|---|---|---|
Queue | 多生产者/多消费者、需要 task_done/join 协调 | 点对点、追求低延迟 |
Pipe | 两个进程间的高频点对点通信 | 多写入者(可能数据交错损坏) |
shared_memory | 大块数组/矩阵,读写频繁 | 需要复杂同步协议时(得自己配 Lock) |
5.3.5 线程池 vs 进程池:实测对比
同一份工作分别交给线程池和进程池。CPU 密集任务 K=8,每份 N=4_000_000:
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def cpu_task(n):
s = 0
for i in range(n):
s += i * i
return s
CPU-bound K=8 N=4_000_000:
serial=1.383s threadpool=1.354s processpool=0.803s
speedup: thread=1.02x process=1.72x (cpus=10)
I/O-bound K=8 (每份 sleep 0.1s):
serial=0.841s threadpool=0.106s processpool=0.613s
两个方向完全相反:
- CPU 密集:线程池 1.02x(GIL 压死),进程池 1.72x(真并行)。但 8 个 worker 在 10 核上只拿到 1.72x,远低于理论 8x——因为
spawn的启动税 + 参数序列化吃掉了大半收益。 - I/O 密集:线程池 7.9x(近乎线性),进程池只有 1.37x(
spawn启动比sleep还慢)。
进程池的启动方式直接决定收益。 把同一个 CPU 任务换到 fork 上下文:
processpool[fork] : 0.282s
processpool[spawn]: 0.779s
fork 进程池比 spawn 快 2.8x(0.282s vs 0.779s)。这就是「为什么别人的进程池那么快」的答案:在 Linux 上默认 fork,在 macOS/Windows 上默认 spawn。长任务(单份工作远大于启动开销)无所谓;短任务、大批量时,启动税会主导总耗时。
补充一个线程侧的实测边界:线程能否利用 C 扩展的并行,取决于该操作是否释放 GIL,并非「NumPy 就一定并行」。本机 numpy 2.5.3(Apple Accelerate 后端)实测:
np.sort(5e6 元素)4 线程加速 3.2x~3.8x(释放 GIL),但A @ B矩阵乘法 4 线程加速仅 1.0x(未释放)。选线程池前,最好对你真正调用的那个 C 操作测一次。
5.3.6 选型决策表
把前面的实测汇总成一张可直接查的表:
| 场景 | 首选 | 理由(实测依据) |
|---|---|---|
| 纯 Python CPU 密集、单份任务大 | ProcessPoolExecutor + fork(Linux) | 进程池 1.72x,fork 池 0.282s vs spawn 0.779s |
| 纯 Python CPU 密集、任务碎、启动敏感 | 复用长驻进程池 / 批处理合并任务 | spawn 单进程 42.63ms 会吃掉碎任务收益 |
| 阻塞 I/O(网络/DB/文件) | ThreadPoolExecutor 或 asyncio | 线程池 7.9x;进程池反而被启动税拖累 |
| C 扩展计算 | 先测该操作是否释放 GIL,再定线程/进程 | np.sort 3.5x,matmul 1.0x |
| 大数组/矩阵跨进程共享 | shared_memory + numpy.ndarray(buffer=...) | 零拷贝,父进程直接看到子进程修改 |
| 两进程高频点对点通信 | Pipe | 比 Queue 快 1.5x~1.7x |
| 多生产者/消费者 | Queue | Pipe 多写入者会数据交错 |
CPU 密集的并行还能走「事件循环 + 进程池」的混合路线,那是第 6 章 事件循环的实现与调度 的内容。
5.3.7 共享内存的生命周期与常见坑
shared_memory 快,但它把「手动管理内存生命周期」的责任交给了你。核心规则只有两条:
- 每个使用它的进程都要
close():关闭的是本进程的映射,不是销毁内存。 - 由创建者调用一次
unlink():标记这块内存可被回收;没有unlink就会泄漏(在 Linux 上体现为/dev/shm里残留的段)。
from multiprocessing import shared_memory
shm = shared_memory.SharedMemory(create=True, size=1024)
try:
# ... 子进程用 name 打开、读写 ...
pass
finally:
shm.close()
shm.unlink() # 只在创建者进程里做一次
如果创建者进程崩溃、来不及 unlink,multiprocessing.resource_tracker 会在进程退出时兜底回收——但它只在正常退出时可靠,被 SIGKILL 杀死仍会残留。所以生产代码里,创建与 unlink 最好放在同一个 try/finally 里。
多进程还有几个高频坑,一并列在这里:
| 坑 | 现象 | 解法 |
|---|---|---|
多线程进程里用 fork | 子进程继承锁状态,可能死锁;3.12+ 发 DeprecationWarning | 用 spawn / forkserver |
子进程里再建 Pool | daemon 进程不允许有子进程,抛 AssertionError | 只在主进程建池,或设 daemon=False |
Queue 里 join() 顺序错 | 消费者先 join 导致死锁 | 先 put 完所有数据再 join |
传大对象走 Queue | pickle 开销吞掉并行收益(见 5.3.2) | 改用 shared_memory |
spawn 下用局部函数/lambda 作 target | PicklingError | target 必须是模块级可导入对象 |
其中「spawn 不能 pickle 局部函数」在本章实验里真实踩到过:把 lambda 当 Process(target=...) 传,spawn 直接抛 Can't pickle local object,而 fork 不会。这也是跨平台代码必须先按 spawn 约束写的原因。
小结
- 启动方式实测:
fork6.84ms <forkserver10.76ms <spawn42.63ms(每进程);macOS 默认spawn,是进程池慢的主因。 - 跨进程传对象要走 pickle,实测比内存拷贝慢 3.4x~45x,且是一次完整副本。
shared_memory让子进程原地修改对父进程可见(parent_sees=(1, 1000000)),大数组共享应优先它而非 pickle。Pipe比Queue快约 1.5x~1.7x;多生产者场景才用Queue。- 线程池 vs 进程池方向相反:CPU 密集进程池 1.72x、线程池 1.02x;I/O 密集线程池 7.9x、进程池 1.37x。
fork进程池比spawn快 2.8x。 - 线程能否吃到 C 扩展并行,取决于该操作是否释放 GIL(本机
np.sort3.5x,matmul1.0x),必须实测。
本节把多进程的三笔开销拆清楚了,下一节进入单线程并发的另一端:事件循环的实现与调度 。
阅读导航:上一节:自由线程构建与迁移影响 · 下一节:事件循环的实现与调度 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。