幕 III · 三种活法CH 12进度 12/25

Python:GIL 到底挡住了什么

GIL 是编程语言史上最著名的一个设计缺陷 —— 而它同时也是 Python 能有今天的原因之一。这一章有两个目标。第一个:纠正一句被传坏了的话。「Python 多线程没用」只对 CPU 密集任务成立;对 IO 密集任务,Python 多线程是真的有效的,因为所有阻塞 IO 在进内核前都会释放 GIL。第二个:讲清楚正在发生的那场地震 —— free-threaded Python 拆掉 GIL 之后,二十年来「凑巧是对的」的并发代码,会开始真正地出竞态。

GIL引用计数multiprocessingPEP 7033.14

GIL 是什么:一把解释器级的大锁

GIL(Global Interpreter Lock):一个 CPython 进程里,同一时刻只有一个线程能执行 Python 字节码。

注意措辞。它不是

  • ❌「Python 不能有多线程」—— 能,而且是真的 OS 线程threading.Thread 底下就是 pthread_create)。
  • ❌「Python 线程不能并发」—— 能并发,只是不能并行地跑字节码
  • ❌「Python 是单线程的」—— 不是。

一把锁,谁想执行字节码,谁就得先拿到它。

为什么会有 GIL:一个关于引用计数的故事

⚙ 机器 · GIL 的根源,是 Python 的内存管理

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 的大矩阵运算!) */
◆ 定论 · 「Python 多线程没用」这句话,只对了一半
  • 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 一定能拿到!
⚠ 陷阱 · 「护航效应」:IO 线程被 CPU 线程饿死

一个真实且常见的性能问题:

你有一个线程在做 CPU 密集计算(持有 GIL 5ms),另一个线程在等网络。数据到了,IO 线程想继续处理 —— 但它得先抢到 GIL

而 CPU 线程刚放开 GIL,就立刻又去抢了(它是「热」的,在 CPU 上跑着)。IO 线程刚被唤醒,还在调度队列里 —— 它抢不过

后果:你的网络延迟从 1ms 变成了 50ms,而 CPU 使用率看起来完全正常。

这就是「在同一个 Python 进程里混合 CPU 密集和 IO 密集任务」的经典事故。

解法:把它们分开。CPU 的活丢进 ProcessPoolExecutor,或者干脆拆成两个服务。

绕过 GIL 的四条路

方案适合代价
① multiprocessing
ProcessPoolExecutor
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):NoneTrue、小整数、内置类型 —— 这些永远不会被回收,干脆不给它们计数。(这一条消灭了最热的争用点。)
  • 每个对象一把细粒度锁,替代 GIL。
  • 改用 mimalloc(线程安全的分配器)。
⚠ 陷阱 · GIL 拿掉之后,你的旧代码会开始出 bug

这是本章最重要的警告,而且业界还没有充分意识到它。

过去二十年,Python 程序员几乎从来没被内存模型咬过。为什么?因为 GIL 在悄悄替你做了两件事:

  • ① 它让很多操作事实上原子化了。list.append(x)d[k] = vx = 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 下跑一遍测试。

坏消息:你依赖的那些库,作者也还没想过这件事。

◈ 三语 · 还有一条路:子解释器(PEP 734)

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 事件循环,单步执行给你看;然后打开「阻塞调用」开关,看整个循环怎么死。