《Python高级编程》5.3 多进程、共享内存与 IPC 选型

用本机实测数字拆解多进程的三笔开销:fork/spawn/forkserver 的进程启动成本、跨进程 pickle 序列化税、Queue 与 Pipe 的通道差异;再用 shared_memory 实测 numpy 数组的零拷贝共享,最后给出线程池与进程池的选型决策表。

本节目标:量化多进程的三笔开销(启动、序列化、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
多生产者/消费者QueuePipe 多写入者会数据交错

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
子进程里再建 Pooldaemon 进程不允许有子进程,抛 AssertionError只在主进程建池,或设 daemon=False
Queue 里 join() 顺序错消费者先 join 导致死锁先 put 完所有数据再 join
传大对象走 Queuepickle 开销吞掉并行收益(见 5.3.2)改用 shared_memory
spawn 下用局部函数/lambda 作 targetPicklingErrortarget 必须是模块级可导入对象

其中「spawn 不能 pickle 局部函数」在本章实验里真实踩到过:把 lambda 当 Process(target=...) 传,spawn 直接抛 Can't pickle local object,而 fork 不会。这也是跨平台代码必须先按 spawn 约束写的原因。

小结

  1. 启动方式实测:fork 6.84ms < forkserver 10.76ms < spawn 42.63ms(每进程);macOS 默认 spawn,是进程池慢的主因。
  2. 跨进程传对象要走 pickle,实测比内存拷贝慢 3.4x~45x,且是一次完整副本。
  3. shared_memory 让子进程原地修改对父进程可见(parent_sees=(1, 1000000)),大数组共享应优先它而非 pickle。
  4. Pipe 比 Queue 快约 1.5x~1.7x;多生产者场景才用 Queue。
  5. 线程池 vs 进程池方向相反:CPU 密集进程池 1.72x、线程池 1.02x;I/O 密集线程池 7.9x、进程池 1.37x。fork 进程池比 spawn 快 2.8x。
  6. 线程能否吃到 C 扩展并行,取决于该操作是否释放 GIL(本机 np.sort 3.5x,matmul 1.0x),必须实测。

本节把多进程的三笔开销拆清楚了,下一节进入单线程并发的另一端:事件循环的实现与调度 。

阅读导航:上一节:自由线程构建与迁移影响 · 下一节:事件循环的实现与调度 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时