news 2026/9/11 21:05:23

CPython 自由线程构建 QSBR 槽位泄漏修复解析:从 gh-issue-155363 看线程状态创建失败路径的回收机制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CPython 自由线程构建 QSBR 槽位泄漏修复解析:从 gh-issue-155363 看线程状态创建失败路径的回收机制

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 thefree-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 数组在反复失败的过程中无界增长。

这条记录虽然只有三句话,但信息密度很高,涉及三个关键概念:

  1. free-threaded build(自由线程构建):CPython 的--disable-gil实验性构建模式,由Py_GIL_DISABLED宏控制;
  2. QSBR:为自由线程构建引入的安全内存回收(SMR)机制,用于在存在并发读访问时安全地延迟释放共享内存;
  3. 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 0QSBR_INITIAL 1QSBR_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_countdeferred_memorydeferred_page_memoryshould_processallocatedfreelist_next等字段;
  • struct _qsbr_shared:per-interpreter 共享状态,含wr_seqrd_seqarray(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

注意两点:

  1. 先预留 TLBC 索引,再预留 QSBR 槽位。TLBC(Thread-Local ByteCode)是自由线程构建中每个线程独立的字节码副本索引,其预留/释放 API 声明在 Include/internal/pycore_code.h(_Py_ReserveTLBCIndex/_Py_UnreserveTLBCIndex)。当 QSBR 预留失败时,代码已经正确地回滚了 TLBC 索引——这说明"创建失败时的资源回滚"在本修复之前就是代码库的既有约定;
  2. _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 中所说的泄漏:

  1. new_threadstate()调用_Py_qsbr_reserve()成功,从 freelist 取走一个槽位并将其allocated = true
  2. 随后HEAD_LOCKinit_threadstate()add_threadstate()之间的任意一步失败(例如init_threadstate()内部的资源分配失败),函数在HEAD_UNLOCK之前提前返回NULL
  3. 此时没有任何代码把槽位还给 freelist——_Py_qsbr_unregister()只会在线程状态正常销毁路径上被调用,而这里PyThreadState本身已经就地释放(free_threadstate(),Python/pystate.c,只是归还给 preallocated 池或直接PyMem_RawFree);
  4. 该槽位保持allocated = truetstate == 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 21:05:13

手势识别优化实战:从分类网络到关键点几何特征的全链路复盘

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 21:04:49

YOLOv8在石油钻井平台设备状态监测中的工业落地实践

简介&#xff1a;本资源是一套面向计算机、人工智能、自动化等专业学生的毕业设计级项目&#xff0c;聚焦石油钻井平台关键设备的智能状态监测&#xff0c;基于YOLOv8实现高精度目标检测与可视化分析。适用于毕设、课程设计、大作业及工程实践入门&#xff0c;无需深厚算法基础…

作者头像 李华
网站建设 2026/9/11 21:04:08

RH850/F1L CAN初始化与启动流程深度解析

简介&#xff1a;本资源是面向汽车电子开发工程师与嵌入式初学者的RH850/F1L微控制器实战入门套件&#xff0c;聚焦车身控制、动力总成等车规级应用场景&#xff0c;解决硬件配置难、外设驱动调试门槛高、文档分散等典型开发痛点。压缩包共123个文件&#xff0c;涵盖18份权威PD…

作者头像 李华
网站建设 2026/9/11 21:02:59

C++栈与队列:从容器适配器到高并发实战的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 21:02:57

LeetCode 160 相交链表:双指针解法与原理详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华