📑 本页目录(点开跳转)
04 · GIL:为什么多线程救不了你
⏱ 94 分钟 | ⭐ 判据不是「CPU 密集还是 I/O 密集」,是「这个操作放不放 GIL」——同样叫 CPU 密集,np.sort 开线程快 3 倍,json.loads 开线程一点不快
🎯 一句话
一个 Python 进程里有一把全局锁,同一时刻只允许一个线程在执行 Python 字节码;但一个操作只要下沉到 C 里去干重活,它就可以把这把锁放开。 所以「多线程有没有用」不取决于活重不重,取决于这段活是在解释器里跑还是在 C 里跑。这一章给的就是这条判据,以及判据两边分别该选线程、进程还是 async。
🧩 一、GIL 是什么:一个进程一把锁
GIL 全称 Global Interpreter Lock,全局解释器锁。它的规则用一句话讲完:
⭐ 一个 CPython 进程里,同一时刻只有一个线程能在执行 Python 字节码。 你可以开 100 个线程,操作系统也确实把它们分配到了不同的核上,但它们要排队抢同一把锁。
先看你的解释器现在是什么形状:
# 你的解释器现在是什么形状:启动方式 + GIL 在不在
import multiprocessing as mp, sys
if __name__ == "__main__":
print("平台 :", sys.platform)
print("默认启动方式 :", mp.get_start_method())
print("本平台可选 :", mp.get_all_start_methods())
print("GIL 还在吗 :", getattr(sys, "_is_gil_enabled", lambda: "该版本没有这个函数")())
print("线程切换间隔 :", sys.getswitchinterval(), "秒")
本机(8 核 Windows 11 / CPython 3.13)输出:
对照
平台 : win32
默认启动方式 : spawn
本平台可选 : ['spawn']
GIL 还在吗 : True
线程切换间隔 : 0.005 秒
⭐ 最后那行 0.005 秒是全部机制:一个线程拿到锁之后,每隔 5 毫秒会被要求把锁交出来一次,让别的线程有机会跑。所以线程之间是轮流跑,不是同时跑——切换很密集,看起来像并行,加起来的吞吐却还是一个核的量。
为什么会有这么一把锁:CPython 用引用计数管内存,每个对象身上挂着一个「有几个名字指着我」的计数器(这就是 01 章里那些「标签」的实现)。这个计数器几乎每执行一条字节码都会被加减一次。如果两个线程同时改同一个计数器,就会出现「都以为自己是最后一个」于是重复释放,或者「都以为对方还在用」于是永远不释放。
⭐ 给每个对象配一把锁在技术上可行,但代价是给每一次加减都加锁——单线程程序会因此显著变慢。GIL 是「一把大锁换掉几亿把小锁」的工程折衷:单线程最快,多线程受罚。
🧩 二、纯 Python 的 CPU 活:线程一点忙都帮不上
先把最常见的期待打掉。8 核机器,4 份一模一样的计算活:
# 纯 Python 的 CPU 活:串行 / 4 线程 / 4 进程
import math, time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def burn(n=3_000_000):
s = 0.0
for i in range(n):
s += math.sqrt(i)
return s
if __name__ == "__main__": # ⭐ Windows 上少这一行,多进程直接炸,见第五节
t = time.perf_counter()
[burn() for _ in range(4)]
print("串行 %.2f s" % (time.perf_counter() - t))
t = time.perf_counter()
with ThreadPoolExecutor(4) as ex:
list(ex.map(burn, [3_000_000] * 4))
print("4 线程 %.2f s" % (time.perf_counter() - t))
t = time.perf_counter()
with ProcessPoolExecutor(4) as ex:
list(ex.map(burn, [3_000_000] * 4))
print("4 进程 %.2f s" % (time.perf_counter() - t))
要点
串行 0.56 s
4 线程 0.73 s
4 进程 0.38 s
⭐ 看中间那一行:开了 4 个线程,比串行还慢。慢的那部分不是错觉,是抢锁和切换的开销——活一点没少干,还多干了「排队」这件事。
⚠️ 线程那一档在不同机器、不同轮次之间会在「和串行持平」到「比串行慢三成」之间摆动(我这台机器上三轮分别是 0.57 / 0.60 / 0.75 s,对应的串行是 0.56 / 0.62 / 0.86 s)。要看的不是绝对数,是它从来不会接近 4 倍。
⚠️ 而进程那一档也只快了 1.5 倍,不是 4 倍——因为起进程本身要钱:
# 起进程本身要多少钱
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
def noop(x):
return x
if __name__ == "__main__":
t = time.perf_counter()
with ThreadPoolExecutor(4) as ex:
list(ex.map(noop, range(4)))
print("起 4 个线程 + 跑 4 个空任务 %.3f s" % (time.perf_counter() - t))
t = time.perf_counter()
with ProcessPoolExecutor(4) as ex:
list(ex.map(noop, range(4)))
print("起 4 个进程 + 跑 4 个空任务 %.3f s" % (time.perf_counter() - t))
对照
起 4 个线程 + 跑 4 个空任务 0.002 s
起 4 个进程 + 跑 4 个空任务 0.387 s
⭐ 0.002 s 对 0.387 s,差了近 200 倍。 这 0.39 秒是净开销,从并行省下的时间里扣。任务总时长如果不到一秒,多进程大概率是亏的。
⭐ 三、真正的分界:这个操作放不放 GIL
上一节容易让人得出「CPU 密集就用进程」的结论。那个结论太粗,而且恰好在最常见的两个场景上给出相反的建议。
关键在于:GIL 是可以被主动放开的。任何 C 扩展在进入一段「不碰 Python 对象」的纯计算之前,都可以调用 Py_BEGIN_ALLOW_THREADS 把锁交出去,算完再拿回来。锁交出去的那段时间里,别的线程是真的在另一个核上跑。
同样是「CPU 密集」,两个库的选择不一样:
# 同样是「CPU 密集」,线程池管不管用取决于这个操作放不放 GIL
import json, time
import numpy as np
from concurrent.futures import ThreadPoolExecutor
arrays = [np.random.rand(8_000_000) for _ in range(4)]
blob = json.dumps([{"id": i, "v": [i, i + 1, i + 2]} for i in range(300_000)])
blobs = [blob] * 4
print("JSON 大小 %.1f MB" % (len(blob) / 1e6))
def timed(fn):
t = time.perf_counter(); fn(); return time.perf_counter() - t
def np_serial(): [np.sort(a) for a in arrays]
def np_thread():
with ThreadPoolExecutor(4) as ex: list(ex.map(np.sort, arrays))
def js_serial(): [json.loads(b) for b in blobs]
def js_thread():
with ThreadPoolExecutor(4) as ex: list(ex.map(json.loads, blobs))
for name, f in [("np.sort 串行", np_serial), ("np.sort 4线程", np_thread),
("json 串行", js_serial), ("json 4线程", js_thread)]:
print("%s %.2f s" % (name, min(timed(f) for _ in range(3)))) # ⭐ 取最小值,理由见 05 章
JSON 大小 13.7 MB
| 任务 | 串行 | 4 线程 |
|---|---|---|
| np.sort | 0.80 s | 0.25 s |
| json | 1.51 s | 1.45 s |
⭐ 这四行就是本章的正题:
| 操作 | 串行 | 4 线程 | 倍数 | 为什么 |
|---|---|---|---|---|
np.sort 排 800 万个 float |
0.80 s | 0.25 s | ⭐ 3.2× | 排序在 C 里做,全程放开 GIL,4 个线程真的在 4 个核上跑 |
json.loads 解析 13.7 MB |
1.51 s | 1.45 s | 1.04× | json 的 C 加速器全程在构造 Python 对象(dict / list / str),碰 Python 对象就不能放锁 |
⭐ 判据可以直接背下来:这段活里有没有在造 Python 对象。
| 会放开 GIL(线程真的有用) | 不放开 GIL(线程没用) |
|---|---|
| numpy / scipy 的数组运算、排序、线性代数 | 纯 Python 的循环、字符串拼接、字典操作 |
| PyTorch 的算子(CPU 和 GPU 都是) | json.loads / json.dumps |
压缩解压(zlib / gzip / bz2) |
re 正则匹配(不放) |
hashlib 摘要(数据够大时) |
绝大多数纯 Python 写的第三方库 |
所有 I/O:文件读写、socket、time.sleep |
pickle 序列化本身 |
⚠️ 这张表里的每一行都该被你自己验一遍,别背。 我用同样的方法(串行一次、四线程一次)实测了三个常被问到的:
| 操作 | 串行 | 4 线程 | 倍数 | 结论 |
|---|---|---|---|---|
re.findall 在 320 万字符上找模式 |
0.223 s | 0.217 s | 1.03 | ❌ 不放锁 |
zlib.compress 压 10 MB |
0.240 s | 0.086 s | 2.78 | ✅ 放锁 |
hashlib.sha256 摘要 50 MB |
0.174 s | 0.110 s | 1.58 | ✅ 放锁(数据小的时候收益被调用开销吃掉) |
⭐ 不确定的时候别猜,量一遍。 上面那 12 行就是模板:同一个函数,串行一次、四线程一次,看倍数。倍数接近核数就是放锁的,接近 1 就是不放的。怎么把这种测量做对(预热、取最小值、别被缓存骗),是 05 章整章的内容。
⭐ 站内有一处正好卡在这条分界线上。AI全栈 03 · 后端骨架 讲 async 服务时说:真 CPU 活——解析 50 MB JSON、本地算 embedding——async 救不了,只能扔线程池或独立进程。 那句话把这类活指向了两个选项,而没说什么时候该选哪个。⭐ 这一章补的就是那个判据:它举的两个例子分别落在分界线两边——本地算 embedding 走 numpy / torch,扔线程池就管用;解析 50 MB JSON 扔线程池基本没用,得扔进程。
🚦 四、三种并发怎么选
把「放不放 GIL」和「在等还是在算」两个问题交叉,答案就唯一了:
| 你的活 | 选什么 | 为什么 |
|---|---|---|
在等网络 / 磁盘 / 数据库,而且库是 async 的 |
⭐ async | 等待期间 GIL 本来就是放开的;协程比线程轻,几千个并发也不吃力 |
在等,但手上的库是同步的(requests、老的数据库驱动) |
⭐ 线程池 | 同上,等待时锁是放开的。线程池是同步库的标准解法,别为了 async 硬换库 |
| 在算,而且算的是 numpy / torch / 压缩 / 哈希 | ⭐ 线程池 | 底层放锁,能真并行,还不用付 pickle 和进程启动的钱 |
| 在算,纯 Python 代码(解析、循环、正则) | ⭐ 进程池 | 只有换个进程才能换一把新的 GIL |
| 在算,纯 Python,但数据非常大 | ⚠️ 先算账,见第六节 | 搬数据的钱可能超过并行省下的钱 |
「在等」那一档的效果是最好看的——因为等待期间 GIL 是放开的,8 个任务能挤在一起等:
# 等待类的活:线程和 async 都能赢,因为等待的时候 GIL 是放开的
import asyncio, time
from concurrent.futures import ThreadPoolExecutor
def wait_io(_): # 假装是一次网络请求
time.sleep(0.25)
async def wait_io_async(_):
await asyncio.sleep(0.25)
async def gather_all():
await asyncio.gather(*(wait_io_async(i) for i in range(8)))
if __name__ == "__main__":
t = time.perf_counter(); [wait_io(i) for i in range(8)]
print("串行 %.2f s" % (time.perf_counter() - t))
t = time.perf_counter()
with ThreadPoolExecutor(8) as ex:
list(ex.map(wait_io, range(8)))
print("8 线程 %.2f s" % (time.perf_counter() - t))
t = time.perf_counter(); asyncio.run(gather_all())
print("async %.2f s" % (time.perf_counter() - t))
对照
串行 2.00 s
8 线程 0.25 s
async 0.27 s
⭐ 2.00 → 0.25,正好 8 倍,因为 8 个任务的等待完全重叠了。线程和 async 在这里打平——区别不在这个规模上,在几千个连接的规模上:一个线程要几百 KB 到几 MB 的栈,一个协程只要几 KB。
⛔ async 的语法、事件循环怎么转、为什么不能在协程里做阻塞调用,那些归 AI全栈 03 · 后端骨架。本章只负责「三种里该选哪种」。
🛑 读到这里可以停 —— 前半章讲完了(约 32 分钟)。 后半章还有:进程模型:fork 和 spawn 是两件不同的事 · pickle 的账:搬数据的钱可能比省下的还多 · 站内的两处,全是这一章的账 · GIL 不等于线程安全 · GIL 会消失吗(这一节会过期) · 一张排查表 回来的时候不用重读,直接从下一节接着看就行。
🧩 五、进程模型:fork 和 spawn 是两件不同的事
选了进程之后,第二类坑就来了:子进程是怎么诞生的。有两种造法,行为差得很远。
| fork(Linux 默认) | spawn(⭐ Windows / macOS 默认) | |
|---|---|---|
| 怎么造 | 把当前进程整个复制一份 | 从零启动一个新的 Python 解释器 |
| 子进程能看见什么 | 父进程 fork 那一刻的全部内存(写时复制) | ⭐ 什么都看不见,只有被 pickle 传过去的东西 |
| 启动开销 | 小 | 大(要重新 import 一遍所有模块) |
| 顶层代码 | 不会重跑 | ⭐ 会重跑一遍 |
| 线程 / 锁 | ⚠️ 只复制调用 fork 的那个线程,别的线程持有的锁会永远锁着 | 干净,没这个问题 |
⭐ spawn 会把你的主模块重新 import 一遍,这是 Windows 上所有多进程怪事的根源:
# spawn 会把这个模块【重新 import 一遍】:守卫外面的代码,每个子进程都会再跑一次
import os, time
from concurrent.futures import ProcessPoolExecutor
print("[模块顶层] pid=%d 我被执行了" % os.getpid())
STATE = {"loaded_at": time.time()} # 守卫【外面】:每个子进程都会重新造一份
def who(_):
return "pid=%d | STATE 里有 %d 个键 | EXTRA=%r" % (
os.getpid(), len(STATE), STATE.get("EXTRA"))
if __name__ == "__main__":
STATE["EXTRA"] = "主进程加上去的" # 守卫【里面】:子进程看不见
print("[主进程] pid=%d" % os.getpid())
with ProcessPoolExecutor(2) as ex:
for line in ex.map(who, range(2)):
print("[子进程]", line)
要点
[模块顶层] pid=19208 我被执行了
[模块顶层] pid=51172 我被执行了
[模块顶层] pid=35412 我被执行了
[主进程] pid=35412
[子进程] pid=19208 | STATE 里有 1 个键 | EXTRA=None
[子进程] pid=19208 | STATE 里有 1 个键 | EXTRA=None
(pid 数值和这几行的先后顺序每次都不同,几个进程的 stdout 是抢着刷的。)
⭐ 两条结论都在这段输出里:
[模块顶层]打了三次——两个子进程各把这个文件重新执行了一遍。所以顶层写的任何耗时代码(加载模型、连数据库、读大 CSV)都会被乘以进程数。- 子进程的
EXTRA是None。守卫里面改的那次修改,子进程完全不知道。进程之间不共享对象——这是 01 章全部「共享」直觉失效的地方:那一章说「赋值只是贴标签,两个名字指同一个对象」,跨进程之后连对象都是两个。
⚠️ if __name__ == "__main__" 不是仪式,是必需
如果没有那道守卫,子进程重新 import 主模块时会再次执行创建进程池的那一行,于是无限递归。CPython 检测到了这件事,报错如下(这是真实输出):
对照
RuntimeError:
An attempt has been made to start a new process before the
current process has finished its bootstrapping phase.
This probably means that you are not using fork to start your
child processes and you have forgotten to use the proper idiom
in the main module:
if __name__ == '__main__':
freeze_support()
·
⚠️ 报错还没完,主进程那边收到的是另一条完全不指向根因的消息:
concurrent.futures.process.BrokenProcessPool: A process in the process pool was
terminated abruptly while the future was running or pending.
💀 这就是为什么它难查:你在终端里看到的第一屏是 BrokenProcessPool,字面意思是「池子里有个进程突然死了」,读起来像资源不足或者环境问题;真正的 RuntimeError 被埋在一大堆 multiprocessing/spawn.py 的栈帧中间。而在 Jupyter / IDE 里,子进程的 stderr 常常根本不显示,你只会看到那句 BrokenProcessPool,然后开始怀疑人生。
📋 规矩:任何会用到多进程的脚本,一律把可执行代码放进 if __name__ == "__main__":。 顶层只留 import、常量和函数/类定义。这条在 Linux 上也照做——你写的脚本迟早会有人在 Windows 或 macOS 上跑。
⚠️ 六、pickle 的账:搬数据的钱可能比省下的还多
跨进程传参数和收结果,走的是 pickle:对象序列化成字节 → 通过管道传给子进程 → 子进程再反序列化成对象。这一来一回的钱经常被忽略。
# 进程间传参要 pickle:大对象搬过去的钱,可能比并行省下的还多
import pickle, time
import numpy as np
from concurrent.futures import ProcessPoolExecutor
def cheap(a): # 活很轻:只求个和
return float(a.sum())
if __name__ == "__main__":
arrays = [np.random.rand(8_000_000) for _ in range(4)] # 每份 64 MB
blob = pickle.dumps(arrays[0])
t = time.perf_counter(); pickle.dumps(arrays[0]); pk = time.perf_counter() - t
print("单份大小 %.0f MB | pickle 一次 %.3f s" % (len(blob) / 1e6, pk))
t = time.perf_counter(); [cheap(a) for a in arrays]
print("串行 %.3f s" % (time.perf_counter() - t))
t = time.perf_counter()
with ProcessPoolExecutor(4) as ex:
list(ex.map(cheap, arrays))
print("4 进程 %.3f s" % (time.perf_counter() - t))
对照
单份大小 64 MB | pickle 一次 0.055 s
串行 0.036 s
4 进程 0.870 s
💀 4 个进程比串行慢了 24 倍。 256 MB 的数据被序列化、塞进管道、再反序列化,而真正的计算只花了 36 毫秒。
⭐ 一条能算的规矩:
单个任务的计算时间,至少要是「它的数据 pickle 一趟的时间」的 10 倍,多进程才值得开。 上面这个例子里 pickle 一趟 0.055 s、计算 0.009 s——差了 6 倍,方向反了。
⚠️ 还有三个连带的坑:
| 坑 | 长什么样 |
|---|---|
| 不是所有东西都能 pickle | lambda、局部函数、打开的文件句柄、数据库连接、threading.Lock 都不行,报 PicklingError 或 TypeError: cannot pickle ... |
| 返回值也要 pickle | 子进程返回一个 2 GB 的 DataFrame,回程的开销和去程一样贵 |
| 内存被乘以进程数 | 4 个子进程各持一份 64 MB 的副本,峰值是 5 份而不是 1 份(怎么量峰值内存见 05 章) |
📋 避开办法:别传数据,传「去哪拿数据」。把 4 个进程各自要读的文件路径 / 行号区间 / 分片编号传过去,让它自己读。大数组还可以放进 multiprocessing.shared_memory 共享一块内存,只传名字。
💀 七、站内的两处,全是这一章的账
① DataLoader(num_workers=4) 在 Windows 上一跑就炸。
PyTorch 的 DataLoader 在 num_workers > 0 时就是一个进程池。所以第五节和第六节的账它一分不少:
| 现象 | 根因 | 怎么修 |
|---|---|---|
一开始训练就 BrokenPipeError / RuntimeError: ... bootstrapping phase |
训练脚本没有 if __name__ == "__main__" 守卫 |
把训练代码整段放进守卫里 |
Notebook 里设 num_workers>0 就卡死不动 |
spawn 要重新 import 主模块,而 Notebook 没有可 import 的「主模块文件」 | 把 Dataset 类和训练循环挪进 .py 文件再 import;或者在 Notebook 里就用 num_workers=0 |
| 数据集对象很大,worker 一起来内存就爆 | 每个 worker 拿到的是数据集对象的一份副本(第五节:进程之间不共享对象) | Dataset 里只存路径和索引,__getitem__ 里才真正读数据 |
| 每个 epoch 开头卡好几秒 | 每个 epoch 重建 worker,每次都付一遍 spawn 的钱(本机 4 进程 ≈ 0.39 s,import torch 之后更贵) | persistent_workers=True |
⛔ num_workers / prefetch_factor / pin_memory 具体该调到多少,归 AI基础设施 22 · 数据管线与存储。本章只负责解释为什么会炸。
② n_jobs=-1 为什么有时候反而更慢。
sklearn 的 n_jobs 走的是 joblib 的进程池,账和第六节一模一样。⚠️ 交叉验证时每个进程都要拿到一份训练数据的副本——5 折 × 一份 2 GB 的矩阵,峰值就是 10 GB。小数据集上 n_jobs=-1 常常比 n_jobs=1 还慢,因为搬数据的钱超过了并行省下的。
🧯 八、GIL 不等于线程安全
最后一个反直觉的地方:GIL 保护的是解释器自己的内部状态,不是你的代码。
「同一时刻只有一个线程在跑字节码」不等于「你的一行 Python 是一个整体」。total = plus_one(total) 是读 → 调用 → 写回三步,中间任何一步都可能被换下去:
# GIL 不等于线程安全:读—改—写中间只要有一次函数调用,就有换线程的机会
import sys, threading
sys.setswitchinterval(1e-6) # 默认 0.005 秒,这里调到百万分之一秒,把概率放大
def plus_one(x):
return x + 1 # ⭐ 一次函数调用 = 一个可以被换下去的点
total = 0
def bump(n=50_000):
global total
for _ in range(n):
total = plus_one(total) # 读 total → 调用 → 写回,三步不是一个整体
ts = [threading.Thread(target=bump) for _ in range(8)]
[t.start() for t in ts]; [t.join() for t in ts]
print("应该是 400000,实际是", total, " 丢了", 400_000 - total)
要点
应该是 400000,实际是 123085 丢了 276915
💀 丢了 27 万次更新,占七成。 三次运行分别是 123085 / 116319 / 115419,每次都不一样——这正是并发 bug 最难受的特征:不确定、不复现、不报错。
⚠️ sys.setswitchinterval(1e-6) 那一行是为了把概率放大到肉眼可见。默认的 5 毫秒下,同样的代码在 CPython 3.13 上我连跑三次都没丢过一次——这才是真正危险的地方:你本地测不出来,它在生产的负载下才出现。
📋 规矩:只要有两个线程会写同一个东西,就上锁(threading.Lock),或者干脆用 queue.Queue 让它们只通过队列通信。⚠️ 不要因为「我看了 CPython 源码,这个操作是原子的」就省掉锁——那是实现细节,换个版本、换个解释器就变。
🛑 读到这里可以停 —— 已经读了约 61 分钟。 最后一段还有(约 28 分钟):GIL 会消失吗(这一节会过期) · 一张排查表 · 检查点与走神救援 回来的时候不用重读,直接从下一节接着看就行。
🗓️ 九、GIL 会消失吗(这一节会过期)
PEP 703 给 CPython 加了一个可选的 free-threaded 构建(俗称 no-GIL),3.13 起作为实验特性提供,3.14 起转为官方支持但仍非默认。装的是那个构建时,sys._is_gil_enabled() 会返回 False,纯 Python 的多线程才能真正吃满多核。
⚠️ 今天不要按「GIL 快没了」来做设计决策,三个理由:
| 理由 | 说明 |
|---|---|
| 默认装的还不是它 | python.org 的默认安装包仍是带 GIL 的。你得专门装 free-threaded 版本(本机 sys._is_gil_enabled() 返回 True) |
| C 扩展要重新适配 | 大量第三方轮子还没有 free-threaded 版本,装不上或者掉回兼容模式 |
| ⭐ 本章的账不会全部作废 | GIL 没了,pickle 的开销、进程启动的开销、spawn 重新 import 这些依然在;而第八节的「线程安全要自己保证」会变得更重要,因为原来靠 GIL 侥幸没出问题的代码,那层侥幸没有了 |
📋 今天的正确姿势:按本章的判据写代码(放锁的活用线程、不放锁的活用进程),这套写法在 GIL 消失之后依然正确,只是「不放锁的活」那一档会多出一个选项。
📋 十、一张排查表
| 现象 | 大概率是哪一节 | 一行验证 |
|---|---|---|
| 开了 8 个线程,CPU 占用还是只有一个核 | 一、二 —— 活在解释器里跑,抢同一把锁 | 换 4 进程再测一次,快了就是 GIL |
| 开线程反而更慢 | 二 —— 抢锁和切换的净开销 | 和串行比,不要和「期望值」比 |
| 同事说线程池管用,我这儿不管用 | ⭐ 三 —— 他的活放 GIL,你的不放 | 串行/4 线程各测一次,看倍数 |
| 4 个进程比串行慢十几倍 | ⭐ 六 —— pickle 的账 | len(pickle.dumps(arg)) 看要搬多少 |
BrokenProcessPool |
⭐ 五 —— 没写 if __name__ == "__main__" |
往上翻栈,找 bootstrapping phase |
| 顶层的模型加载被执行了 5 次 | 五 —— spawn 会重新 import 主模块 | 在顶层打一行 print(os.getpid()) |
Notebook 里 num_workers>0 卡死 |
七 —— 没有可 import 的主模块文件 | 先设 num_workers=0 确认是不是它 |
| 计数器 / 缓存偶尔少几条,重跑就好了 | ⭐ 八 —— GIL 不保证你的代码线程安全 | sys.setswitchinterval(1e-6) 放大概率 |
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| 05 · 怎么量:计时、剖析、内存 | 本章每一个结论都是量出来的。⭐ 在选并发方式之前,你得先知道时间花在哪 —— 那一章讲怎么把「串行/线程/进程各测一遍」这件事做对(预热、取最小值、别被缓存骗) |
| 01 · 名字、对象和绑定 | 那一章说「两个名字可以指同一个对象」。⭐ 跨进程之后这条全部失效:传过去的是 pickle 出来的副本,改了对面也不知道 |
| AI全栈 03 · 后端骨架 | async 的语法、事件循环怎么转、为什么别在协程里做阻塞调用,全在那里。⭐ 它把「真 CPU 活」指向了线程池或独立进程,本章第三节补上「什么时候该选哪个」的判据 |
| AI全栈 11 · 长任务与队列 | 本章讲的是一个进程内怎么并发。活如果长到分钟级,正确答案既不是线程也不是进程池,而是把它扔进队列 —— 那是那一章的正题 |
| AI基础设施 22 · 数据管线与存储 | num_workers / prefetch_factor / pin_memory 具体调多少在那里。⭐ 本章负责的是为什么它在 Windows 上一跑就炸 |
| 机器学习与深度学习基础 15 · PyTorch实战手册 | 训练脚本的骨架在那里。⚠️ 把它抄进本地脚本时,记得把训练循环放进 if __name__ == "__main__",否则 num_workers>0 一开就是本章第五节那个报错 |
| 07 · 异常、traceback 和调试 | 第五节那个 BrokenProcessPool 是「表层异常盖住根因」的典型样本,那一章讲怎么系统地往下扒 |
✅ 检查点
- GIL 用一句话说是什么规则?为什么 CPython 需要它(和哪个内存管理机制有关)?
- 8 核机器上,4 份纯 Python 的 CPU 活开 4 个线程,实测结果是什么?为什么会这样?
- 本章说「CPU 密集就开进程」这个说法太粗。真正的判据是什么?
np.sort和json.loads各落在哪一边,实测倍数分别是多少? - 等 I/O 的活为什么线程和 async 都能赢?实测 8 个
sleep(0.25)串行和 8 线程分别是多少?两者的区别在什么规模上才显现? - spawn 和 fork 的三个关键区别是什么?为什么在 Windows 上「顶层加载模型」这件事特别贵?
- 少写
if __name__ == "__main__"会怎样?为什么这个错误难查(你在终端里先看到的是哪条消息)? - 什么情况下 4 个进程会比串行慢?本章那个例子慢了多少倍?给出一条能算的规矩,以及避开的办法。
- 既然有 GIL,为什么
total = plus_one(total)还会丢更新?实测丢了多少?为什么这个 bug 在本地特别难复现? DataLoader(num_workers=4)在 Notebook 里卡死,根因是什么?persistent_workers=True省掉的是哪一笔钱?- free-threaded 构建普及之后,本章哪些结论会作废、哪些不会?
👀 答案
- 一个 CPython 进程里,同一时刻只有一个线程能在执行 Python 字节码。 它存在是因为 CPython 用引用计数管内存——几乎每条字节码都要加减对象上的计数器,两个线程同时改会导致重复释放或永不释放。给每个对象配锁在技术上可行,但会让单线程程序显著变慢,GIL 是「一把大锁换掉几亿把小锁」的折衷。线程之间靠
sys.getswitchinterval()(本机 0.005 秒)轮流拿锁,是轮流跑不是同时跑。 - 串行 0.56 s、4 线程 0.73 s、4 进程 0.38 s —— 开线程比串行还慢,慢的是抢锁和切换的净开销,活一点没少干。进程那档只快 1.5 倍而不是 4 倍,因为起 4 个进程本身就要 0.387 s(同样的空任务开 4 个线程只要 0.002 s,差近 200 倍)。
- 判据是 「这个操作放不放 GIL」,等价问法是「这段活里有没有在造 Python 对象」。
np.sort排 800 万个 float:串行 0.80 s → 4 线程 0.25 s,3.2 倍,因为排序在 C 里做、全程放开 GIL;json.loads解析 13.7 MB:1.51 s → 1.45 s,只有 1.04 倍,因为它全程在构造 dict / list / str 这些 Python 对象,碰 Python 对象就不能放锁。 - 因为等待的时候 GIL 是放开的,8 个任务的等待可以完全重叠。实测串行 2.00 s、8 线程 0.25 s(正好 8 倍)、async 0.27 s。两者在这个规模上打平,区别要到几千个连接才显现:一个线程要几百 KB 到几 MB 的栈,一个协程只要几 KB。
- ① fork 是复制当前进程、spawn 是从零启动一个新解释器;② fork 的子进程能看见父进程的全部内存,spawn 的什么都看不见,只有被 pickle 传过去的东西;③ spawn 会把主模块重新 import 一遍,所以顶层代码会重跑。Windows / macOS 默认就是 spawn(本机
get_all_start_methods()只有['spawn'])。因此顶层写的加载模型 / 连数据库 / 读大 CSV 会被乘以进程数 —— 本章那个例子里[模块顶层]打了 3 次(1 个主 + 2 个子)。 - 子进程重新 import 主模块时会再次执行创建进程池那一行,无限递归。CPython 会抛
RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase。⚠️ 难查是因为你在终端里先看到的是BrokenProcessPool: A process in the process pool was terminated abruptly—— 字面像资源不足,真正的RuntimeError被埋在一堆multiprocessing/spawn.py栈帧中间;在 Notebook / IDE 里子进程的 stderr 常常根本不显示。 - 当搬数据的钱超过并行省下的钱时。本章例子:4 份 64 MB 数组只求个和,串行 0.036 s、4 进程 0.870 s,慢了 24 倍(pickle 一份就要 0.055 s,而计算只要 0.009 s)。规矩:单个任务的计算时间至少要是它的数据 pickle 一趟时间的 10 倍,多进程才值得开。避开办法是 别传数据,传「去哪拿数据」(文件路径、行号区间、分片编号),大数组可以用
multiprocessing.shared_memory只传名字。 - 因为 GIL 保护的是解释器自己的内部状态,不是你的代码。
total = plus_one(total)是读 → 调用 → 写回三步,函数调用处就是一个可以被换下去的点。实测 8 个线程各加 5 万次,应该是 400000,实际 123085(丢了 276915,约七成),三次运行 123085 / 116319 / 115419 每次都不一样。⚠️ 难复现是因为那是靠sys.setswitchinterval(1e-6)把概率放大出来的;默认 5 毫秒下连跑三次一次都没丢 —— 本地测不出来,生产负载下才出现。 DataLoader(num_workers>0)就是一个进程池,spawn 要重新 import 主模块,而 Notebook 没有可 import 的「主模块文件」。修法是把 Dataset 类和训练循环挪进.py再 import,或者在 Notebook 里用num_workers=0。persistent_workers=True省的是 每个 epoch 重建 worker 的进程启动钱(本机 4 个空进程就要 0.39 s,import torch之后更贵)。- 会作废的:第二节「纯 Python 的 CPU 活开线程没用」——free-threaded 构建下线程能真正吃满多核。不会作废的:pickle 的开销、进程启动的开销、spawn 重新 import 主模块,这些和 GIL 无关;而第八节「线程安全要自己保证」会变得更重要,因为原来靠 GIL 侥幸没出问题的代码,那层侥幸没有了。今天默认装的仍是带 GIL 的构建(本机
sys._is_gil_enabled()返回True)。
🛑 可以停在这里
⚡ 走神救援
⭐ GIL 是一把进程级的锁:同一时刻只有一个线程能执行 Python 字节码。 它存在是因为 CPython 用引用计数管内存,几乎每条字节码都要加减计数器——拿一把大锁换掉几亿把小锁。
⚠️ 实测 4 份纯 Python 的 CPU 活:4 线程比串行还慢(慢的是抢锁和切换的净开销),4 进程只快一点点(起进程本身就很贵,而开线程几乎免费)。
⭐ 但「CPU 密集就开进程」这个说法太粗,真正的判据是「这个操作放不放 GIL」,等价问法是「这段活里有没有在造 Python 对象」:
np.sort多线程能到三倍多(排序在 C 里做、全程放锁),而json.loads几乎没有加速(它全程在构造 dict / list / str)。选择表:等 I/O 且库是异步的 → async;等 I/O 但库是同步的 → 线程池;算的是 numpy / torch / 压缩 / 哈希 → 线程池;算的是纯 Python → 进程池。 等待类的活里线程和 async 打平,区别要到几千个连接的规模才显现。
选了进程之后第二类坑是进程怎么诞生:Windows / macOS 默认 spawn,它从零启动新解释器并把主模块重新 import 一遍——⭐ 进程之间不共享对象,第 01 章那套「两个名字指同一个对象」在这里全部失效。
⚠️ 少写
if __name__ == "__main__"会无限递归,而真正的RuntimeError被埋在栈中间,你先看到的是BrokenProcessPool——字面像资源不足,于是你会去查错地方。💀 pickle 的账更贵:几份大数组只求个和,4 进程比串行慢二十多倍。⭐ 规矩:单任务的计算时间至少要是数据 pickle 一趟时间的 10 倍。
下一节 👉 05-怎么量:计时、剖析、内存.md