CPython 自由线程构建 QSBR 槽位泄漏修复解析:从 gh-issue-155363 看线程状态创建失败路径的回收机制
【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython
导读
本文围绕 CPython 仓库中一条 NEWS 变更记录(Misc/NEWS.d/next/Core_and_Builtins/2026-08-07-13-40-12.gh-issue-155363.Qk3Vt9.rst)展开:在**自由线程构建(free-threaded build,即Py_GIL_DISABLED配置)**下,当线程状态(PyThreadState)创建失败、而内部 QSBR(Quiescent-State Based Reclamation,静默状态内存回收)槽位已经预留给该线程时,旧实现会泄漏该槽位,导致 QSBR 数组在反复失败的场景下无界增长。读完本文,你将掌握 QSBR 槽位的生命周期(预留 → 注册 → 注销)、失败路径为何会泄漏、修复的准确位置与方式,以及如何通过源码验证该修复。
一、变更记录原文与问题定位
1.1 变更记录全文
该 NEWS 条目完整内容如下:
Fix a leak in the
free-threaded buildwhen creating a thread state fails after an internal QSBR slot has been reserved for it. The slot could never be reclaimed, so the QSBR array grew without bound across repeated failures.
用中文表述即:修复自由线程构建中的一个泄漏——当线程状态创建失败、且在此之前已经为它预留了内部 QSBR 槽位时,该槽位永远无法被回收,导致 QSBR 数组在反复失败的过程中无界增长。
这条记录虽然只有三句话,但信息密度很高,涉及三个关键概念:
- free-threaded build(自由线程构建):CPython 的
--disable-gil实验性构建模式,由Py_GIL_DISABLED宏控制; - QSBR:为自由线程构建引入的安全内存回收(SMR)机制,用于在存在并发读访问时安全地延迟释放共享内存;
- QSBR 槽位(slot):每个线程在进入解释器时,需要在 per-interpreter 的 QSBR 状态数组中占用一个条目。
1.2 为什么说这是一条值得深挖的变更
表面上看它只是一条"修了个泄漏"的新闻,但结合源码可以发现:它触及了自由线程构建中一个极易发生、却极难观测的资源生命周期问题——QSBR 数组由PyMem_RawCalloc按需扩容分配,数组大小由shared->size记录,一旦某个槽位被置为allocated状态却没有任何线程引用它,该槽位既不会被扫描(poll)回收,也不会进入空闲链表(freelist),数组只能不断扩容。若创建线程状态的操作(如内存不足、TLBC 索引耗尽)反复失败,泄漏会线性累积,最终造成不可忽视的内存膨胀。
二、QSBR 机制速览:为什么需要"槽位"
在深入修复细节之前,先建立对 QSBR 的基础认知。CPython 在自由线程构建中,许多无锁数据结构(如字典键表PyDictKeysObject、列表后备数组_PyListArray)在读与写并发时,不能立刻释放被替换的旧内存,否则读线程可能踩到 use-after-free。QSBR 的做法是:让每个线程定期在"静默点"报告自己的读取序列号,只有当一个对象的目标序列号(qsbr_goal)小于等于所有存活线程的最小读取序列号时,才能安全释放它。
- 全局写序列
wr_seq:per-interpreter 计数器,从 1 开始、每次递增 2,始终保持奇数(见 Include/internal/pycore_qsbr.h 中的QSBR_OFFLINE 0、QSBR_INITIAL 1、QSBR_INCR 2定义); - 每线程读序列:线程到达静默状态时把当前
wr_seq拷贝到本地(_Py_qsbr_quiescent_state,Include/internal/pycore_qsbr.h); - 全局读序列
rd_seq:所有在线线程读序列的最小值,由qsbr_poll_scan()扫描线程状态数组得出(Python/qsbr.c)。
仓库中还有一份专门的设计文档 InternalDocs/qsbr.md,详细说明了 QSBR 在自由线程构建中的用途、实现细节与已知限制(例如扫描所有线程状态在超过约 1000 个线程的应用中可能成为瓶颈)。
而每个线程要参与 QSBR,就必须在 per-interpreter 的 QSBR 状态数组中占有一个_qsbr_thread_state条目,这个条目就是本次 NEWS 所说的"slot"。相关数据结构定义在 Include/internal/pycore_qsbr.h:
struct _qsbr_thread_state:每线程状态,含seq(最后观测的写序列,0 表示离线)、deferred_count、deferred_memory、deferred_page_memory、should_process、allocated、freelist_next等字段;struct _qsbr_shared:per-interpreter 共享状态,含wr_seq、rd_seq、array(64 字节对齐的_qsbr_pad数组)、size、以及受PyMutex保护的freelist。
三、槽位生命周期:预留、注册与注销
QSBR 槽位与PyThreadState的生命周期严格绑定,对应的 API 在 Include/internal/pycore_qsbr.h 中声明,实现位于 Python/qsbr.c。
3.1 槽位预留:_Py_qsbr_reserve()
当创建一个新的PyThreadState时,CPython 会先为它预留一个 QSBR 槽位。入口在 Python/pystate.c 的new_threadstate():
#ifdef Py_GIL_DISABLED int32_t tlbc_idx = _Py_ReserveTLBCIndex(interp); if (tlbc_idx < 0) { free_threadstate(tstate); return NULL; } Py_ssize_t qsbr_idx = _Py_qsbr_reserve(interp); if (qsbr_idx < 0) { _Py_UnreserveTLBCIndex(interp, tlbc_idx); free_threadstate(tstate); return NULL; } #endif注意两点:
- 先预留 TLBC 索引,再预留 QSBR 槽位。TLBC(Thread-Local ByteCode)是自由线程构建中每个线程独立的字节码副本索引,其预留/释放 API 声明在 Include/internal/pycore_code.h(
_Py_ReserveTLBCIndex/_Py_UnreserveTLBCIndex)。当 QSBR 预留失败时,代码已经正确地回滚了 TLBC 索引——这说明"创建失败时的资源回滚"在本修复之前就是代码库的既有约定; _Py_qsbr_reserve()返回的是数组下标而非指针,注释明确说明原因是"数组可能在后续扩容时被重新分配,指针会失效"(Python/qsbr.c)。
_Py_qsbr_reserve()的实现(Python/qsbr.c)优先从共享freelist取一个空闲槽位;若 freelist 为空,则调用grow_thread_array()将数组翻倍扩容(初始最小为 8 个条目,见MIN_ARRAY_SIZE定义 Python/qsbr.c),扩容需要先_PyEval_StopTheWorld()暂停所有线程,再把旧数组内容memcpy到新数组并重建 freelist(Python/qsbr.c)。
3.2 槽位注册:_Py_qsbr_register()
new_threadstate()在完成init_threadstate()并把线程加入解释器的线程链表后,才调用_Py_qsbr_register()把槽位与线程状态关联起来(Python/pystate.c):
#ifdef Py_GIL_DISABLED // Must be called with lock unlocked to avoid lock ordering deadlocks. _Py_qsbr_register(tstate, interp, qsbr_idx); tstate->tlbc_index = tlbc_idx; #endif_Py_qsbr_register()(Python/qsbr.c)在互斥锁保护下完成qsbr->tstate = tstate; tstate->qsbr = qsbr;的双向关联。
3.3 槽位注销:_Py_qsbr_unregister()
线程状态销毁时调用_Py_qsbr_unregister()(Python/pystate.c),其实现(Python/qsbr.c)把allocated置回false、清空tstate指针,并把槽位压回freelist供后续线程复用。注意freelist是"槽位复用池",与"数组扩容"是两个层面:只要槽位能回到 freelist,数组就不会因为反复创建/销毁线程而无界增长。
四、泄漏根因:预留成功但注册永远不发生
把上面的生命周期拼起来,就能精确复现 NEWS 中所说的泄漏:
new_threadstate()调用_Py_qsbr_reserve()成功,从 freelist 取走一个槽位并将其allocated = true;- 随后
HEAD_LOCK→init_threadstate()→add_threadstate()之间的任意一步失败(例如init_threadstate()内部的资源分配失败),函数在HEAD_UNLOCK之前提前返回NULL; - 此时没有任何代码把槽位还给 freelist——
_Py_qsbr_unregister()只会在线程状态正常销毁路径上被调用,而这里PyThreadState本身已经就地释放(free_threadstate(),Python/pystate.c,只是归还给 preallocated 池或直接PyMem_RawFree); - 该槽位保持
allocated = true且tstate == NULL的"幽灵"状态,既不会被任何线程使用,也永远无法回到 freelist。
从源码结构看,修复前new_threadstate()的失败路径只回滚了 TLBC 索引(_Py_UnreserveTLBCIndex),没有对 QSBR 槽位做任何回收动作。这正好与 NEWS 的描述一一对应:"创建线程状态失败后,已预留的 QSBR 槽位永远无法回收,QSBR 数组在反复失败中无界增长。"
泄漏的实际影响随失败频率线性放大:grow_thread_array()每次扩容都会把size翻倍并calloc一块更大的数组,虽然旧数组会被PyMem_RawFree释放,但共享结构shared->size只会单调增长;如果"创建线程状态"这个操作被反复触发且反复失败(例如内存压力下的线程风暴),size会一路膨胀,扫描(qsbr_poll_scan)成本也随之上升。
五、修复方式与验证路径
5.1 修复思路
修复方案非常直接:在new_threadstate()的 QSBR 预留失败分支之外,补上"预留成功但后续创建失败"时的回滚调用。即把_Py_qsbr_reserve()返回的qsbr_idx在错误路径上归还。仓库中与之语义对称的既有先例是:
_Py_UnreserveTLBCIndex():注释明确说明它是"释放一个由_Py_ReserveTLBCIndex()预留但从未存入PyThreadState的索引"(Include/internal/pycore_code.h),说明"预留后回滚"是自由线程构建中既定的资源管理模式;- 修复后的
new_threadstate()失败路径应当形如:先_Py_UnreserveTLBCIndex(interp, tlbc_idx),再归还 QSBR 槽位(使其回到 freelist),最后free_threadstate(tstate),保证三个预留资源全部对称回收。
可以推断,修复涉及新增一个与_Py_UnreserveTLBCIndex对应的 QSBR 回滚 API(或在_Py_qsbr_register前置条件下复用注销逻辑),并在new_threadstate()的失败路径调用它。这条变更归类于Core_and_Builtins,说明它落在核心运行时(解释器/线程状态管理)而非某个具体模块。
5.2 如何在仓库中验证修复
- 阅读关联文档:Misc/NEWS.d/next/Core_and_Builtins/2026-08-07-13-40-12.gh-issue-155363.Qk3Vt9.rst 本身;
- 核对创建路径:检查 Python/pystate.c 中
new_threadstate()的所有失败分支是否都已对称回滚 TLBC 与 QSBR 资源; - 核对槽位回收语义:在 Python/qsbr.c 中确认
_Py_qsbr_reserve()与_Py_qsbr_unregister()对allocated/freelist的维护是否闭合; - 关注后续演进:
qsbr_poll_scan()需要遍历整个数组(Python/qsbr.c),数组规模失控不仅浪费内存,也会拖慢每次 QSBR poll;这解释了为何"无界增长"被当作必须修复的缺陷。
六、从修复看自由线程构建的资源管理原则
这条变更虽小,却浓缩了自由线程构建(Py_GIL_DISABLED)中资源管理的一条核心原则:凡是 per-thread 的共享资源,预留(reserve)与回收(release/unreserve)必须成对出现,且错误路径必须与正常路径同样严谨。
类似的成对资源在仓库中还有不少,可以作为延伸阅读:
| 资源 | 预留 | 回收/注销 | 关键位置 |
|---|---|---|---|
| QSBR 槽位 | _Py_qsbr_reserve() | _Py_qsbr_unregister() | Python/qsbr.c |
| TLBC 索引 | _Py_ReserveTLBCIndex() | _Py_UnreserveTLBCIndex()/_Py_ClearTLBCIndex() | Include/internal/pycore_code.h |
| QSBR 写序列推进 | _Py_qsbr_advance() | 由qsbr_poll_scan()驱动的延迟释放 | Python/qsbr.c |
而 QSBR 本身的设计权衡(延迟推进写序列以减少内存竞争、以QSBR_DEFERRED_LIMIT/QSBR_FREE_MEM_LIMIT等阈值约束峰值内存、eval_breaker作为静默点等)都可以在 InternalDocs/qsbr.md 与 Objects/obmalloc.c 的延迟释放实现中进一步研读。
结语
gh-issue-155363 对应的这条 NEWS 记录,揭示了自由线程构建中一个"隐藏很深、后果很实"的资源泄漏:一次PyThreadState创建失败,可能让一个 QSBR 槽位永久脱离回收路径,并在反复失败下让 QSBR 数组无界膨胀。修复的本质是让"预留"与"回收"在错误路径上同样对称。对自由线程构建的维护者与研究者而言,理解reserve → register → unregister的完整链路,是排查这类问题的第一手方法论。
【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考