CPython 自由线程深度实战:从 PEP 703 的偏向引用计数、临界区到 mimalloc,无 GIL 时代的并发工程全景
执行摘要:Python 3.14(2025 年 10 月)把自由线程(free-threaded)从实验特性升级为正式支持(PEP 779),单线程性能损耗从 3.13 实验期的 25%–40% 收敛到 5%–10%,3.15 alpha 在 Linux x86-64 上约 9%、macOS ARM64 上约 6%。但"删掉一把锁"这件事在工程上远不是删一行代码:它牵动了 CPython 最核心的四个子系统——引用计数、对象内存布局、分配器、专门化解释器。本文拆解 PEP 703 的四块基石(偏向引用计数、不朽化、细粒度锁与临界区、延迟引用计数 + mimalloc),解释为什么 3.13t 会慢 40%,给出 C 扩展迁移的 Py_mod_gil 实战代码与 borrowed reference 陷阱,并提供一份可执行的落地决策清单。核心结论:自由线程不是"更快的 Python",而是"可并行的 Python"——它解决的是 GIL 之外无处安放的那部分 CPU 并行需求,代价是内存上涨 15%–20% 与整个扩展生态的重新认证。
一、先搞清楚 GIL 到底保护了什么
大多数人对 GIL 的理解停留在"同一时刻只有一个线程跑字节码"。这只说对了表象。GIL 真正在保护的是三样东西,而移除它的全部工程复杂度,都来自于为这三样东西找替代方案。
第一,引用计数的原子性。 CPython 用引用计数做主内存管理,每一个 Py_INCREF / Py_DECREF 都是对对象头里那个 Py_ssize_t ob_refcnt 的读改写。没有 GIL,两个线程同时 decref 同一个对象就是标准的 data race——轻则计数错乱导致悬垂指针,重则 double free。如果把所有 incref/decref 换成原子指令,单线程性能会立刻掉一大截,因为这是 CPython 里执行频率最高的操作,没有之一。
第二,容器内部状态的完整性。 list.append、dict.__setitem__ 在字节码层面看起来是"一条指令",但底层要走 resize、rehash、freelist 回收等若干步。GIL 保证了这些步骤不会被另一个线程插进来看到中间态。
第三,C 扩展作者的心智模型。 二十多年来,无数 C 扩展依赖"我这段代码执行期间不会有别的 Python 线程跑"这个隐式契约,把全局状态、静态缓存、borrowed reference 都压在这个假设上。GIL 消失,这个契约整体失效。
理解了这三点,就会明白为什么 PEP 703 不是"把锁去掉",而是用四套更细的机制分别替代这三类保护。
二、PEP 703 的四块基石
2.1 偏向引用计数:让 99% 的操作走非原子快路径
关键洞察是:绝大多数对象在任何时刻只被一个线程访问。既然如此,就没必要让所有线程都为它付原子指令的代价。
偏向引用计数的做法是给对象记录一个"拥有者线程 id":
// 简化后的 PyObject 头(自由线程构建)
typedef struct {
uintptr_t ob_tid; // 拥有者线程 id
uint16_t _padding;
uint8_t ob_mutex; // 每对象一把轻量锁
uint8_t ob_gc_bits;
uint32_t ob_ref_local; // 拥有者线程的非原子计数
Py_ssize_t ob_ref_shared; // 其它线程用的原子计数(打包了"共享标记"位)
PyTypeObject *ob_type;
} PyObject;
- 快路径:当前线程 ==
ob_tid,直接对ob_ref_local做普通加减,编译器可以寄存器缓存,几乎零成本。 - 慢路径:跨线程访问,改用
ob_ref_shared上的原子操作,并把要减的增量以"欠账"形式记在线程本地,避免每次都碰共享字段。 - 回收合并:当对象重新变成单线程私有(或进入 GC 的安全点),把 shared 侧的值合并回 local,让对象重新回到快路径。
代价很直接:PyObject 头从 16 字节膨胀到约 32 字节。这就是为什么自由线程构建的内存占用会涨 15%–20%——不是泄漏,是每多一个活对象就多付 16 字节。
2.2 不朽化:把进程级热点从竞争里摘出去
True、False、None、小整数、interned 字符串、静态类型对象——这些对象的 refcount 是全进程所有线程都在疯狂读写的超级热点。如果连它们都要走原子操作,缓存行会在核间疯狂乒乓。
不朽化的做法极其粗暴也极其有效:把它们的 refcount 设成一个哨兵极大值(如 UINT32_MAX 对齐的量级),永不增减、永不释放。判定时先检查是否不朽,是就直接跳过整个 incref/decref 路径。
static inline void Py_INCREF(PyObject *op) {
// 自由线程构建下的简化逻辑
if (_Py_IsImmortal(op)) {
return; // 什么都不做
}
uintptr_t tid = _Py_ThreadId();
if (op->ob_tid == tid) {
op->ob_ref_local++; // 非原子快路径
} else {
_Py_atomic_add_ssize(&op->ob_ref_shared, (1 << _Py_REF_SHARED_SHIFT));
}
}
这是一个很典型的工程取舍:用"放弃精确性"换"消除竞争"。这些对象本来也就是永不释放的,让它们的计数变得"不精确"没有任何语义损失。
2.3 细粒度锁与临界区:C API 层的死锁免疫
去掉大锁后,容器需要自己的锁。但如果让 C 代码手动 PyMutex_Lock / Unlock,嵌套调用和异常跳转让开发者必然写出死锁。CPython 引入了临界区宏:
static PyObject *
cache_set(MyCache *self, PyObject *args)
{
PyObject *key, *value;
if (!PyArg_ParseTuple(args, "OO", &key, &value)) {
return NULL;
}
// 临界区可嵌套,退出时(含异常早退)自动按逆序释放
Py_BEGIN_CRITICAL_SECTION(self->dict);
Py_BEGIN_CRITICAL_SECTION(self->index);
if (PyDict_SetItem(self->dict, key, value) < 0) {
Py_END_CRITICAL_SECTION2(); // 早退也要正确配对
return NULL;
}
PyList_Append(self->index, key);
Py_END_CRITICAL_SECTION2();
Py_ReturnNone;
}
临界区的关键设计是可重入 + 隐式释放:底层实现维护一个 per-thread 的锁栈,退出作用域(包括 return NULL 的异常路径)时自动弹栈解锁。这让"加锁"变成了作用域而非纪律问题。
但这里有一个必须讲清的认知陷阱:官方明确声明,内置容器内部的这些锁不构成对未来行为的保证。list.append 单个操作是线程安全的,但"先检查再写入"这类复合操作在无 GIL 下不再有任何隐式串行化语义:
# 有 GIL 时"看起来"正确,无 GIL 下是标准竞态
if key not in shared_dict:
shared_dict[key] = compute(key) # 两个线程可能同时 compute
# 正确写法:业务层显式加锁,或用原子原语
with lock:
if key not in shared_dict:
shared_dict[key] = compute(key)
# 更好的写法:用 dict.setdefault / 但注意 value 仍会被预先求值
val = shared_dict.setdefault(key, sentinel)
2.4 内存管理:延迟引用计数 + mimalloc + QSBR
三件事一起做:
- 延迟引用计数(deferred refcounting):栈顶、模块 dict 里这类"不可能由本操作导致最后一个引用消失"的对象,干脆不 incref/decref,把账留给 GC 去算。这直接消掉了大量无谓的原子操作。
- 分配器从 pymalloc 换成 mimalloc:pymalloc 的 arena 是全局共享的,多线程下需要锁。mimalloc 的 thread-local heap + 跨线程 free 的延迟回收队列,天然与自由线程契合,并且自带更好的碎片表现。
- QSBR 安全回收:某个线程正在无锁读一个对象时,另一线程不能立刻
free它。自由线程构建让线程周期性上报"静默态"(quiescent state),所有线程都经过静默态之后才真正回收内存——这是用户态 RCU 的经典套路,避免了读侧任何同步开销。
顺带一个重要变化:GC 从分代改为非分代。分代 GC 依赖"对象年龄"的全局统计,在自由线程下代价过高;3.14 的自由线程构建改为非分代 GC,配合延迟引用计数工作。
三、为什么 3.13t 慢 40%,3.14 只慢 5%–10%
这是整个项目里最容易被忽略、但工程上最有信息量的一段。
3.13t 的实验期,为了让自由线程构建先跑起来,专门化自适应解释器(PEP 659 的 specializing interpreter)被整个禁用。这意味着所有 BINARY_OP、LOAD_ATTR、CALL 都退回通用慢路径——那 20%–40% 的回退,大头根本不是原子操作的成本,而是丢了专门化。
3.14 做的工作是把专门化解释器重新适配到无 GIL 语义下:
- 内联缓存里存的是 borrowed reference,需要按 unicode/int 的实际类型做特化,且必须在安全点失效;
- 专门化指令的 deopt 路径需要与 QSBR 协作,避免一个线程正在执行专门化代码时另一个线程把底层对象回收掉;
- 关键指令(如
LOAD_GLOBAL、STORE_ATTR)的版本号校验改成原子读 + 单侧失效。
结果是单线程损耗收敛到 5%–10%。这个数字的意义在于:它证明了自由线程的成本主要来自"工程未适配"而非"理论不可能",后续仍有压缩空间。
四、C 扩展迁移:Py_mod_gil 与 borrowed reference 陷阱
自由线程构建不与传统 ABI 兼容(对象头变了),扩展必须重新编译,wheel 走独立的 t ABI tag(如 cp314t)。更重要的是显式声明:
#define PY_SSIZE_T_CLEAN
#include <Python.h>
// 方式一:老式槽位(3.13+ 支持)
static PyModuleDef_Slot module_slots[] = {
{Py_mod_gil, Py_MOD_GIL_NOT_USED}, // 我支持无 GIL
{Py_mod_multiple_interpreters, Py_MOD_PER_INTERPRETER_GIL_SUPPORTED},
{0, NULL}
};
static struct PyModuleDef mymodule = {
.m_base = PyModuleDef_HEAD_INIT,
.m_name = "mymodule",
.m_size = 0,
.m_slots = module_slots,
};
最危险的运行时行为:只要 import 一个没有声明 Py_mod_gil 的扩展,解释器会静默重新启用 GIL 并打一条警告。你的程序从"并行"悄悄退回"串行",而且没有任何异常。因此生产上必须显式自检:
import sys, sysconfig
def assert_free_threaded() -> None:
built_free = bool(sysconfig.get_config_var("Py_GIL_DISABLED"))
gil_off = not sys._is_gil_enabled() # 运行时真实状态
print(f"build: Py_GIL_DISABLED={built_free}, runtime GIL enabled={not gil_off}")
if built_free and not gil_off:
# 通常意味着某个 C 扩展把 GIL 又开了回来
raise RuntimeError("GIL re-enabled at runtime: 检查是否有扩展未声明 Py_mod_gil")
常见的三类移植问题:
- 全局/静态缓存:
static PyObject *last_result;在 GIL 下安全,无 GIL 下必须改成 thread-local(Py_tss_t)或加锁。 - borrowed reference 生命周期:
PyList_GET_ITEM返回的 borrowed pointer,在 GIL 下"反正别人跑不了"所以安全;无 GIL 下另一线程可能已经把它 pop 掉并释放。必须在临界区内使用,或显式Py_NewRef提升为 strong reference。 - 隐式原子性假设:
PyDict_Next遍历期间字典可能被并发修改,需要临界区包裹整个遍历。
生态进度(2026 年初):NumPy 2.1、PyTorch 2.6、pandas 2.2.3 等科学栈已就绪,PyPI 前 360 大二进制包中超过 50% 提供自由线程 wheel;OpenCV、grpcio 等仍存在缺口。uv 在找不到 cp314t wheel 时会回退源码编译,CI 里务必预留这一步的时间。
五、什么时候真的该上自由线程
先看一组可直接复现的对比:
# bench_ft.py —— 用 python3.14 与 python3.14t 分别跑
import time, sys
from concurrent.futures import ThreadPoolExecutor
print("GIL enabled:", sys._is_gil_enabled())
def burn(n: int) -> int:
# 纯 Python CPU 密集:有 GIL 时完全无法并行
s = 0
for i in range(n):
s += (i * i) % 1000003
return s
def run(workers: int, total: int = 8_000_000) -> float:
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=workers) as ex:
list(ex.map(burn, [total // workers] * workers))
return time.perf_counter() - t0
for w in (1, 2, 4, 8):
print(f"workers={w}: {run(w):.2f}s")
| 负载类型 | 传统 GIL | 自由线程 3.14t | 建议 |
|---|---|---|---|
| I/O 密集(网络、DB) | 线程已足够 | 无收益 | 保持 asyncio / 线程池 |
| 纯 Python CPU 密集 | 不随核扩展 | 近线性扩展 | 上自由线程 |
| NumPy / PyTorch 大算子 | 已在 C 层释放 GIL | 几乎无额外收益 | 保持默认构建 |
| 小张量密集调用的推理管线 | GIL 在 8–10 线程成瓶颈 | 明显消除瓶颈 | 上自由线程 |
| 依赖未适配扩展 | — | 静默退回 GIL | 先做生态审计 |
一句话决策法:CPU 密集且在 Python 层(而不是已经被 NumPy 吞掉的部分)、且依赖栈已经适配,才值得切。
六、生产落地检查清单
- 先审计再切换:
pip install ft-utils一类工具或直接用python3.14t -X gil=0 -c "import 你的全部依赖",然后断言sys._is_gil_enabled() is False。这一步不做,后面所有调优都是自欺欺人。 - 基准必须是自己的负载:别信通用 benchmark,拿真实请求 trace 在两种构建下各跑一遍,同时记录 p99 延迟而非只有吞吐——无 GIL 下锁竞争形态完全不同。
- 内存预算上调 15%–20%,并重新校准容器 limit。对象头膨胀 + mimalloc 的行为差异会让 OOM 线悄悄上移。
- 代码审查重点找"隐式原子性":
if k not in d、d.get(k) or d.setdefault(k)、全局计数器+=、单例懒加载——这些在 GIL 下"看起来对"的模式是重灾区。 - 线程池大小重新调:GIL 时代把线程数开到核数 2–4 倍是常见做法,无 GIL 下这个值往往应该回到核数附近,超开只会增加调度与缓存失效。
- 关注 3.15 的 ABI 统一:官方计划统一 GIL 与自由线程的 ABI,扩展只需编译一次即可同时支持两种模式;同时 PEP 734 的
concurrent.interpreters提供了"同进程内进程级隔离"的第三条路,适合隔离互不信任的代码。
七、结语
移除 GIL 这件事,工程上的真实难度从来不在"删锁",而在于CPython 三十年来把并发正确性外包给了一把大锁,现在要把它逐一收回来。偏向引用计数解决的是最高频操作的成本,不朽化解决的是最热对象的竞争,临界区解决的是 C 代码的可维护性,mimalloc + QSBR 解决的是内存生命周期。这四块基石拼起来,才换来一个 5%–10% 单线程代价的真并行 Python。
真正值得带走的判断是:自由线程把 Python 从"I/O 并发语言"变成了"I/O 与 CPU 都能并发的语言",但它没有让单线程代码变快,反而变慢了一点。如果你的瓶颈在网络等待,它对你毫无价值;如果你的瓶颈是一堆无法下沉到 NumPy 的 Python 层计算——比如复杂业务规则、序列化、数据校验、小算子编排——那么 2026 年是时候认真评估 python3.14t 了。

发表评论 取消回复