Python:GIL 到底挡住了什么
GIL 是编程语言史上最著名的一个设计缺陷 —— 而它同时也是 Python 能有今天的原因之一。这一章有两个目标。第一个:纠正一句被传坏了的话。「Python 多线程没用」只对 CPU 密集任务成立;对 IO 密集任务,Python 多线程是真的有效的,因为所有阻塞 IO 在进内核前都会释放 GIL。第二个:讲清楚正在发生的那场地震 —— free-threaded Python 拆掉 GIL 之后,二十年来「凑巧是对的」的并发代码,会开始真正地出竞态。
GIL 是什么:一把解释器级的大锁
GIL(Global Interpreter Lock):一个 CPython 进程里,同一时刻只有一个线程能执行 Python 字节码。
注意措辞。它不是:
- ❌「Python 不能有多线程」—— 能,而且是真的 OS 线程(
threading.Thread底下就是pthread_create)。 - ❌「Python 线程不能并发」—— 能并发,只是不能并行地跑字节码。
- ❌「Python 是单线程的」—— 不是。
它是:一把锁,谁想执行字节码,谁就得先拿到它。
为什么会有 GIL:一个关于引用计数的故事
CPython 用引用计数做垃圾回收。每个对象头里有个 ob_refcnt:
typedef struct _object {
Py_ssize_t ob_refcnt; /* ← 就是这个数字 */
PyTypeObject *ob_type;
} PyObject;
/* 每次你多一个引用: */
#define Py_INCREF(op) ((op)->ob_refcnt++) /* ← 这是第 8 章那个 counter++ ! */
#define Py_DECREF(op) if (--(op)->ob_refcnt == 0) dealloc(op);
看那个 ob_refcnt++。它就是第 8 章讲的那个「三条指令的自增」。
如果两个线程并行执行字节码,它们会并发地 INCREF/DECREF 同一个对象(比如 None、小整数、你的全局配置对象 —— 这些被所有代码引用)。
后果:引用计数会算错。算多了 → 内存泄漏。算少了 → 对象被提前释放 → 段错误。
怎么修?两条路:
- A:把每一次 INCREF/DECREF 都改成原子操作。—— 但这些操作极其频繁(Python 里几乎每一行代码都在动引用计数),全改成原子指令,单线程性能会掉 30~40%。
- B:加一把大锁,让字节码执行串行化。—— Guido 选了 B。1992 年,那时候多核 CPU 还是奢侈品。
这个选择在当时是对的,而且它换来了一样东西:极其简单的 C 扩展 API。你写 C 扩展时,完全不用考虑线程安全 —— GIL 替你保证了。numpy、scipy、pandas、PyTorch 这整个生态,是踩着 GIL 长出来的。
关键事实:IO 会释放 GIL
这是本章最重要的一句话,也是被误传最多的一点:
/* CPython 源码里,每一个可能阻塞的操作,都长这样: */
Py_BEGIN_ALLOW_THREADS /* ← 释放 GIL!其他线程可以跑了 */
result = recv(fd, buf, len, flags); /* 真正的阻塞系统调用 */
Py_END_ALLOW_THREADS /* ← 重新抢 GIL */
/* 这个宏对出现在:
socket 的所有操作、文件读写、time.sleep()、
subprocess、锁的等待、以及所有正经的 C 扩展(numpy 的大矩阵运算!) */
- CPU 密集(纯 Python 计算):多线程真的没用。加速比 ≈ 1.0×,甚至更慢(白付切换和抢 GIL 的开销)。
- IO 密集(网络、文件、数据库):多线程是真的有效的。10 个线程抓 10 个网页,接近 10 倍加速。
- C 扩展里的重计算(numpy 的矩阵乘、PIL 的图像处理):也有效!因为好的 C 扩展会在长计算前
Py_BEGIN_ALLOW_THREADS。
(这就是为什么「用 numpy 就能绕过 GIL」这句话是对的 —— numpy 在 C 层放开了 GIL。)
下面这个观察台把四种场景的 GIL 持有时间线摆在一起。重点看第二个 tab(IO 密集)—— 那两条 IO 是真的重叠的。
GIL 是怎么被换手的
如果一个线程死不放手怎么办?CPython 有一个强制换手机制:
import sys sys.getswitchinterval() # 默认 0.005 → 5 毫秒 sys.setswitchinterval(0.001) # 可以调 # 机制(Python 3.2 起,Antoine Pitrou 重写的): # 1. 线程 B 想要 GIL,但线程 A 拿着 → B 设一个「gil_drop_request」标志,然后等 # 2. 线程 A 每执行完一条字节码,检查这个标志 # 3. 看到标志 && 已经跑了 5ms → A 放开 GIL,叫醒 B # 4. 【重要】A 放开后会立刻再去抢 —— 所以不保证 B 一定能拿到!
一个真实且常见的性能问题:
你有一个线程在做 CPU 密集计算(持有 GIL 5ms),另一个线程在等网络。数据到了,IO 线程想继续处理 —— 但它得先抢到 GIL。
而 CPU 线程刚放开 GIL,就立刻又去抢了(它是「热」的,在 CPU 上跑着)。IO 线程刚被唤醒,还在调度队列里 —— 它抢不过。
后果:你的网络延迟从 1ms 变成了 50ms,而 CPU 使用率看起来完全正常。
这就是「在同一个 Python 进程里混合 CPU 密集和 IO 密集任务」的经典事故。
解法:把它们分开。CPU 的活丢进 ProcessPoolExecutor,或者干脆拆成两个服务。
绕过 GIL 的四条路
| 方案 | 适合 | 代价 |
|---|---|---|
① multiprocessingProcessPoolExecutor |
CPU 密集 + 粗粒度 (图像批处理、数据清洗、批量计算) |
· 进程启动慢 · 内存翻 N 倍(每个进程一份解释器 + 一份数据) · 所有跨进程数据要 pickle —— 传一个大 DataFrame 的开销可能超过计算本身 · 不能共享对象( multiprocessing.shared_memory 能共享字节,不能共享 Python 对象) |
| ② 下沉到 C / Rust numpy / polars / PyO3 |
CPU 密集 + 数值/数据 | · 这是最有效的一条路,而且是整个科学计算生态的做法 · 因为 C 层会 Py_BEGIN_ALLOW_THREADS,真的多核并行· 代价:你得写 C / Rust,或者你的问题恰好能被 numpy 向量化 |
| ③ asyncio | IO 密集 + 高并发 (几千~几万连接) |
· 函数染色,生态劈成两半 · 对 CPU 密集完全无效 · 下一章详谈 |
| ④ free-threaded Python (3.13t / 3.14t) |
真·多核多线程 | · 单线程性能有损失(几个百分点,3.14 已经优化了很多) · 生态还在追(C 扩展要重新适配) · 你的旧代码会开始出竞态(见下) · 不是默认构建,要专门安装 |
free-threaded Python:那场正在到来的地震
PEP 703(Sam Gross,2023 年被 Steering Council 接受)终于要拆 GIL 了。时间线:
| 3.13(2024) | 实验性。--disable-gil 构建,装出来叫 python3.13t。 |
| 3.14(2025) | 官方正式支持(PEP 779 —— 从「实验」升级为「supported」)。仍然不是默认构建。 |
| 未来 | 如果生态跟上、性能损失可接受 —— 可能成为默认。但没有承诺时间表。 |
它是怎么做到的?(简化版)
- 偏向引用计数(biased reference counting):对象记住自己的「所有者线程」。所有者线程的 INCREF/DECREF 是非原子的(快!),其他线程的才走原子路径。大多数对象只被一个线程碰,所以大多数引用计数操作还是快的。
- 不朽对象(immortal objects):
None、True、小整数、内置类型 —— 这些永远不会被回收,干脆不给它们计数。(这一条消灭了最热的争用点。) - 每个对象一把细粒度锁,替代 GIL。
- 改用 mimalloc(线程安全的分配器)。
这是本章最重要的警告,而且业界还没有充分意识到它。
过去二十年,Python 程序员几乎从来没被内存模型咬过。为什么?因为 GIL 在悄悄替你做了两件事:
- ① 它让很多操作事实上原子化了。
list.append(x)、d[k] = v、x = y—— 这些在 GIL 下不会被打断(因为它们是单条字节码,而 GIL 切换发生在字节码之间)。于是无数代码「凑巧」是线程安全的。 - ② 它提供了免费的内存屏障。每次 GIL 换手都是一次完整的同步 —— 可见性问题从来不存在。
GIL 一拿掉,这两层保护同时消失。
# 这段代码在有 GIL 时「一直是对的」
counter = 0
def worker():
global counter
for _ in range(1_000_000):
counter += 1 # ← 在 3.14t 里,这会真的丢更新
# (回到第 8 章:LOAD / ADD / STORE)
# 修法:threading.Lock,或者用 itertools.count(),
# 或者每个线程自己算最后 sum。
好消息:free-threaded 构建带了 ThreadSanitizer 支持 —— 你可以像 Go 那样跑竞态检测。如果你打算迁移,第一件事就是在 TSan 下跑一遍测试。
坏消息:你依赖的那些库,作者也还没想过这件事。
Python 3.12 实现了每解释器一个 GIL(PEP 684),3.14 把它包装成了标准库模块 concurrent.interpreters(PEP 734)。
from concurrent import interpreters # Python 3.14+
interp = interpreters.create() # 一个独立的解释器,有自己的 GIL
interp.exec("print('我有自己的 GIL')")
# 位置:介于线程和进程之间
# · 比进程轻(同一个进程,不用 fork,启动快)
# · 比线程隔离(各有各的 GIL,真并行)
# · 但数据仍然要「搬」(不能直接共享 Python 对象)
它是一个折中方案:拿到了 multiprocessing 的并行度,避开了进程启动的开销。但数据共享依然受限 —— 所以它没有解决 multiprocessing 最痛的那个点(pickle 搬数据)。
现实判断:free-threading 是更彻底的答案,子解释器更像是一个过渡方案。两条路 CPython 都在推,让市场去选。
实用决策表
| 你的任务 | 用什么 | 不要用什么 |
|---|---|---|
| 抓 1000 个网页 | ThreadPoolExecutor(50)(简单)或 asyncio + httpx(并发量大时) |
不要用 multiprocessing —— 进程开销 + pickle,纯亏 |
| 处理 1000 张图片 | ProcessPoolExecutor(核数) |
不要用 threading(GIL 挡死) 不要用 asyncio(毫无意义) |
| 大矩阵运算 | numpy / polars / torch —— 它们在 C 层已经多核并行了 | 不要自己写多进程去并行 —— 你多半会比 numpy 慢 |
| 一个 Web 服务 | 多进程 × 每进程一个 asyncio 循环 ( gunicorn -w 4 -k uvicorn.workers.UvicornWorker)这是 Python Web 的标准姿势:进程用满多核,asyncio 用满每个核的 IO 等待 |
不要单进程 asyncio(只用一个核) |
| CPU + IO 混合 | 拆开。IO 走 asyncio / 线程,CPU 用 await asyncio.to_thread()(如果是 numpy 之类会放 GIL 的)或 ProcessPoolExecutor(如果是纯 Python) |
不要混在一起 —— 「护航效应」会毁掉你的延迟 |
1. 亲手验证「IO 会释放 GIL」。写两个版本:① 两个线程各 time.sleep(2);② 两个线程各跑一个 2 秒的纯计算循环。计时。① 是 2 秒,② 是 4 秒。这一个实验能治好「Python 多线程没用」的迷信。
2. 装一个 python3.14t,跑那个 counter += 1 的实验。看着它丢更新。然后想一想你项目里有多少这样的代码。
3. 用 sys.setswitchinterval(0.0001) 把切换间隔调小,再跑一次竞态测试 —— 你会发现即使在有 GIL 的 3.12 上,counter += 1 也会开始丢。GIL 从来没有承诺过原子性,它只是让窗口变窄了。
下一章:Python 的另一条路 —— asyncio。一个真的 mini 事件循环,单步执行给你看;然后打开「阻塞调用」开关,看整个循环怎么死。