本节目标:讲清 PEP 703 如何在不加锁的前提下让 CPython 去掉 GIL,并说明它带来的对象头膨胀、单线程开销与 C 扩展迁移成本。
适用版本:Python 3.12+(实测 3.14.6)
5.2 自由线程构建与迁移影响
上一节我们看到 GIL 把 CPU 密集多线程压成 1.0x。自由线程(free-threading,PEP 703)就是要拆掉这把锁。但拆锁不是删掉一行代码——引用计数本身就不是线程安全的,去掉 GIL 后每一次 ob_refcnt 自增都要重新设计。这一节先如实交代本机环境,再拆 PEP 703 的核心机制。
5.2.1 先看本机:这不是自由线程构建
本节最有价值的「实测」是一条否定结论——必须先把话说清楚,避免读者误以为下面的性能数字来自本机。
import sys, sysconfig
print(sys._is_gil_enabled()) # True
print(sysconfig.get_config_var("Py_GIL_DISABLED")) # 0
print("free-threading build" in sys.version) # False
print(sys.version)
# 3.14.6 (main, Jun 10 2026, 10:03:53) [Clang 21.0.0 (clang-2100.0.123.102)]
三个判据一致:_is_gil_enabled() 为 True、Py_GIL_DISABLED 为 0、sys.version 里没有 free-threading build 字样。再试运行期开关,标准构建会直接报错退出:
$ PYTHON_GIL=0 python -c "print('x')"
Fatal Python error: config_read_gil: Disabling the GIL is not supported by this build
$ python -X gil=0 -c "print('x')"
Fatal Python error: config_read_gil: Disabling the GIL is not supported by this build
**因此本节无法给出本机的自由线程性能数字。**下面关于性能的描述全部来自官方文档,我会标注出处;本机验证的部分只有上面这几条「构建类型判据」。判断一台机器能否跑自由线程,标准做法就是下面这组开关:
| 检查项 | 标准构建(本机) | 自由线程构建 |
|---|---|---|
sysconfig.get_config_var("Py_GIL_DISABLED") | 0 | 1 |
sys._is_gil_enabled() | True | 默认 False,可用 PYTHON_GIL=1 / -X gil=1 临时开回 |
sys.version | 无标记 | 含 free-threading build |
| 可执行文件名 | python3.14 | python3.14t(ABI 标签带 t) |
| 构建方式 | 默认 | ./configure --disable-gil && make |
如果你要自己拿一个自由线程解释器,官方路径是从源码构建(本机未执行,仅列出流程):
# 从源码构建一个自由线程解释器(官方 --disable-gil 开关)
./configure --disable-gil --with-ensurepip=install
make -j
make altinstall # 得到 python3.14t,与标准 python3.14 共存
也可以用官方为各平台提供的预编译 free-threaded 安装包(python3.14t)。注意 --disable-gil 构建的 ABI 标签带字母 t,与标准构建是两套 ABI——纯 Python 包通常两边都能装,但 C 扩展必须针对目标 ABI 分别编译,这也是生态迁移慢的根本原因。
5.2.2 对象头改造:一个对象要同时服务多个线程
去掉 GIL 后,引用计数不能再是一把全局锁下的普通整数。PEP 703 的做法是给每个对象加一份自己的同步状态,把对象头从「一个 Py_ssize_t ob_refcnt」扩成一组字段:
typedef struct _object {
uint32_t ob_tid; /* 4B:对象"属主"线程 id */
uint16_t __padding; /* 2B:预留 */
PyMutex ob_mutex; /* 1B:每个对象一把互斥锁 */
uint8_t ob_gc_bits; /* 1B:GC 标志(原来存在 PyGC_Head) */
uint32_t ob_ref_local; /* 4B:属主线程的本地引用计数 */
Py_ssize_t ob_ref_shared; /* 共享引用计数 + 状态位 */
PyTypeObject *ob_type;
} PyObject;
(字段布局引自 PEP 703 规范章节。)三个关键点:
- 偏向引用计数(biased reference counting):对象有一个「属主线程」。属主自己增减引用只动
ob_ref_local(快路径,无原子操作);其他线程动ob_ref_shared(慢路径,需要原子操作)。这样单线程程序的引用计数操作几乎不退化。 - 每个对象一把
PyMutex:不是全局锁,而是细粒度对象锁,只在实际发生竞争时使用。 ob_gc_bits把 GC 标志搬进对象头:因为分代 GC 也依赖 GIL,去锁后必须让 GC 能在对象级别协作。
偏向引用计数的「偏向」体现在读写路径的不对称上。ob_ref_shared 不是单纯的计数,而是把低位当作状态位使用:
| 状态 | 含义 | 归零时的动作 |
|---|---|---|
默认(0b00) | 纯共享计数 | 直接释放对象 |
Weakref(0b01) | 存在弱引用 | 走弱引用清理流程 |
Queued(0b10) | 已入合并队列 | 等待队列合并 |
Merged(0b11) | 已合并 | 合并完成后再判定 |
属主线程走快路径(只动 ob_ref_local,不碰原子操作、不碰 ob_mutex);其他线程走慢路径(原子地改 ob_ref_shared)。这套设计的目标很明确:单线程程序的引用计数开销尽量不退化,代价只在真正跨线程共享对象时才付出。
5.2.3 减少锁的巧思:延迟引用计数、永生对象与临界区
只靠「偏向 + 对象锁」还不够——很多高频操作(比如加载模块、调用类方法)会不断触碰全局对象,每次加锁成本太高。PEP 703 叠加了三层优化:
- 延迟引用计数(deferred reference counting):对特定类型的对象,用「按对象标志位」开启一种「不即时增减、由 GC 批量处理」的计数模式。官方 howto 明确列出适用类型,包括模块对象、模块顶层函数、类中定义的方法等——都是长期存活、引用变化相对可控的对象。
- 永生对象(immortalization):像
None、True、小整数、驻留字符串这类永不释放的对象,直接把引用计数设为「永生」值,彻底跳过增减操作。代价是:自由线程构建下所有 interned 字符串都变成永生,内存只增不减。 - Python 临界区(critical sections)与乐观访问:对
dict/list的常见操作,CPython 采用「乐观」策略——先不加锁直接读,用一个版本号/状态位校验是否被打断,只有冲突时才回退到加锁。这避免了读多写少场景下的锁竞争。
这三层叠加起来,目标只有一个:让「无 GIL」的代价尽量落在多线程竞争上,而不是落在单线程性能上。
5.2.4 无 GIL 不等于不用锁
一个常见误解是「自由线程后多线程就万能了」。恰恰相反,去掉 GIL 后应用层要自己面对的竞态变多了:GIL 曾经顺手保证的「单条字节码原子性」不复存在(回想 5.1.4:裸 counter += 1 在标准构建上零丢失,在自由线程构建上就不再保证)。你需要的 threading.Lock、RLock、Queue 一个都不会少,甚至更需要。
官方 howto 还列出几条已知限制,都是「无 GIL 后仍不安全」的典型:
- frame 对象:如果某个 frame 正在另一个线程里执行,访问它的
f_locals可能直接让解释器崩溃。 - 迭代器:多个线程并发访问同一个迭代器对象不是线程安全的,可能出现重复或遗漏元素。
- 上下文变量:自由线程构建下
thread_inherit_context默认为True,新线程会继承创建者的 context,行为与标准构建不同。
5.2.5 C 扩展迁移:Py_mod_gil 与自动回退
对 C 扩展作者来说,迁移成本集中在一点:你的扩展是否线程安全。CPython 的设计是「默认保守」:
- 扩展模块通过多阶段初始化声明
Py_mod_gil槽位,显式告诉解释器「我已经线程安全,可以关 GIL 跑」。 - 如果导入了一个没有声明该槽位的 C 扩展,解释器会自动把 GIL 重新打开(howto 原文:The GIL may also automatically be enabled when importing a C-API extension module that is not explicitly marked as safe)。
这意味着:一个自由线程构建的 Python,一旦 import 了未适配的 C 扩展,就会悄悄退回 GIL 模式——你的多线程加速可能莫名其妙消失。这也是为什么迁移评估的第一步是盘点依赖树里的 C 扩展(NumPy、Pandas、数据库驱动等)是否已适配。
扩展侧声明「我可以关 GIL 跑」的写法,是在多阶段初始化的模块定义里加一个 Py_mod_gil 槽位:
static PyModuleDef_Slot slots[] = {
/* 声明本扩展在无 GIL 下线程安全 */
{Py_mod_gil, Py_MOD_GIL_NOT_USED},
{0, NULL}
};
static struct PyModuleDef moddef = {
PyModuleDef_HEAD_INIT, "myext", NULL, 0, methods, slots, NULL, NULL, NULL
};
(上述 C 代码为示意,本机未编译——第 9 章会真编译 C 扩展。)反之,若扩展没有这个槽位,解释器就按「不安全」处理,自动把 GIL 打开。
5.2.6 性能代价与版本路线
单线程性能(来自官方文档,非本机实测):
- Python 3.14 的 What’s New 表述:自由线程模式下单线程性能损失约为 5%~10%,取决于平台与 C 编译器。
pyperformance基准套件的平均值:macOS aarch64 约 1%,x86-64 Linux 约 8%。- 3.14 的一个重要进展:专门化自适应解释器(PEP 659)在自由线程模式下已启用,这是 3.13 到 3.14 性能大幅改善的主要原因。
版本时间线(PEP 703 官方规划):
| 阶段 | 版本 | 状态 |
|---|---|---|
引入 --disable-gil 构建开关,出现两种 ABI | 3.13 | 已发布(实验性) |
| 完成 PEP 703 实现、C API 收敛、自适应解释器启用 | 3.14 | 已发布(本机 3.14.6 为标准构建) |
| GIL 由运行期环境变量/开关控制,合并为单一 ABI | 约 2026–2027 | 规划中 |
| 自由线程成为默认 | 约 2028–2030 | 规划中 |
3.13 到 3.14 之间的自由线程进展值得单独列一下,因为 3.14 是本机基线版本:
| 维度 | 3.13(首次引入) | 3.14(本机基线) |
|---|---|---|
| PEP 703 实现完成度 | 实验性、有临时绕行方案 | 实现完成,C API 收敛,临时方案被替换 |
| 专门化自适应解释器(PEP 659) | 未启用 | 已启用,性能大幅改善 |
| 单线程开销(官方口径) | 更高 | 约 5%~10%(pyperformance 均值 macOS aarch64 约 1%) |
| Windows 扩展编译 | Py_GIL_DISABLED 自动判定 | 需构建后端显式指定 |
注意时间线是 PEP 703 在 2023 年写下的相对规划(「再 2~3 个版本」),并非承诺的具体版本号。判断某版本是否已默认自由线程,以该版本实际的
Py_GIL_DISABLED默认值和发布说明为准,不要照搬规划年份。
5.2.7 今天就能用的并行:PEP 734 子解释器(本机实测)
自由线程要等生态,但 3.14 已经把 PEP 734 的 concurrent.interpreters 落地了,而且它在标准构建上就能用——每个子解释器拥有自己的 GIL(PEP 684 的 per-interpreter GIL)。本机实测确认模块可用:
import concurrent.interpreters as iv
print(iv.create()) # Interpreter(1)
print([x for x in dir(iv) if not x.startswith("_")])
# ['ExecutionFailed','Interpreter','InterpreterError','Queue','create','get_main', ...]
关键问题是:每个子解释器一个 GIL,能不能真的并行跑 Python? 让 k 个子解释器各在自己的线程里跑满一份 CPU 计算(N=8_000_000),与「单解释器串行跑 k 份」对比:
import concurrent.interpreters as iv
import threading, time
SRC = "def work(n):\n s=0\n for i in range(n): s += i*i\n return s\n"
N = 8_000_000
def serial(k): # 单解释器顺序跑 k 份
t0 = time.perf_counter()
for _ in range(k):
s = 0
for i in range(N): s += i*i
return time.perf_counter() - t0
def parallel(k): # k 个子解释器 + k 个线程
interps = []
for _ in range(k):
it = iv.create(); it.exec(SRC); interps.append(it)
ths = []
def target(it): it.exec(f"__res = work({N})")
t0 = time.perf_counter()
for it in interps:
th = threading.Thread(target=target, args=(it,)); th.start(); ths.append(th)
for th in ths: th.join()
dt = time.perf_counter() - t0
for it in interps: it.close()
return dt
1 op (main interp) : 0.394s
k=2: serial=0.682s 2-interp-parallel=0.368s speedup_vs_serial=1.85x
k=4: serial=1.441s 4-interp-parallel=0.348s speedup_vs_serial=4.15x
4 个子解释器在 4 个线程上拿到 4.15x 加速(几乎线性),因为每个子解释器持有独立的 GIL,互不阻塞。这与 5.1 里普通多线程的 1.0x 形成鲜明对照——同样是标准构建,同样是 threading,换一种执行单元就能真并行。
不过子解释器有硬约束:对象不能随意跨解释器共享(iv.is_shareable() / NotShareableError 就是为这个设计的),通信要走 iv.create_queue()。它更像「轻量级进程」,适合任务彼此独立的 CPU 密集场景,而不是共享大量可变状态的多线程程序。
5.2.8 迁移判断框架
由于本机无法实测自由线程,这里给一个纯逻辑的评估清单,帮你在升级前做判断:
- 负载是否真的受益:只有 CPU 密集、且是纯 Python 或已适配的 C 扩展,才可能从自由线程获益;I/O 密集在标准构建上已经能加速(见 5.1.3),不必迁移。
- 依赖里有多少 C 扩展:任一未声明
Py_mod_gil的扩展都会让 GIL 自动回退,迁移收益归零。 - 能否接受单线程 5%~10% 的回退:对延迟敏感的单线程服务要谨慎。
- 内存预算:永生字符串 + 更大的对象头会让常驻内存上升。
下一节 多进程、共享内存与 IPC 选型 会给出另一条不依赖解释器演进的多核路线。
小结
- 本机(3.14.6)是标准构建:
Py_GIL_DISABLED=0、_is_gil_enabled()=True、PYTHON_GIL=0与-X gil=0均报 fatal error。本节性能数字均来自官方文档,非本机实测。 - PEP 703 给每个对象加了
ob_ref_local/ob_ref_shared/PyMutex等字段,用偏向引用计数让属主线程走无原子操作的快路径。 - 延迟引用计数(按对象标志位)、永生对象、
dict/list的乐观访问三层叠加,把去锁代价尽量从单线程移走。 - 自由线程不消除竞态:frame
f_locals、共享迭代器都被官方列为不安全;应用层仍需自己加锁。 - 导入未声明
Py_mod_gil的 C 扩展会自动把 GIL 开回来;单线程开销官方口径约 5%~10%(pyperformance 在 macOS aarch64 约 1%)。 - 不用等自由线程:3.14 的 PEP 734 子解释器(
concurrent.interpreters)在标准构建上本机实测 4 线程 4.15x,每个子解释器持有独立 GIL,代价是对象不能随意跨解释器共享。
本节讲的是「未来的方向」,下一节 多进程、共享内存与 IPC 选型 讲的是「今天的多核手段」。
阅读导航:上一节:GIL 的实现与影响边界 · 下一节:多进程、共享内存与 IPC 选型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。