多进程与真并行:fork、spawn 与共享内存
更新时间:2026-09-02。本文回答:多进程到底比多线程快在哪、代价是什么?
fork和spawn启动的子进程有什么本质区别,为什么 Windows/Mac 上的代码搬到 Linux 行为不一样?多个进程怎么共享数据,Queue和共享内存差多少?以及——在 07 · asyncio 事件循环 的单线程协程里,遇到吃 CPU 的活到底该怎么办? 00 · GIL、多进程与 asyncio 讲了多进程的用法,这一篇往下挖一层:进程是怎么被造出来的、内存怎么共享、任务怎么分发,以及每一步在为什么付钱。本文 5 个 demo 全部在服务器(CentOS 7,4 核,Python 3.6.8)实测,代码见文末「代码位置」。
一、先看实测:线程为什么不加速,进程为什么行
GIL 保证同一进程里同一时刻只有一个线程执行 Python 字节码。这对 I/O 密集无所谓(等待时会释放 GIL),但对 CPU 密集就是灾难:多个线程抢同一把锁,不但没法并行,还要额外付锁竞争和线程切换的钱。多进程则是另起多个解释器,每个进程有自己的 GIL、自己的 Python 解释器实例,由操作系统把它们调度到不同 CPU 核心上——这才是真并行。
demo1(d1_gil_parallel/gil_parallel.py)跑 8 个纯 CPU 任务(每个反复做 20 万次 sha256),分别用串行、4 线程、4 进程:
cpu_count=4 tasks=8 work=sha256x200000 each
serial : 1.12s
threads : 1.12s speedup vs serial = 0.99x
processes: 0.41s speedup vs serial = 2.69x # (另一次峰值 3.83x)
threads/processes ratio = 2.71x三个结论:
- 多线程 ≈ 串行(0.99x):线程在 GIL 下轮流执行,8 个任务的总 CPU 工作量一点没少,反而因为抢锁略有损耗。
- 多进程近线性加速:4 核上拿到 2.7~3.8 倍。不是满分 4 倍,因为要付进程创建、任务 pickle 下发、结果回收的固定成本;任务越重、越均匀,这个比值越接近核数。
- 进程比线程快约 2.7~3.7 倍——这就是「真并行」和「看起来并发」的差距。
一句话记忆:I/O 密集用线程/协程(等的时候让出),CPU 密集用多进程或下沉 C(下沉 C 见 06 · Python 与 C 互操作,numpy/PyTorch 这类库内部释放 GIL 也是同理)。
二、进程是怎么造出来的:fork、spawn、forkserver
multiprocessing 在不同平台默认用不同方式创建进程,这是无数「我本地好好的,上线就炸」问题的根源。三种方式:
| 方式 | 怎么造子进程 | 子进程能看到父进程的内存吗 | 启动速度 | 默认平台 |
|---|---|---|---|---|
| fork | 直接 fork() 系统调用,复制父进程地址空间(写时复制) | 能,继承父进程当前所有变量/已导入模块/打开的文件 | 极快 | Linux(默认) |
| spawn | 启动一个全新的 Python 解释器,重新 import 主模块 | 不能,只拿到你显式传进去的参数 | 慢(冷启动 + 重新 import) | Windows、macOS(Python 3.8+) |
| forkserver | 先 fork 一个干净的「服务器」进程,之后由它 fork 工作进程 | 部分(forkserver 启动时的状态) | 中 | 可选手动指定 |
demo2(d2_fork_spawn/fork_spawn.py)先在父进程分配一个 160MB 的大列表 BIG,再分别用 fork 和 spawn 造子进程,看子进程能不能看到 BIG、各自花多久:
python=3.6.8 parent_pid=13510 parent_RSS=160.5MB
[fork child] pid=13511 sees_BIG=True len=20000000 peak_RSS=158.7MB
[fork] start+join=0.006s
[spawn child] pid=13519 sees_BIG=False len=-1 peak_RSS=158.3MB
[spawn] start+join=0.256s
spawn is 44.9x slower to start than fork读数:
- fork 子进程
sees_BIG=True:它直接继承了父进程的BIG,靠的是下一节讲的写时复制;启动只要 0.006s(一次fork()系统调用)。
四、Pool 与 chunksize:并行度和 IPC 粒度的权衡
每次 Process().start() 都要造进程、pickle 任务、回收结果,开销不小。multiprocessing.Pool(n) 预先造好 n 个常驻 worker,把任务排进队列分发——进程复用,摊薄启动成本。但并行度和「每次分发多少任务」都有讲究。
demo3(d3_pool_scaling/pool_scaling.py)固定 32 个 CPU 任务,先看 worker 数的影响,再固定 4 worker 看 chunksize:
cpu_count=4 tasks=32 work=sha256x120000
Pool( 1) chunksize=1 : 2.82s speedup=1.00x
Pool( 2) chunksize=1 : 1.41s speedup=2.00x
Pool( 4) chunksize=1 : 0.91s speedup=3.09x
--- chunksize effect (Pool with 4 workers, 32 tiny tasks) ---
chunksize= 1 : 0.72s
chunksize= 4 : 0.71s
chunksize=16 : 1.41s两点:
- 加速比随 worker 数上升但非线性:1→2 是完美 2.00x,2→4 只有 3.09x。核数越往上,固定开销(进程、IPC、结果回收)占比越大;且超过物理核数继续加 worker 不会更快,反而因争抢下降。所以
Pool(processes=os.cpu_count())是 CPU 密集的常用默认,I/O 密集才考虑超过核数。 - chunksize 要挑活:
map(fn, iterable, chunksize=c)控制一次给 worker 派多少个任务。任务小而多时,chunksize=1 意味着每个任务都单独 pickle、走一次管道,IPC 成本占比高;适当调大(如 4)把多个任务打包成一次 IPC,更快。但本 demo 里 chunksize=16 反而退化到 1.41s——打包太大导致负载不均:任务被分成 2 大包,4 个 worker 里只有 2 个拿到活、其余空闲,并行度塌掉。经验:默认让Pool自动估算(map不传 chunksize 时按len(iterable)/(n*4)估算),任务极轻量才手动调大。
五、进程间怎么传数据:共享内存零拷贝 vs Queue 逐个 pickle
多进程不共享 Python 堆,交换数据只有两条路:pickle 序列化后经管道/Queue 传,或者写进一块共享内存让对方直接读。前者通用(能传几乎任意可 pickle 对象)但每个对象都要序列化 + 拷贝 + 反序列化;后者只支持原始数值数组/字节,但零拷贝、零序列化。
demo4(d4_shared_memory/shared_vs_queue.py)让 4 个进程各产出 50 万个 float(共 200 万),A 方案写进 mp.Array("d") 共享 ctypes 数组,B 方案把 list 塞进 Queue 回传:
shared-memory Array: 0.145s (last=499.999, total floats=2000000, nothing pickled back)
Queue + pickle : 0.215s (floats received=2000000)
Queue/shared ratio = 1.48x (the gap is pickle + pipe IPC)数据量越大、对象越复杂,这个比值越夸张(本 demo 每个 worker 只回传 50 万元素,Queue 已慢 48%;若回传的是嵌套 dict / 大 numpy 数组且往返频繁,差距能到数倍)。选型口诀:
- 少量、离散、结构化的结果(每个任务算完返回一个数/一个小 dict)→ 用
Queue/Pool.map的返回值,简单直接; - 大块同质数值数据(图像帧、矩阵、采样信号、大数组)→ 用共享内存(
sharedctypes.Array或shared_memory.SharedMemory),worker 往里写、主进程读,只传一个名字/句柄,不传数据本身; Queue底层是「管道 + 一个 feeder 线程 + pickle」,Pipe是更薄的双向管道,二者都要序列化。
六、回到事件循环:CPU 活怎么不冻住 asyncio
07 篇 反复强调协程里不能写阻塞调用——一个吃满 CPU 的长计算和 time.sleep 一样会卡住唯一的事件循环线程。多进程正是这个问题的标准答案:loop.run_in_executor(ProcessPoolExecutor, fn, *args) 把 CPU 函数丢进进程池,事件循环线程立刻解放,继续 tick 其他协程,等 worker 进程算完再把结果(经 pickle)送回。
demo5(d5_loop_executor/loop_executor.py)设一个每 0.1s 跳动的心跳协程,再跑两个重 CPU 任务。坏版本直接在协程里同步调用,好版本用 run_in_executor 丢进 2 进程池:
BAD: CPU task run directly in coroutine (blocks the loop thread)
[bad] heartbeat ticks at: 0.86, 0.96, 1.06, 1.16, 1.26, 1.36, 1.46, 1.57
GOOD: CPU tasks offloaded via run_in_executor(ProcessPoolExecutor)
[good] heartbeat ticks at: 0.10, 0.20, 0.30, 0.40, 0.50, 0.60, 0.70, 0.80
results=['task-0-done', 'task-1-done'] wall=1.61s对比鲜明:坏版本心跳第一拍从 0.10s 被拖到 0.86s——CPU 任务占着线程,事件循环没机会处理 sleep(0.1) 的定时回调,等 CPU 活干完才一口气补 tick(0.86→0.96→1.06 挤在一起);好版本心跳严格 0.10/0.20/0.30… 整齐跳动,因为计算在另外两个进程里真并行,事件循环线程全程空闲响应。注意好版本的两个 CPU 任务是并行完成的(进程池),坏版本是串行的(一个线程里先后跑),所以好版本响应更稳、总耗时也不更长。
这就把 07 和 08 缝在了一起:协程负责「等待」(I/O),进程负责「计算」(CPU)。一个真实服务通常两者都要——asyncio 扛并发连接,遇到图片处理/加解密/报表计算这类 CPU 活就
run_in_executor甩给进程池。(ThreadPoolExecutor也能用来 offload 阻塞式 I/O 库,比如同步的requests,因为 I/O 等待时会释放 GIL;但纯 CPU 必须用进程池。)
七、选型决策与常见坑
| 场景 | 用什么 | 原因 |
|---|---|---|
| 纯 CPU、要吃满多核 | Pool(cpu_count()) / 进程池 | 绕开 GIL 真并行 |
| 大量并发网络/I/O | asyncio 协程(07 篇) | 单线程事件循环,无锁无栈开销 |
| 协程里遇到 CPU 活 | run_in_executor(ProcessPoolExecutor) | 不冻事件循环 |
| 协程里遇到阻塞 I/O 库 | run_in_executor(ThreadPoolExecutor) | I/O 等待释放 GIL,线程池够了 |
| 大块数值数据跨进程 | 共享内存 sharedctypes/shared_memory | 零拷贝,免 pickle |
| 离散小结果回传 | Queue / Pool.map 返回值 | 简单通用 |
必须记住的坑(本系列 demo 实踩):
if __name__ == "__main__":守卫不可省(spawn/forkserver),否则子进程 re-import 主模块时无限递归造进程。- 跨 context 的同步原语别混用:spawn 的 Process 配 spawn 的
Queue/Lock(用ctx.Queue()),否则死锁。 - 传给 worker 的函数和参数必须可 pickle:模块顶层函数可以,lambda、闭包、局部函数、不可序列化的对象(打开的连接、锁、文件句柄)都不行。
- COW 会被引用计数悄悄破坏:fork 后遍历父进程的大 Python 容器会逐页触发复制;要真共享用 sharedctypes/shared_memory。
- worker 数别超物理核(CPU 密集),chunksize 别贪大(负载不均)。
- 进程别频繁创建销毁:用常驻 Pool 复用;进程启动(尤其 spawn)是毫秒~百毫秒级的贵操作。
八、与本站主线衔接
- 并发模型总览与「三种模型怎么选」:00 · GIL、多进程与 asyncio。
- 协程/事件循环底层(为什么单线程能扛高并发、阻塞为何致命):07 · asyncio 事件循环。
- fork/exec 系统调用与进程在内核里是什么:C 篇 09 · fork 与进程实践。
- 用户态线程/协程调度的另一种成熟实现(GMP 模型):Go 篇 07 · GMP 调度。
- 共享内存(mmap/POSIX shm)在操作系统层面的原理:共享内存。
- 绕开 GIL 的另一条路——把热计算下沉到 C:06 · Python 与 C 互操作;Go/cgo 对照见 12 · GOC 内存对比。
代码位置
本文 5 个 demo 均在服务器(CentOS 7,4 核,Python 3.6.8)实测通过,纯标准库:
demos/python-expert/multiprocessing/
├── run_all.sh # 一键跑全部 5 个 demo
├── d1_gil_parallel/gil_parallel.py # 串行/线程/进程对比:线程 0.99x、进程 3.83x
├── d2_fork_spawn/fork_spawn.py # fork 0.006s 见父内存 vs spawn 0.256s 全新解释器
├── d3_pool_scaling/pool_scaling.py # worker 数加速比 + chunksize 负载不均
├── d4_shared_memory/shared_vs_queue.py # 共享内存零拷贝 vs Queue pickle(1.48x)
└── d5_loop_executor/loop_executor.py # 协程直接跑 CPU 冻心跳 vs run_in_executor运行:bash demos/python-expert/multiprocessing/run_all.sh(需要 Python 3.6+;spawn 行为在 Windows/macOS/Linux 均可复现,Linux 默认 fork)。
一句话总结
多进程 = 用「多个解释器 + 操作系统调度」换到真并行,代价是进程启动(spawn 比 fork 慢几十倍)、数据要 pickle 或走共享内存;fork 靠写时复制廉价继承父内存但会被引用计数破坏,大块数据用共享内存零拷贝;CPU 活在 asyncio 里要用 run_in_executor 甩给进程池,别让它冻住唯一的事件循环线程。
- spawn 子进程
sees_BIG=False:全新解释器重新 import 主模块,父进程main()里分配的BIG根本不存在(len=-1是捕获NameError的标记);启动 0.256s,慢 44.9 倍——这 0.25 秒花在冷启动解释器、重新 import 依赖上。主模块 import 越重(Django、pandas、torch…),spawn 越慢。 - 子进程 RSS 都在 158MB 左右:fork 是因为 COW 共享了父内存;spawn 是因为它重新 import 时模块级代码照常加载(本 demo 大列表放在
main()里、__main__守卫保护,所以 spawn 子进程没重建列表,只占解释器自身)。
坑(spawn 子进程会重新执行 import 的模块):spawn 子进程会重新 import 主脚本。所以所有「启动进程」的代码必须放在
if __name__ == "__main__":守卫里,否则子进程 import 时又执行一遍Process().start(),无限递归炸进程。本 demo 早期版本把 160MB 分配写在模块级,spawn 子进程 re-import 时各建一份,直接卡死——这就是这个坑的真实复现。坑(不要混用上下文):用
mp.get_context("spawn")造的 Process,配套的Queue/Lock也要用同一个 context 创建(ctx.Queue())。用默认 fork 上下文造的 Queue 传给 spawn 进程,会因信号量/管道跨上下文不匹配而死锁——本 demo 调试时正是被这个卡了很久,最后改成子进程直接 print 才绕开。fork 快但有暗病:fork 只复制调用线程,其他线程在子进程里消失,如果它们正持有锁(比如某个后台线程持着 import 锁/ malloc 锁),子进程再去碰同一资源就可能死锁。这正是 macOS 在 Python 3.8 把默认改成 spawn 的原因。fork 的底层系统调用见 C 篇 09 · fork 与进程实践。
三、写时复制(COW):fork 为什么几乎不花钱还能共享内存
fork 说「复制父进程地址空间」,但真的逐字节复制 160MB 就不会只要 0.006s 了。内核用的是写时复制(Copy-On-Write, COW):fork 时父子进程的页表指向同一批物理页,内核把这些页标记为只读;之后谁要写某一页,才触发缺页异常、内核把那一页复制一份给写者,读者仍共享旧页。
所以 demo2 里 fork 子进程的 peak_RSS=158.7MB 几乎不占新物理内存——它和父进程共享那 160MB 的物理页,RSS/ru_maxrss 统计把共享页也算进了每个进程,看起来各自 160MB,实际物理内存只花了一份多一点。这也是多进程「预加载大模型/大数据再 fork」能省内存的原理:父进程先把只读数据加载好,fork 出的 N 个 worker 共享它。
但 Python 的 COW 有个著名的坑——引用计数会破坏共享。CPython 用引用计数管理对象,每个对象头里有个 ob_refcnt,只要「读」一个对象,解释器就会把它的引用计数 +1 再 -1,这是一次写。于是 fork 之后,worker 一旦遍历父进程继承来的一个大 list/dict,每个被碰过的对象所在的页都因为 refcnt 改动被复制一份,COW 退化成「几乎全复制」,内存暴涨。
规避办法:
- 让子进程只读地、尽快处理,或干脆用 forkserver/spawn 配合显式共享;
- 把要跨进程共享的大数据放进
multiprocessing.sharedctypes(Array/Value)或 3.8+ 的multiprocessing.shared_memory,它们底层是独立的 mmap 共享内存段,不经过 Python 对象引用计数,多进程真正零拷贝共享同一块物理内存; - 数值计算优先用 numpy,配合共享内存放 ndarray 的数据缓冲。
下一节的 demo4 就对比了「共享内存零拷贝」和「Queue 逐个 pickle」的差距。