自由线程 Python 深度拆解:当 GIL 从「不可动摇的前提」降级成一个编译开关——从偏向引用计数、QSBR 到 abi3t 稳定 ABI 的全链路实战
写 Python 的人,多多少少都被问过一个问题:「你们 Python 不是有 GIL 吗,多线程不是假的吗?」
过去二十多年,标准答案是「是的,CPU 密集就上多进程」。这个答案背后是一整套被迫形成的工程习惯:multiprocessing 满天飞、数据在进程间来回 pickle、模型权重在每个 worker 里复制一份、一台 64G 内存的机器跑 8 个 worker 就 OOM。我们把它当成 Python 的「天气」——不可改变,只能穿衣服适应。
现在这个天气变了。
Python 3.13 第一次提供了可以关掉 GIL 的构建(experimental);Python 3.14 在 2025 年 10 月 7 日发布,自由线程构建(free-threaded build)正式成为官方支持的构建配置;而正在 rc 阶段的 Python 3.15 落地了 PEP 803——为自由线程构建提供稳定 ABI(abi3t),这是整个 C 扩展生态从「每个版本编两套轮子」走向「一套轮子通吃」的关键一步。
换句话说:GIL 从一个语言层面的前提,正在降级成一个 ./configure 时的开关。
这篇文章不打算停留在「GIL 没了,性能起飞」这种标题级别的判断上。我们要做的是把它拆开:GIL 到底锁了什么、拿掉它之后 CPython 内部必须补上哪五个洞、单线程为什么必然要付出 5%~10% 的税、你现有的代码在自由线程下会以什么方式悄悄出错、C 扩展要改哪些行、以及最实际的——你到底该不该现在就上。
文章里所有的实测数据都是我在一台 8 核 arm64 机器、Python 3.14.4(GIL 构建)上跑出来的,脚本会完整贴出来,你可以自己复现。涉及 CPython 内部结构的部分,字段和 API 名称都对齐了 3.14/3.15 的官方文档与源码,不是凭记忆写的。
一、先把「GIL 锁的是什么」这件事说清楚
绝大多数关于 GIL 的讨论都停在「同一时刻只有一个线程执行 Python 字节码」。这句话是对的,但它是结论,不是原因。不理解原因,你就无法判断拿掉 GIL 之后什么会坏。
1.1 一切从引用计数开始
CPython 用引用计数做内存管理:每个对象头里有一个计数器,多一个人引用就 +1,少一个就 -1,归零即析构。好处是回收及时、可预测;代价是每一次对象读写都在改写共享内存。
看这段最普通的代码:
def f(items):
x = items[0] # items[0] 的引用计数 +1
return len(x) # x 出作用域时 -1
这里没有任何「共享状态」的味道,但在解释器层面,items[0] 指向的那个对象的引用计数被读改写了两次。现在假设两个线程同时对同一个对象做这件事,而计数器的更新不是原子的:
线程 A: load refcnt -> 2
线程 B: load refcnt -> 2
线程 A: store refcnt = 3
线程 B: store refcnt = 3 # 丢了一次 +1
最终计数是 3,实际引用是 4。等到两个线程各自 -1,计数会提前归零——对象在还有人用的时候被 free 掉。这不是「结果算错了」,这是 use-after-free,是段错误和随机内存损坏。
所以 GIL 的第一职责是:保证引用计数的读改写序列化。它不是为了保护你的业务数据,它是为了保护解释器自己不崩。
1.2 GIL 的三重职责
把 GIL 完整地拆开,它实际上同时承担了三件事:
职责一:引用计数完整性。 如上。这是最底层、也是最难替换的一层,因为它的频率极高——几乎每条字节码都会碰引用计数。
职责二:内置容器的内部结构一致性。 list.append 在 C 层面是「检查容量 → 可能 realloc → 写入 ob_item[n] → 更新 ob_size」。这几步之间如果被另一个线程插进来同样操作,就会出现两个线程写到同一个槽位、或者 ob_size 比实际元素多一个(于是读到未初始化的野指针)。GIL 让这几步天然成为一个「事务」。
职责三:C 扩展的隐式串行化。 这是最容易被低估的一层。过去三十年写的所有 C 扩展,只要没有主动 Py_BEGIN_ALLOW_THREADS,就运行在 GIL 保护之下。这意味着大量 C 扩展里的全局变量、静态缓存、模块级状态,从来没有加过锁——因为不需要。GIL 一撤,这些代码全部变成数据竞争。
理解这三层,你就能理解为什么 PEP 703 不是「删掉一个锁」这么简单,为什么它需要分三个阶段、跨越至少三个大版本才能推进。
1.3 实测:GIL 如何把 8 核变成 1 核
理论讲完,上数据。我写了一个纯 Python 的 CPU 密集任务——Collatz 序列步数统计,全程只有字节码,没有任何会释放 GIL 的 C 调用:
"""Pure-Python CPU work: never releases the GIL."""
import os, sys, sysconfig, time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def collatz_steps(limit: int) -> int:
"""纯字节码,零 C 层 GIL 释放点。"""
total = 0
for start in range(2, limit):
n, steps = start, 0
while n != 1:
n = n // 2 if n % 2 == 0 else 3 * n + 1
steps += 1
total += steps
return total
LIMIT, N = 60_000, 8
task = lambda _: collatz_steps(LIMIT)
def bench(label, fn, n):
t0 = time.perf_counter(); fn(n); dt = time.perf_counter() - t0
print(f"{label:<28} n={n} wall={dt:7.3f}s", flush=True)
return dt
def serial(n): [task(i) for i in range(n)]
def threaded(n):
with ThreadPoolExecutor(max_workers=n) as ex: list(ex.map(task, range(n)))
def processed(n):
with ProcessPoolExecutor(max_workers=n) as ex: list(ex.map(task, range(n)))
if __name__ == "__main__":
print(f"python={sys.version.split()[0]} "
f"gil_disabled={sysconfig.get_config_var('Py_GIL_DISABLED')} cpus={os.cpu_count()}")
bench("serial (1 task)", serial, 1)
s8 = bench("serial (8 tasks)", serial, N)
t8 = bench("threads (8 tasks)", threaded, N)
p8 = bench("processes (8 tasks)", processed, N)
print(f"\nthreads : {s8/t8:.2f}x (ideal 8.00x)")
print(f"processes : {s8/p8:.2f}x (ideal 8.00x)")
在 8 核 arm64、Python 3.14.4(GIL 构建)上的真实输出:
python=3.14.4 gil_disabled=0 cpus=8
serial (1 task) n=1 wall= 0.328s
serial (8 tasks) n=8 wall= 2.626s
threads (8 tasks) n=8 wall= 2.643s
processes (8 tasks) n=8 wall= 0.695s
threads : 0.99x (ideal 8.00x)
processes : 3.78x (ideal 8.00x)
0.99x。八个线程,八个核,加速比是 0.99 倍——比单线程串行还慢一点点。那 0.01 就是线程调度和 GIL 交接的开销。这就是「Python 多线程是假的」这句话的实证版本。
进程版本拿到 3.78x 而不是 8x,原因有两个,都值得记住:一是 8 个逻辑核里只有一部分是性能核(Apple silicon 是性能核 + 能效核的混合架构),二是进程池的启动、fork/spawn、pickle 往返在一个总时长仅 2.6 秒的任务上占了可观比例。这两点在后面判断「自由线程能赚多少」的时候会反复出现。
1.4 更反直觉的一组数据:会释放 GIL 也救不了你
很多人知道「hashlib、zlib、numpy 这类 C 扩展会主动释放 GIL,所以多线程有效」。这是对的,但有个前提被忽略了:释放和重新获取 GIL 本身是有成本的,而且这个成本会随线程数放大。
我把任务换成「循环里反复 sha256.update(4KB),中间夹一点 Python 算术」:
ROUNDS = 60_000
BLOCK = b"x" * 4096
def cpu_task(seed: int) -> int:
h = hashlib.sha256()
acc = 0
for i in range(ROUNDS):
h.update(BLOCK) # C 层,会释放 GIL
acc = (acc * 31 + (i ^ seed)) & 0xFFFFFFFF # 字节码,需要 GIL
return acc ^ int.from_bytes(h.digest()[:4], "big")
同一台机器上的实测:
serial (1 task) n=1 wall= 0.107s
serial (8 tasks) n=8 wall= 0.865s
threads (8 tasks) n=8 wall= 2.013s
processes (8 tasks) n=8 wall= 0.323s
thread speedup vs serial-8 : 0.43x
process speedup vs serial-8: 2.67x
0.43x——多线程比串行慢了一倍以上。
为什么?这段代码每轮都要「释放 GIL → 做哈希 → 抢回 GIL → 执行几条字节码」。8 线程 × 6 万轮 = 48 万次 GIL 抢夺。哈希 4KB 是微秒级,而 8 个线程争一把锁的交接开销在高争用下同样是微秒级——绝大部分时间花在锁的乒乓上,而不是算哈希上。
这个结果的工程含义很重要:**「这个库会释放 GIL」不等于「多线程一定更快」。**细粒度地反复进出 GIL,比根本不释放更糟。要靠释放 GIL 拿到并行度,单次 C 调用必须足够长(比如一次处理 100MB 而不是 4KB)。这也解释了为什么 NumPy 大矩阵运算多线程有效,而「循环里调用 NumPy 小操作」多线程反而变慢。
1.5 GIL 的隐性福利:它顺手掩盖了你所有的竞态
这是自由线程迁移中最危险的一点,而且几乎没人提前告知。
我写了一个教科书级的竞态:8 个线程各自对同一个全局整数 += 20 万次。
counter = 0
def bump():
global counter
for _ in range(ITERS):
counter += 1 # LOAD / ADD / STORE 三步,不是原子操作
理论上应该丢失大量更新。GIL 构建上的真实输出:
python=3.14.4 gil=True
counter: expected=1600000
actual counter = 1600000 lost updates = 0
dict check-then-act: expected=1
actual misses = 1
160 万次读改写,一次都没丢。
counter += 1 在字节码层面是 LOAD_FAST / LOAD_CONST / BINARY_OP / STORE_FAST 若干条指令,理论上完全可以在中间被切走。但现代 CPython 的 GIL 切换是按字节码计数 + 时间片(默认 5ms)触发的,而且 3.11 之后的特化自适应解释器(PEP 659)让这段循环执行得极快——一个线程往往能在一个时间片里跑完成千上万轮,切换点恰好落在 LOAD 和 STORE 之间的概率被压到了极低。
所以结论是:**在 GIL 构建上,你的竞态测不出来,不代表它不存在。**你的代码库里可能已经躺着几十个这样的 bug,GIL 帮你兜了二十年。自由线程构建做的事情,就是把这层兜底撤掉——所有真实存在的竞态会在那一刻同时变得可观测。
记住这一点,后面第五章讲迁移时我们会给出具体的排查手段。
二、路线图:从实验特性到编译开关
在动手之前,有必要把时间线和「官方承诺到了哪一步」搞清楚,因为这直接决定你的技术选型能有多激进。
PEP 703(Making the Global Interpreter Lock Optional in CPython)被指导委员会接受时,明确规划了三个阶段:
| 阶段 | 内容 | 落地版本 |
|---|---|---|
| Phase I | 自由线程构建可用,但明确标记为实验性 | Python 3.13 |
| Phase II | 自由线程构建官方支持,但仍是可选构建 | Python 3.14 |
| Phase III | 自由线程构建成为默认(最终成为唯一) | 尚未确定 |
进入 Phase II 的判据由 PEP 779 定义,2025 年 6 月 16 日被通过。这份 PEP 值得读,因为它把「什么叫做好了」量化成了五条:
- 可欲性(Desirability)——实验已经清楚证明自由线程有巨大潜在收益:更高吞吐、显著更低的延迟、以及全新的基于线程的能力。但 PEP 自己也写明:这不是一个 drop-in 的方案,部分代码必须重新设计才能吃到收益、才能绕开性能陷阱。
- 稳定性——大部分新 API 在 3.13 就定型,3.14 只做增补和替换(替掉那些原本靠 GIL 保证线程安全的 API),没有破坏 3.13 的自由线程 API。
- 可维护性——主体设计相对简单,复杂度藏在既有 C API 之下。无锁 list/dict、QSBR 回收、避免死锁的临界区难写对,但对外 API 没什么坑。
- 性能(CPU 与内存)——单线程回归和内存增长必须在可接受范围内。
- 稳定 ABI——理想情况下同一个 wheel 能同时用于两种构建。
第 5 条正是 3.15 的 PEP 803 在做的事。指导委员会在通过 PEP 779 时明确表态:**期望自由线程的稳定 ABI 在 Python 3.15 准备好并定义清楚。**这个承诺已经兑现了。
所以现在(3.14 稳定 + 3.15 rc)这个时间点的定位是:
- **语言和解释器层面:已经可以严肃使用。**官方支持,不是实验特性,API 稳定。
- **纯 Python 生态:基本可用。**大部分纯 Python 库天然兼容,问题集中在「隐藏的竞态」而非「跑不起来」。
- **C 扩展生态:正在跨过分水岭。**3.14 之前必须为
cp313t/cp314t单独编轮子;3.15 的abi3t让「一次编译多版本通用」成为可能,但需要非平凡的源码改动,而且构建工具链还在追赶。
这个判断很关键:如果你的技术栈是「纯 Python + 少量成熟 C 扩展」,现在就可以认真评估;如果你重度依赖大量小众 C 扩展,2026 年下半年到 2027 年才是舒适区。
三、五根支柱:拿掉 GIL 之后,CPython 补了哪五个洞
这一章是全文的技术核心。GIL 撤掉之后留下的三个职责必须有替代方案,CPython 的答案是五套互相咬合的机制。理解它们,你才能解释「为什么我的自由线程程序不快」这类问题。
3.1 先看对象头:一切改动都刻在这里
最直观的入口是 PyObject 结构体本身。在 GIL 构建里,它极其朴素:
/* GIL 构建 */
struct _object {
Py_ssize_t ob_refcnt; /* 一个引用计数 */
PyTypeObject *ob_type;
};
在自由线程构建(Py_GIL_DISABLED)里,它长这样(来自 CPython 3.14 Include/object.h):
/* 自由线程构建 */
struct _object {
uintptr_t ob_tid; // 拥有者线程 id
uint16_t ob_flags;
PyMutex ob_mutex; // per-object lock(每对象锁)
uint8_t ob_gc_bits; // gc 相关状态
uint32_t ob_ref_local; // 本地引用计数
Py_ssize_t ob_ref_shared; // 共享(原子)引用计数
PyTypeObject *ob_type;
};
一个字段一个字段读下来,PEP 703 的整体设计几乎全部写在这里了:
ob_ref_local+ob_ref_shared+ob_tid→ 偏向引用计数ob_mutex→ 每对象锁 / 临界区ob_flags→ 不朽标记、延迟计数标记ob_gc_bits→ GC 状态从「藏在 refcnt 高位」挪成独立字段
顺带也解释了内存开销:非 GC 对象的头从 16 字节涨到了 24 字节(arm64/x86-64 下按对齐算)。这是自由线程构建内存占用更高的第一个来源,而且是无法回避的结构性成本。
3.2 偏向引用计数:把 99% 的原子操作省掉
替代 GIL 保护引用计数最朴素的想法是「把 refcnt 改成原子整数」。这个方案实现上最简单,性能上最灾难——原子操作在多核上会引发缓存行争抢(cache line ping-pong),而热点对象(None、小整数、常用类型对象、模块对象)会被所有核心疯抢,单线程性能可能直接掉 50% 以上。
PEP 703 采用的是偏向引用计数(biased reference counting),思路来自 2018 年的一篇论文,核心洞察是:绝大多数对象的引用计数操作,都来自创建它的那个线程。
于是把一个计数拆成两个:
ob_ref_local:只有拥有者线程能碰,因此用普通的非原子读写即可。ob_ref_shared:其他线程操作时走这里,原子操作。ob_tid:记录拥有者线程 id,用来判断「我是不是拥有者」。
于是 Py_INCREF 的逻辑变成:
/* 概念示意,非逐字源码 */
static inline void Py_INCREF(PyObject *op)
{
if (_Py_IsImmortal(op)) {
return; /* 不朽对象:什么都不做 */
}
if (op->ob_tid == _Py_ThreadId()) {
op->ob_ref_local++; /* 快路径:非原子,零争用 */
}
else {
_Py_atomic_add_ssize(&op->ob_ref_shared, (1 << SHARED_SHIFT));
}
}
对象真正的引用数是两者之和;当拥有者线程结束、或者对象被「转移」时,本地计数会被归并(merge)进共享计数,ob_tid 置为「无主」。之后所有操作都走原子路径。
这个设计带来两个必须记住的工程后果:
后果一:线程亲和性直接决定性能。 对象在 A 线程创建、在 B 线程被反复引用,每次 incref/decref 都是原子操作 + 缓存行跨核同步。这是自由线程下最主要的性能陷阱——不是「锁」,是引用计数的 cache 争抢。第六章给规避手法。
后果二:对象生命周期会被拉长。 官方文档明确列为已知限制:自由线程的引用计数机制会让对象活得更久,而每线程引用计数(per-thread reference counting)也会延迟对象释放。如果你的代码依赖「离开作用域立刻析构」的确定性行为(比如用 __del__ 关文件、放锁、写日志),在自由线程构建下这个假设不再可靠。老实用 with 语句和显式 close()。
3.3 不朽对象:给永生者免税
有一类对象天生就是全进程共享、永不释放的:None、True、False、小整数、内建类型对象、interned 字符串。它们的引用计数会被所有线程疯狂读写,是最严重的争用热点,而它们的计数值根本没有意义——反正永远不会归零。
PEP 683 引入的不朽对象(immortal objects)就是给它们发免死金牌:打上标记,Py_INCREF/Py_DECREF 直接短路返回,一次原子操作都不做。
值得注意的是官方文档对 3.14 的表述——不朽化的范围是被刻意收窄的:
As of the 3.14 release, immortalization is limited to:
- Code constants: numeric literals, string literals, and tuple literals composed of other constants.
- Strings interned by
sys.intern().
也就是说:代码常量(数字字面量、字符串字面量、由常量组成的元组字面量)和 sys.intern() 出来的字符串。为什么要收窄?因为不朽是有代价的——不朽对象永远不会被回收。3.13 早期把范围放得更宽(比如所有 interned 字符串),结果导致自由线程构建的内存占用明显偏高。官方文档现在把「所有 interned 字符串都是不朽的」明确列为内存增长的原因之一。
对你的实际影响:**动态生成大量字符串并 sys.intern() 的代码,在自由线程构建下是内存泄漏级别的行为。**比如「把每条日志的 tag 都 intern 一下省内存」这种在 GIL 构建下合理的优化,到了自由线程构建就完全反转了。
3.4 延迟引用计数:给顶层对象再免一道税
不朽对象解决了「不可变的全局单例」。但还有一类对象同样被全局共享、同样是争用热点,却必须能被回收:模块对象、类对象、函数对象、方法对象、代码对象。
想想一个 web 框架的请求处理路径:每次调用一个函数,函数对象要 incref;访问一个类属性,类对象要 incref。8 个线程同时处理请求,就是 8 个核在抢同一个函数对象的引用计数缓存行。
PEP 703 的方案是延迟引用计数(deferred reference counting):对这类对象,解释器在栈上和局部变量里的临时引用不再逐次计数,而是标记为「延迟」,只在 GC 扫描时统一核算。这样热点路径上的原子操作被彻底消除,同时对象仍然可以被回收(只是回收时机依赖 GC)。
C 扩展作者可以对自己的类型主动启用这个特性(3.14 提供了 PyUnstable_Object_EnableDeferredRefcount,注意 PyUnstable_ 前缀意味着 API 还可能变),典型场景是「模块级单例配置对象被所有线程高频读取」。
副作用同样要记住:官方文档把「每线程引用计数会延迟对象释放」列为内存增长来源之一。延迟计数意味着这类对象的回收从「即时」变成了「等 GC」。
3.5 mimalloc + QSBR:无锁读的地基
这一对组合是全文最容易被跳过、但技术含量最高的部分。
为什么要换内存分配器? CPython 原本用自研的 pymalloc。PEP 703 换成了 mimalloc(微软开源的分配器)。表面理由是 mimalloc 的多线程性能更好——它是 per-thread heap 设计,线程本地分配无锁。但真正的理由更深:mimalloc 让「无锁读」成为可能。
考虑无锁读一个 dict:线程 A 读 d[k],同时线程 B 在 d 里插入导致哈希表 realloc、旧的 entries 数组被 free。线程 A 手上拿着已经被释放的指针——use-after-free。
要解决这个问题,你需要「读者可能还在看旧数组,所以旧数组不能立刻真正释放」的机制。这正是 QSBR(Quiescent State Based Reclamation,基于静止状态的回收) 做的事,思想和 Linux 内核的 RCU 同源:
- 写者把旧内存放进「待回收队列」,不立即
free。 - 每个线程周期性地宣告自己进入了「静止状态」(不持有任何被追踪的指针,通常在字节码分派的安全点)。
- 当所有线程都至少经过一次静止状态之后,说明没人还能持有旧指针,此时才真正释放。
mimalloc 在这里的作用是提供「这块内存属于哪个 heap、能否安全复用」的元数据结构,让 QSBR 的延迟回收可以高效实现,同时保证「即使读者读到了已被复用的内存,也不会读到类型完全不同的垃圾」。
**这套机制的工程后果直接写在官方的已知限制里:QSBR 会延迟内存释放。**你的进程 RSS 会比 GIL 构建更高、更「毛刺」,因为释放是批量的、滞后的。做容量规划时必须留余量,尤其是内存受限的容器环境(K8s 里设置 memory.limit 时要留出比 GIL 构建更多的 headroom)。
3.6 每对象锁与临界区:优雅的死锁规避
引用计数解决了,容器内部结构一致性(GIL 的职责二)怎么办?答案是 ob_mutex——每个对象自带一把轻量锁。list.append 会锁住 list,dict.__setitem__ 会锁住 dict。
官方文档对此的表述很谨慎,值得原文引用理解:
Built-in types like
dict,list, andsetuse internal locks to protect against concurrent modifications in ways that behave similarly to the GIL. However, Python has not historically guaranteed specific behavior for concurrent modifications to these built-in types, so this should be treated as a description of the current implementation, not a guarantee of current or future behavior.
翻译成人话:**内置容器不会因为并发修改而崩溃,但你不能依赖任何具体的并发语义。**紧接着文档给出了明确建议:能用 threading.Lock 就用 threading.Lock,不要依赖内置类型的内部锁。这条建议我建议你直接写进团队的 code review checklist。
对 C 扩展作者,CPython 暴露的是临界区(critical sections) API。它的精妙之处在于死锁规避:
Py_BEGIN_CRITICAL_SECTION(dict);
PyObject *key, *value;
Py_ssize_t pos = 0;
while (PyDict_Next(dict, &pos, &key, &value)) {
...
}
Py_END_CRITICAL_SECTION();
普通的「同时持有两把锁」写法很容易死锁:线程 A 持 obj1 等 obj2,线程 B 持 obj2 等 obj1。CPython 的临界区机制是这样破的:当线程在临界区内部因为任何原因被挂起(比如需要等另一把锁、或者到了 GIL 式的切换点)时,它会先释放自己持有的临界区锁,挂起结束后重新获取。
这意味着临界区不保证整段代码的原子性——中间可能被别人插进来。它保证的是「访问这个对象的那一瞬间结构是完整的」。这个语义和很多人对「critical section」这个词的直觉不同,是 C 扩展迁移时最常见的错误来源:你不能用一个临界区包住「读 dict → 判断 → 写 dict」然后以为它是事务。
3.7 GC:从分代到停止世界
最后一块拼图是垃圾回收(处理循环引用的那个 GC,不是引用计数)。自由线程构建的 GC 必须在扫描时看到一致的对象图,所以它采用 stop-the-world:暂停所有线程,扫描,恢复。
实践含义:**GC 暂停成为自由线程程序的一个新的延迟来源。**在 GIL 构建里,GC 发生在持有 GIL 的线程上,本来也没有并行度可损失;在自由线程构建里,一次 GC 会把 8 个正在干活的核全部按停。如果你追求 p99 延迟,需要关注 gc 模块的调优(gc.freeze() 把启动期对象移出扫描范围、调大阈值、必要时在关键路径上 gc.disable() 配合手动回收)。
四、解释器层的连带改造与「性能税」的账本
4.1 单线程要交多少税:两个数字与它们的差异
这是所有决策的前提。官方给出了两个数字,来源不同,都要知道:
Python 3.14 What's New 的表述:
The performance penalty on single-threaded code in free-threaded mode is now roughly 5-10%, depending on the platform and C compiler used.
自由线程 HOWTO 文档的表述:
On the pyperformance benchmark suite, the average overhead ranges from about 1% on macOS aarch64 to 8% on x86-64 Linux systems.
从 3.13 时期普遍引用的「约 40%」到现在的 1%~10%,进步的主要来源写在 What's New 里:PEP 659 的特化自适应解释器(specializing adaptive interpreter)在 3.14 的自由线程模式下被启用了。
这件事技术上不容易:特化解释器的原理是「运行时观察类型,把通用字节码替换成特化版本,并在内联缓存里记录假设」。在 GIL 构建下改写字节码是安全的;在自由线程下,多个线程可能同时执行同一段代码对象、同时试图特化同一条指令。3.13 的自由线程构建干脆把特化关掉了——这才是当年那 40% 里的大头。3.14 把这块补上(用线程安全的特化协议),性能税就掉到了个位数。
为什么 macOS aarch64 只有 1%,而 x86-64 Linux 有 8%? 主要是原子操作和内存序的硬件代价不同:arm64 的 LSE 原子指令、以及弱内存模型下 relaxed 序的原子操作可以非常便宜;x86-64 的 lock 前缀指令代价相对更高。另外编译器版本也有影响(What's New 明确提到 "depending on the platform and C compiler used")。
**结论:这 1%~10% 是你为「能用 8 个核」付的门票钱。**只要你能拿到 2x 以上的并行加速,这笔账就是压倒性划算的。反过来,如果你的服务本质是单线程的(比如典型的单进程 CLI 工具、或者已经用多进程打满 CPU 的服务),自由线程构建对你是纯亏。
4.2 顺带一提:解释器还在另一条线上加速
自由线程不是 3.14/3.15 唯一的性能故事,两条线是叠加的,评估时容易漏算。尾调用解释器(3.14 引入)用小 C 函数之间的尾调用实现每条字节码、取代巨大的 switch case,官方数字是 pyperformance 上几何平均快 3-5%;3.14 里它是 opt-in(--with-tail-call-interp,建议配合 PGO,只支持 Clang 19+ 的 x86-64/AArch64),到 3.15 官方 Windows 64 位二进制已默认启用。JIT 在 3.15 大幅升级(LLVM 21、新 tracing 前端、基础寄存器分配、GDB 栈回溯),x86-64 Linux 上相对标准解释器 8-9%、AArch64 macOS 上相对尾调用解释器 12-13%——但官方给这段数据挂了「结果尚未最终确定」,且 JIT vs 非 JIT 的区间是「约 15% 变慢到超过 100% 变快」,方差极大,别当免费加速。
4.3 语义变化:两个默认值的翻转
自由线程构建不是只有性能差异,它有两处可观察的行为差异,sys.flags 里的默认值被翻转了:
thread_inherit_context(自由线程默认 True,GIL 构建默认 False)。 为 True 时,用 threading.Thread 创建的线程会以「调用 start() 那个线程的 contextvars.Context 副本」启动,而不是空 Context。
context_aware_warnings(自由线程默认 True,GIL 构建默认 False)。 为 True 时,warnings.catch_warnings 用上下文变量存放警告过滤器;为 False 时它修改全局过滤器列表——而那是线程不安全的。
这两个改动是同一个动机的两面:让 catch_warnings、decimal.localcontext 这类「上下文管理器」在多线程下行为正确。What's New 明确说明:最显著的效果是 catch_warnings 建立的过滤上下文会被在该上下文中启动的线程(或 asyncio 任务)「继承」。
踩坑预警: 如果你有代码依赖「新线程从空 Context 开始」——比如用 contextvars 存 request id 并假设新线程不会误继承上一个请求的 id——在自由线程构建下会出现串号。这类 bug 在日志和链路追踪里表现为「A 用户的请求日志里带着 B 用户的 trace id」,排查起来极其痛苦。迁移时请务必审计所有 contextvars 的使用。
五、代码实战:从判定、基准到 C 扩展迁移
5.1 正确地判定你在哪种构建上
这是所有自由线程代码的第一行防线。有四种方式,各有适用场景,别混用:
import sys
import sysconfig
# 1) 人肉查看:-VV 和 sys.version 里会带 "free-threading build"
# $ python -VV
# Python 3.14.4 free-threading build (main, ...) [Clang ...]
# 2) 构建是否支持自由线程 —— 官方推荐用于「构建配置相关的决策」
supports_ft = sysconfig.get_config_var("Py_GIL_DISABLED") == 1
# 3) 运行时 GIL 是不是真的关掉了(注意:支持 != 已启用)
gil_on = sys._is_gil_enabled() # 3.13+ 才有
# 4) 兼容写法:老版本上不存在 _is_gil_enabled
def gil_enabled() -> bool:
fn = getattr(sys, "_is_gil_enabled", None)
return True if fn is None else fn()
**必须理解 2 和 3 的区别。**自由线程构建可以在运行时把 GIL 重新打开:
# 环境变量
PYTHON_GIL=1 python app.py # 强制开 GIL
PYTHON_GIL=0 python app.py # 强制关 GIL
# 命令行选项
python -X gil=1 app.py
python -X gil=0 app.py
更重要的是它会自动被打开。官方文档:
The GIL may also automatically be enabled when importing a C-API extension module that is not explicitly marked as supporting free threading. A warning will be printed in this case.
也就是说:你以为在跑自由线程,结果 import 了一个没标记支持的 C 扩展,GIL 被静默(其实有 warning,但通常淹在日志里)打开,性能一夜回到解放前。这是生产环境最常见的「为什么我升级了但没变快」的原因。
所以生产启动时应该做一次强断言,而不是相信自己的构建选项:
# startup_check.py —— 放在应用入口,导入所有依赖之后执行
import sys, sysconfig, warnings
def assert_free_threading(strict: bool = True) -> None:
def fail(msg: str) -> None:
if strict:
raise RuntimeError(msg)
warnings.warn(msg, RuntimeWarning, stacklevel=3)
if sysconfig.get_config_var("Py_GIL_DISABLED") != 1:
return fail("运行在 GIL 构建上:自由线程优化不会生效")
if sys._is_gil_enabled():
fail("这是自由线程构建,但 GIL 在运行时被启用了——"
"通常是某个 C 扩展没声明支持自由线程,或设置了 PYTHON_GIL=1 / -X gil=1。")
if __name__ == "__main__":
import mypackage # noqa: F401 先把全部依赖导进来
assert_free_threading(strict=True)
print("free-threading OK:", sys.version)
5.2 揪出「是哪个依赖把 GIL 打开了」
上面的断言只能告诉你「GIL 被打开了」,不能告诉你是谁。这个脚本可以:逐个在子进程里导入依赖,看谁会导致 GIL 被启用。
#!/usr/bin/env python3
"""ft_audit.py —— 逐个体检依赖对自由线程的支持情况。
用法:python ft_audit.py numpy pandas lxml orjson ...
必须用自由线程构建运行。
"""
import subprocess
import sys
import sysconfig
PROBE = (
"import sys, warnings\n"
"warnings.simplefilter('always')\n"
"before = sys._is_gil_enabled()\n"
"import {mod}\n"
"after = sys._is_gil_enabled()\n"
"print('GIL_BEFORE=%s GIL_AFTER=%s' % (before, after))\n"
)
def probe(mod: str) -> tuple[str, str]:
p = subprocess.run([sys.executable, "-X", "gil=0", "-c", PROBE.format(mod=mod)],
capture_output=True, text=True, timeout=180)
if p.returncode != 0:
return "IMPORT_FAILED", (p.stderr.strip().splitlines() or ["<no stderr>"])[-1]
if "GIL_AFTER=True" in p.stdout:
# 找出 CPython 打印的那句 warning
hint = next((ln.strip() for ln in p.stderr.splitlines()
if "GIL" in ln or "free-threaded" in ln), "")
return "ENABLES_GIL", hint
return "OK", ""
def main(mods: list[str]) -> int:
if sysconfig.get_config_var("Py_GIL_DISABLED") != 1:
print("必须在自由线程构建上运行本脚本", file=sys.stderr)
return 2
worst = 0
print(f"{'module':<24} {'status':<16} note")
print("-" * 78)
for mod in mods:
status, note = probe(mod)
print(f"{mod:<24} {status:<16} {note[:44]}")
if status in ("ENABLES_GIL", "IMPORT_FAILED"):
worst = 1
return worst
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:] or ["json", "hashlib"]))
把它挂进 CI,你就有了一道防线:**任何一次依赖升级如果引入了不兼容自由线程的扩展,CI 会红。**这比上线之后靠 APM 图表发现吞吐掉了一半要便宜得多。
另外两个官方推荐的生态追踪站点值得加进书签,用于升级前的可行性预判:py-free-threading.github.io/tracking/ 和 hugovk.github.io/free-threaded-wheels/。
5.3 「原子性错觉」的正确解法
回到 1.5 节那个 160 万次 += 一次没丢的实验。在自由线程构建上,同样的代码会开始丢更新。三种解法,性能差异巨大,选错了等于白迁移。
解法一:加锁(正确,但可能是性能杀手)
import threading
class Counter:
__slots__ = ("_v", "_lock")
def __init__(self):
self._v = 0
self._lock = threading.Lock()
def bump(self, n: int = 1) -> None:
with self._lock:
self._v += n
@property
def value(self) -> int:
with self._lock:
return self._v
正确,但 8 个线程高频争一把锁,你会把并行度重新压回 1——只是这次瓶颈从 GIL 换成了你自己的锁。如果你的迁移结果是「到处加 Lock」,你大概率不会变快。
解法二:分片(推荐的默认姿势)
争用的本质是「多个核写同一个缓存行」。那就让它们写不同的缓存行:
import threading
class ShardedCounter:
"""每个线程写自己的槽位,读的时候求和。"""
__slots__ = ("_slots", "_n")
def __init__(self, shards: int = 64):
self._n = shards
# 用 list 而不是 dict:索引写入不需要哈希、不会 resize
self._slots = [0] * shards
self._local = threading.local()
def _idx(self) -> int:
idx = getattr(self._local, "idx", None)
if idx is None:
# 线程 id 散列到槽位,尽量避免两个热线程撞同一槽
idx = (threading.get_ident() >> 4) % self._n
self._local.idx = idx
return idx
def bump(self, n: int = 1) -> None:
i = self._idx()
self._slots[i] += n # 仍非原子,但同槽位基本只有一个线程
def value(self) -> int:
return sum(self._slots) # 最终一致,读的瞬间可能略滞后
注意这里的取舍:value() 是最终一致的,读的瞬间可能漏掉正在写入的量。对于指标计数、限流统计、缓存命中率这类场景完全够用,而且几乎零争用。分片数建议取 CPU 核数的 4~8 倍。
解法三:线程本地累加 + 定期汇总(吞吐最高)
把分片推到极致:每个线程往自己的 threading.local() 字典里累加,热路径上一次锁都不碰;只在注册缓冲区和定期 flush 时碰一次全局锁。
class LocalAggregator:
def __init__(self):
self._local, self._buffers = threading.local(), []
self._global, self._lock = defaultdict(int), threading.Lock()
def add(self, key: str, n: int = 1) -> None:
buf = getattr(self._local, "buf", None)
if buf is None:
buf = self._local.buf = {}
with self._lock: # 每线程仅一次
self._buffers.append(buf)
buf[key] = buf.get(key, 0) + n # 纯线程本地,零争用
def flush(self) -> dict[str, int]:
with self._lock:
for buf in self._buffers:
for k in list(buf):
self._global[k] += buf.pop(k)
return dict(self._global)
代价是数据有延迟和实现复杂度。这是高吞吐服务里唯一真正能吃满多核的写法。
顺带纠正一个流传很广的误解: 有人说「用 itertools.count() 就是原子的,可以当无锁计数器」。这在 CPython 的 GIL 构建上碰巧成立(next() 在单条 C 调用里完成),但它从来不是语言保证。自由线程构建下不要依赖这种实现细节——回到 3.6 节引用的那句官方警告:这是对当前实现的描述,不是对当前或未来行为的保证。
5.4 从 multiprocessing 迁到 threading:什么时候真的赚
自由线程最实在的收益不是「多线程变快了」,而是**「可以不用多进程了」**。多进程的隐性成本远超大多数人的估算:
| 成本项 | 多进程 | 自由线程 |
|---|---|---|
| 启动开销 | fork/spawn,spawn 上需重新 import 全部模块 | 线程创建,微秒级 |
| 参数/返回值传递 | pickle 序列化 + IPC 拷贝 | 直接传对象引用,零拷贝 |
| 大只读数据(模型/索引/词表) | 每进程一份(或折腾 shm/mmap) | 天然共享一份 |
| 共享可变状态 | Manager 代理,每次访问一次 IPC 往返 | 普通对象 + 锁 |
| 内存总量 | N × 基线 | 1 × 基线 + 少量 |
| 调试与栈追踪 | 跨进程,痛苦 | 单进程,正常 |
| 异常传播 | 需可 pickle,栈信息常丢失 | 原生异常,栈完整 |
具体到一个场景——用 800MB 的只读索引做批量打分:多进程版要在 8 个 worker 里各 load_index() 一次(约 6.4GB 内存、几十秒启动),每批数据还要 pickle 进去、结果 pickle 出来;自由线程版只需把 index 直接闭包捕获传给 ThreadPoolExecutor,索引只有一份、零拷贝。
自由线程版省掉的东西:6GB 内存、8 次索引加载(可能是几十秒的启动时间)、两轮 pickle。这才是自由线程真正的杀手级场景——**不是纯 CPU 加速,而是「共享大只读状态 + 并行计算」这一类过去在 Python 里做得极其别扭的负载。**典型代表:推荐系统的召回打分、向量检索、规则引擎、大词表 NLP 预处理、游戏服务器的场景状态。
反过来,什么情况下不该迁?
- 任务是 I/O 密集的。 你早就该用 asyncio 或线程了,GIL 从来不是瓶颈,自由线程只会让你交那 1%~10% 的税。
- worker 之间完全无共享、且已经用多进程打满了 CPU。 迁过去只是把稳定的进程隔离换成了共享内存的竞态风险。
- 依赖里有大量小众 C 扩展。 参考 5.2 的体检脚本,先量化再决定。
- 强依赖进程隔离做故障域。 一个线程 segfault 会带走整个进程,一个 worker 进程崩了只是少一个 worker。
5.5 一个可复用的对照基准骨架
迁移这件事不该靠感觉。把下面这个骨架放进项目,用你自己的真实负载填进 workload(),然后在 GIL 构建和自由线程构建上各跑一遍:
#!/usr/bin/env python3
"""ft_bench.py —— GIL 构建 / 自由线程构建 对照基准骨架。
python3.14 ft_bench.py # GIL 构建
python3.14t ft_bench.py # 自由线程构建
python3.14t -X gil=1 ft_bench.py # 自由线程构建但开着 GIL(对照组!)
"""
import os, statistics, sys, sysconfig, time
from concurrent.futures import ThreadPoolExecutor
REPEAT = 5
def workload(seed: int) -> int:
"""把你的真实热点塞进来。这里放一个纯字节码占位实现。"""
acc = 0
for i in range(400_000):
acc = (acc * 1103515245 + 12345 + (i ^ seed)) & 0x7FFFFFFF
return acc
def run_threads(n: int) -> float:
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=n) as ex:
list(ex.map(workload, range(n)))
return time.perf_counter() - t0
def best_of(fn, n: int, repeat: int = REPEAT) -> float:
return min(fn(n) for _ in range(repeat)) # 取最小值,降低外部干扰
def main() -> None:
ft = sysconfig.get_config_var("Py_GIL_DISABLED") == 1
gil = getattr(sys, "_is_gil_enabled", lambda: True)()
cores = os.cpu_count() or 1
print(f"python={sys.version.split()[0]} free_threaded_build={ft} "
f"gil_enabled={gil} cpus={cores}")
base, n = best_of(run_threads, 1), 1
print(f"\n{'threads':>8} {'wall(s)':>10} {'speedup':>9} {'efficiency':>11}")
print("-" * 42)
while n <= cores:
t = best_of(run_threads, n) # n 线程各做 1 份工作 => 理想耗时 = base
print(f"{n:>8} {t:>10.3f} {base * n / t:>8.2f}x {base * n / t / n:>10.0%}")
n *= 2
if __name__ == "__main__":
main()
**关键是那个第三种跑法:python3.14t -X gil=1。**它是唯一能把「自由线程构建的固有开销」和「关掉 GIL 带来的收益」分离开的对照组。三组数据放在一起,你才能回答老板那个问题——「升级到底值不值」:
python3.14vspython3.14t -X gil=1的差 = 你交的税(应该落在 1%~10%)python3.14t -X gil=1vspython3.14t的差 = 你赚的钱(并行加速)
只跑前后两个数字,你永远说不清收益来自哪里。
5.6 C 扩展:最少需要改哪些行
如果你维护 C 扩展,这一节是必读的。**默认行为是:你的扩展会让整个进程退回 GIL 模式。**所以第一件事是声明支持。
声明支持(多阶段初始化,推荐):
static struct PyModuleDef_Slot module_slots[] = {
{Py_mod_exec, mymodule_exec},
#if PY_VERSION_HEX >= 0x030D0000
{Py_mod_gil, Py_MOD_GIL_NOT_USED}, /* ← 关键的一行 */
#endif
{0, NULL}
};
static struct PyModuleDef moduledef = {
PyModuleDef_HEAD_INIT,
.m_name = "mymodule",
.m_slots = module_slots,
};
声明支持(单阶段初始化,遗留代码):
PyMODINIT_FUNC
PyInit_mymodule(void)
{
PyObject *m = PyModule_Create(&moduledef);
if (m == NULL) {
return NULL;
}
#ifdef Py_GIL_DISABLED
PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED);
#endif
return m;
}
注意 PyUnstable_Module_SetGIL 只在自由线程构建里定义,所以 #ifdef Py_GIL_DISABLED 不能省。还有一个 Windows 专属的坑,3.14 改了行为:
From Python 3.14, when compiling extension modules for the free-threaded build of CPython on Windows, the preprocessor variable
Py_GIL_DISABLEDnow needs to be specified by the build backend, as it will no longer be determined automatically by the C compiler.
也就是说 Windows 上这个宏不再由编译器自动带上,必须由构建后端显式传。如果你在 Windows 上发现 #ifdef Py_GIL_DISABLED 里的代码全部没编进去,就是这个原因。
声明之后,真正的工作才开始。四条纪律:
纪律一:把借用引用换成强引用。 借用引用(borrowed reference)在容器可能被并发修改时不安全——你拿到指针的下一刻对象可能已经被别人从容器里删掉并析构了。官方给了完整替换表:
| 借用引用 API | 强引用替代 |
|---|---|
PyList_GetItem() / PyList_GET_ITEM() | PyList_GetItemRef() |
PyDict_GetItem() / PyDict_GetItemWithError() | PyDict_GetItemRef() |
PyDict_GetItemString() | PyDict_GetItemStringRef() |
PyDict_SetDefault() | PyDict_SetDefaultRef() |
PyWeakref_GetObject() / PyWeakref_GET_OBJECT() | PyWeakref_GetRef() |
PyImport_AddModule() | PyImport_AddModuleRef() |
PyCell_GET() | PyCell_Get() |
PyDict_Next() | 无替代,用临界区包住 |
不是所有借用引用都要换:PyTuple_GetItem() 是安全的(元组不可变);用 PyDict_GetItem() 解析关键字参数字典也是安全的(那个 dict 是私有的、别的线程拿不到)。判据是「这个容器会不会被别的线程改」,不是「这个函数叫什么名字」。
这些函数部分是 3.13 新增的,要兼容老版本可以用 pythoncapi-compat 提供的兼容实现。
纪律二:别用不做检查的宏。 PyList_GET_ITEM、PyList_SET_ITEM、PySequence_Fast_GET_SIZE 这些宏不做错误检查、不加锁。容器可能被并发修改时,它们全部不安全。
纪律三:PyDict_Next 必须包临界区。 容器类型(PyListObject、PyDictObject、PySetObject)在自由线程构建里会做内部加锁——比如 PyList_Append() 会先锁住 list。但 PyDict_Next() 是显著的例外,它不锁字典:
Py_BEGIN_CRITICAL_SECTION(dict);
PyObject *key, *value;
Py_ssize_t pos = 0;
while (PyDict_Next(dict, &pos, &key, &value)) {
/* 注意:value 是借用引用;如果这里会调用可能重入 Python 的代码,
先 Py_NewRef(value) 拿一个强引用 */
}
Py_END_CRITICAL_SECTION();
纪律四:分配域纪律从「最佳实践」升级成「硬要求」。 官方文档说得很直白:
For thread-safety, the free-threaded build requires that only Python objects are allocated using the object domain, and that all Python objects are allocated using that domain. This differs from the prior Python versions, where this was only a best practice and not a hard requirement.
也就是:PyObject_Malloc() 只能用来分配 Python 对象,Python 对象也只能用它分配。分配普通缓冲区要用 PyMem_Malloc()。过去混用只是不优雅,现在会出错——因为 QSBR 和 mimalloc 的元数据依赖这个不变量。建议直接全局搜一遍 PyObject_Malloc 逐个确认。
还有一条容易误会的:PyGILState_Ensure() / PyGILState_Release()、Py_BEGIN_ALLOW_THREADS 这些线程状态 API,在自由线程构建里仍然要用。它们管理的是线程状态(thread state),不只是 GIL。从 Python 之外创建的线程回调进 Python,依然必须先 PyGILState_Ensure()。别看到「GIL」两个字就删掉。
5.7 abi3t:从「每版一套轮子」到「一次编译通吃」
这是 3.15 最值得关注的工程改进,直接决定 C 扩展生态的迁移速度。
问题背景。 现有的稳定 ABI(abi3)对自由线程构建完全不可用:同时定义 Py_LIMITED_API 和 Py_GIL_DISABLED 会编译失败;而为 GIL 构建编出来的扩展在自由线程构建上会加载失败或崩溃。结果就是每个自由线程小版本都要单独出轮子:cp313t、cp314t、cp315t……对 SciPy、Cryptography、Pydantic 这种矩阵已经很大的项目,这是实打实的维护负担(PEP 803 的 Motivation 一节点名列举了这几个项目的诉求)。
PEP 803 的方案: 新增一个稳定 ABI 变体 abi3t,基于现有 abi3,但把 PyObject 结构体变成 opaque(不透明)。这是必要的——回看 3.1 节,自由线程构建的 PyObject 布局和 GIL 构建完全不同,只要扩展代码把 PyObject 内嵌进自己的实例结构体,ABI 就锁死在某一种构建上了。
代价是需要非平凡的源码改动,具体两条:
- 改用 PEP 697(Python 3.12 引入)的 API:用负的
PyType_Spec.basicsize和PyObject_GetTypeData()来放实例数据,而不是把PyObject作为实例结构体的第一个成员。 - 从
PyInit_函数改成新的导出钩子PyModExport_<modulename>(PEP 793 为此引入),配合 PEP 820 引入的新PySlot结构。
好消息是兼容性:**abi3t 3.15 与 abi3 3.15 是兼容的。**PEP 明确鼓励扩展作者同时为两个 ABI 编译,并用 wheel 标签 abi3.abi3t 声明兼容性——一个轮子,两种构建通吃。
现实的落地时间表要清醒看待。3.15 What's New 里写着:
At the time of writing, these tools do not support
abi3t.
Setuptools、meson-python、scikit-build-core、Maturin 这些构建工具当时还不支持 abi3t。所以现阶段的实际做法是:
- 能迁到
abi3t的项目:等构建工具支持,或者不用构建工具、手动设置Py_TARGET_ABI3T宏。 - 迁不了的(稳定 ABI 并没有暴露 CPython 的全部能力,有些扩展必须用非稳定 API):继续按现有方式,
abi3和版本专属的cp315t分别编。
官方还提供了一份 abi3t 迁移指南(docs.python.org/3.15/howto/abi3t-migration.html),要动手就从那里开始。
顺带提一下同在 3.15 的 PEP 788,它解决的是另一个长期折磨扩展作者的问题:解释器终结(finalization)期间附加线程状态会导致线程永久挂起。新增的三套 API 分别是解释器守卫(interpreter guards,阻止解释器终结)、解释器视图(interpreter views,安全访问可能正在终结的解释器)、以及带内建终结保护的自动 attach/detach API。如果你的扩展有后台线程,这套 API 值得优先关注——多线程 + 解释器关闭本来就是崩溃高发区,自由线程只会让它更频繁。
六、生产级调优清单
前面都是原理和迁移。这一章是把它跑好需要的东西,按优先级排。
6.1 头号敌人不是锁,是引用计数争用
再强调一遍 3.2 节的结论,因为它反直觉到几乎所有人第一次都会踩:自由线程下最常见的性能问题,不是你的 threading.Lock,而是共享对象引用计数导致的缓存行争抢。
症状是:加了线程,CPU 利润率上去了,吞吐没上去甚至下降;perf 看到大量时间花在原子指令和缓存未命中上。
规避手法,按有效性排序:
(1)让热点数据不可变或不朽。 配置字典改成模块级常量元组/frozenset;热点字符串键用字面量(会被不朽化)。
(2)每线程持有自己的副本。 注意一个常见误解:「只读就不改引用计数」是错的——读取容器元素同样会 incref。真正的热点小对象宁可每线程复制一份:
import threading
class PerThread:
"""每线程一份实例,彻底消除引用计数跨核争用。"""
def __init__(self, factory):
self._factory = factory
self._local = threading.local()
def get(self):
obj = getattr(self._local, "obj", None)
if obj is None:
obj = self._factory()
self._local.obj = obj
return obj
# 典型用途:正则、tokenizer、连接、缓冲区、复用的 buffer
_tokenizer = PerThread(lambda: build_tokenizer())
(3)批量化,减少接触次数。 与其在循环里对共享 list 逐个 append,不如在本地攒好一次 extend。接触共享对象的次数减少 N 倍,争用也减少 N 倍。
(4)分片,见 5.3 节。
6.2 内存:五个增长来源和对应的动作
官方文档明确列出了自由线程构建内存增长的原因。逐条对应到你能做的事:
| 增长来源 | 机制 | 你能做的 |
|---|---|---|
| interned 字符串全部不朽 | 永不回收 | 不要动态 sys.intern();不要把用户输入 intern |
| 非 GC 对象头更大 | 24 字节 vs 16 字节 | 无法规避;大量小对象场景(百万级 dataclass)预算按 1.2~1.5x 估 |
| QSBR 延迟释放 | 批量滞后回收 | 容器内存 limit 留更多 headroom;关注 RSS 峰值而非均值 |
| mimalloc vs pymalloc | 分配器行为差异 | 长跑服务观察碎片趋势,必要时定期重启(老办法,但有效) |
| 引用计数/每线程计数延迟释放 | 对象活得更久 | 不要依赖 __del__ 做资源释放,全面改用 with / 显式 close |
最后一行值得单独强调。这类代码在自由线程构建下会变成不定时炸弹:
# ❌ 依赖引用计数即时归零
def read_config(path):
return json.load(open(path)) # 文件句柄靠 refcount 归零来关
# ✅ 显式生命周期
def read_config(path):
with open(path) as f:
return json.load(f)
在 GIL 构建上第一种写法「能用」(CPython 的引用计数确实会立刻关掉它)。在自由线程构建上,对象可能活到下一次 GC——高并发下你会收到 Too many open files。
6.3 线程数不要等于核数
这是从 1.3 节的实测数据里直接得出的教训:8 核机器上进程版只拿到 3.78x,因为逻辑核里有一部分是能效核。自由线程构建面对完全相同的问题。
实践建议:
- **别写
ThreadPoolExecutor(os.cpu_count())就完事。**用 5.5 节的骨架跑一遍加速比曲线,找到 efficiency 开始明显跌落的那个点。 - 容器里
os.cpu_count()可能返回宿主机核数而不是 cgroup 配额。用os.process_cpu_count()(3.13+)或者读 cgroup quota。 - 混合架构(Apple silicon、Intel P/E core、ARM big.LITTLE)上,最优线程数常常是「性能核数」而非总核数。
- 有 GC 压力的负载,线程越多 stop-the-world 的代价越大(要停的线程更多)。这是另一个「线程数不宜过大」的理由。
6.4 把 GIL 开关当灰度手段用
-X gil=1 不只是调试工具,它是上线策略。这是自由线程迁移相比其他大版本升级独有的优势:
- 先升到自由线程构建,但用
PYTHON_GIL=1跑。此时行为等价于 GIL 构建(只是多付了那点税),验证你的依赖树、构建流程、镜像都 OK。 - 灰度一小部分实例切到
PYTHON_GIL=0,观察吞吐、p99、RSS、以及错误率(竞态会以「偶发的诡异数据错误」形式出现,不一定是崩溃)。 - 出问题秒回:改一个环境变量重启,不用回滚镜像。
**第 2 步要盯的是错误率,不是性能。**性能问题一眼能看出来,竞态会以「每天几十条无法复现的异常」形式渗出来。灰度期至少覆盖一个完整业务周期。
6.5 定位争用:3.15 给了两件新武器
自由线程下的性能分析比 GIL 时代难,因为瓶颈从「一把可见的锁」变成了「分布在各处的缓存争抢」。3.15 有两个直接相关的改进:PEP 799 带来 Tachyon 高频统计采样 profiler 和一个统一的 profiling 包——高频采样正适合定位「时间花在哪个函数的原子操作上」,而传统确定性 profiler(cProfile)在多线程下开销大、还会改变争用特征;PEP 831 默认启用帧指针(frame pointers),意味着 perf、eBPF 工具、py-spy 能可靠展开 Python 栈,你可以不改代码、不重启进程就从系统层面看到 8 个线程各自卡在哪。加上 3.14 的 PEP 768(安全的外部调试器接口),「附加到正在跑的多线程 Python 进程上看现场」从黑魔法变成了官方能力。
6.6 决策树:你现在该不该上
把前面所有讨论压缩成一个可执行的判断流程:
你的瓶颈是 CPU 还是 I/O?
├─ I/O 为主 ──▶ 不要迁。asyncio / 线程池已经够了,
│ 自由线程只让你白交 1%~10% 的税。
│
└─ CPU 为主
│
├─ 现在已经用多进程且工作完全无共享、内存也不紧张?
│ ──▶ 不急。收益主要是运维简化,风险大于回报。
│
├─ 有「大块只读共享状态 + 并行计算」的形态?
│ (模型权重 / 索引 / 大词表 / 规则集 / 场景状态)
│ ──▶ 最高优先级迁移目标。收益不只是 CPU,
│ 还有内存(N倍→1倍)、启动时间、调试体验。
│
└─ 纯 CPU、无共享、进程数受内存限制?
──▶ 值得迁。先跑 5.2 依赖体检 + 5.5 三组对照基准。
│
├─ 体检发现关键依赖会打开 GIL
│ ──▶ 等生态。同时去上游提 issue / 贴 tracking 站点数据。
│
└─ 体检通过
──▶ 按 6.4 的三步灰度上线,
重点盯错误率而非吞吐。
七、生态现状:一句冷静的判断
技术上,自由线程 Python 已经成立:PEP 779 的五条判据全部有了答案,性能税降到个位数,API 稳定,3.15 补上了稳定 ABI 这块最后的大拼图。工程上,瓶颈从来不在 CPython,而在生态的迁移速度——并且必须正视:PEP 803 的迁移需要非平凡的源码改动,不是加个编译选项就能过的事。
所以我对当下到 2027 年的判断是:
- **纯 Python 库:基本已就绪。**问题不是「跑不跑」,而是「有没有藏着竞态」——需要的是测试,不是移植。
- **头部 C 扩展(NumPy、SciPy、Cryptography、Pydantic、lxml…):在路上或已完成。**PEP 803 的 Motivation 直接引用了这些项目的诉求,说明上游是深度参与者。
- **长尾 C 扩展:真正的瓶颈。**那些三年没更新的包会把整个进程拖回 GIL 模式。你的迁移可行性由依赖树里最烂的那一个包决定。
做这个判断别靠猜,用官方推荐的两个跟踪站点:py-free-threading.github.io/tracking/ 和 hugovk.github.io/free-threaded-wheels/。
也别忘了 PEP 779 自己那句坦白话:**「这不是一个 drop-in 的方案——部分代码必须重新设计,才能吃到新能力,也才能避开性能陷阱。」**任何承诺「换个构建就快 8 倍」的说法,都没读过这句。
八、总结:一个前提的消失,比一个特性的增加重要得多
回头看这条时间线:
| 时间 | 事件 | 意义 |
|---|---|---|
| PEP 703 被接受 | 规划三阶段路线 | 「可以拿掉」被承认 |
| Python 3.13 | 自由线程构建可用(实验性) | Phase I,能玩了 |
| 2025-06-16 | PEP 779 通过 | 定义了「做好了」的判据 |
| 2025-10-07 | Python 3.14 发布 | Phase II:官方支持;PEP 659 特化在 FT 下启用,税降到个位数 |
| 2026-03-30 | PEP 803 通过 | abi3t:一个轮子通吃两种构建 |
| Python 3.15(rc 中) | abi3t 落地 + JIT 大升级 + Tachyon + 帧指针 | 生态迁移的工具齐了 |
| 未来 | Phase III:自由线程成为默认 | 门槛是生态覆盖率,不是技术 |
技术上最漂亮的部分,是那五根支柱之间的咬合关系:偏向引用计数解决高频路径,不朽对象和延迟计数解决全局热点,mimalloc + QSBR 让无锁读成立,每对象锁 + 挂起即释放的临界区解决结构一致性且不死锁。任何一根抽掉,方案都不成立。这不是「删了一个锁」,是把三十年前的一个架构决策,在不破坏向后兼容的前提下,重新做了一遍。
给三类人的具体建议:
应用开发者。 现在就装一个自由线程构建,用 5.5 的骨架跑你的热点代码、跑那三组对照。哪怕结论是「暂时不迁」,你得到的也是一个能写进技术方案的量化判断,而不是一句「听说 GIL 没了」。同时回头审计代码里所有 contextvars、所有依赖 __del__ 释放资源的地方、所有「反正 GIL 会保护我」的共享可变状态——这些工作在迁移之前就有价值,因为它们本来就是 bug,只是被 GIL 盖住了。
C 扩展维护者。 Py_mod_gil 那一行今天就可以加——但加之前请先真正做到线程安全,别为了不打印 warning 而撒谎,那会把用户送进随机崩溃。然后按 5.6 的四条纪律过代码,特别是借用引用替换表和分配域纪律。abi3t 可以等构建工具跟上,但 PEP 697 风格的类型定义现在改最便宜。
技术选型者。 请把「自由线程 Python」和「Python 变快了」分开。前者改变的是架构可能性——你终于能在 Python 里写「共享大状态 + 多核并行」,而过去这要么用多进程加共享内存硬撑、要么直接换语言。后者(尾调用解释器、JIT)是另一条独立的线,收益更小、方差更大。真正值钱的是前者。
最后一句:过去我们说「Python 不适合 CPU 密集」,完整版其实是「Python 不适合在单进程内做 CPU 密集」。现在这个限定条件正在消失。它不会让 Python 变成 Rust,但它让一大类过去必须换语言、或者必须写得很别扭的架构,重新回到了可选项里。
对一门三十多年的语言来说,能把一个如此底层的前提拆掉重做,还做到向后兼容、性能税只有个位数——这件事本身,比任何单个新特性都更值得记一笔。
附:可复现材料与一手来源
实测环境:8 核 arm64(4 性能核 + 4 能效核),Python 3.14.4(GIL 构建,Clang 21),Py_GIL_DISABLED=0。三组数据:纯字节码 collatz_steps → threads 0.99x / processes 3.78x(1.3 节);hashlib.sha256 4KB × 6 万轮 → threads 0.43x / processes 2.67x(1.4 节);8 线程 × 20 万次 counter += 1 → GIL 构建上丢失 0 次(1.5 节)。脚本见 1.3、5.1、5.2、5.5 节,可直接复制运行。
一手来源(建议读原文):PEP 703、779(2025-06-16 通过)、803(2026-03-30 通过);Python 3.14/3.15 What's New 的自由线程、尾调用解释器、JIT 章节;HOWTO《Python support for free threading》与《C API Extension Support for Free Threading》;CPython 源码 Include/object.h。