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")

常见的三类移植问题:

  1. 全局/静态缓存:static PyObject *last_result; 在 GIL 下安全,无 GIL 下必须改成 thread-local(Py_tss_t)或加锁。
  2. borrowed reference 生命周期:PyList_GET_ITEM 返回的 borrowed pointer,在 GIL 下"反正别人跑不了"所以安全;无 GIL 下另一线程可能已经把它 pop 掉并释放。必须在临界区内使用,或显式 Py_NewRef 提升为 strong reference。
  3. 隐式原子性假设: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 吞掉的部分)、且依赖栈已经适配,才值得切。

六、生产落地检查清单

  1. 先审计再切换:pip install ft-utils 一类工具或直接用 python3.14t -X gil=0 -c "import 你的全部依赖",然后断言 sys._is_gil_enabled() is False。这一步不做,后面所有调优都是自欺欺人。
  2. 基准必须是自己的负载:别信通用 benchmark,拿真实请求 trace 在两种构建下各跑一遍,同时记录 p99 延迟而非只有吞吐——无 GIL 下锁竞争形态完全不同。
  3. 内存预算上调 15%–20%,并重新校准容器 limit。对象头膨胀 + mimalloc 的行为差异会让 OOM 线悄悄上移。
  4. 代码审查重点找"隐式原子性":if k not in d、d.get(k) or d.setdefault(k)、全局计数器 +=、单例懒加载——这些在 GIL 下"看起来对"的模式是重灾区。
  5. 线程池大小重新调:GIL 时代把线程数开到核数 2–4 倍是常见做法,无 GIL 下这个值往往应该回到核数附近,超开只会增加调度与缓存失效。
  6. 关注 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 了。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部