CPython 自由线程构建中设置特殊方法引发内存泄漏的修复分析(gh-issue-155978)
【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython
导读
本文基于 CPython 仓库中的变更记录 Misc/NEWS.d/next/Core_and_Builtins/2026-08-17-15-13-14.gh-issue-155978.4ztALD.rst 展开,聚焦一个在自由线程(free-threaded,即 Py_GIL_DISABLED)构建下出现的内存泄漏问题:当在一个拥有大量子类的类上设置或删除特殊方法(special method,如__repr__)时,解释器会泄漏内存。读完本文,你将理解 CPython 的类型槽位(type slot)更新机制、自由线程构建下"停止世界(stop-the-world)"式的槽位批量更新队列,以及该泄漏的根因与修复思路,并能据此在自己的扩展或测试中复现、规避同类问题。
变更记录原文
本次修复对应的 NEWS 条目全文如下:
Fix a memory leak in the free-threaded build when setting or deleting a special method (such as
__repr__) on a class that has many subclasses.
即:修复自由线程构建中,在拥有许多子类的类上设置或删除特殊方法(如__repr__)时的内存泄漏。该条目不涉及公开 API 或行为变更,属于内部实现级的内存管理修复,归类于Core_and_Builtins。
背景:特殊方法与类型槽位(type slot)
在 CPython 中,类的__repr__、__add__、__getitem__等特殊方法并不会在每次调用时都走普通的方法查找。解释器在类创建以及类属性被修改时,会把这些特殊方法"固化"到类型对象(PyTypeObject)内部的槽位指针上,例如:
__repr__→tp_repr__hash__→tp_hash__lt__/__le__等 6 个方法 →tp_richcompare__add__/__radd__→tp_as_number->nb_add
这套映射关系定义在 Objects/typeobject.c 中的slotdefs数组中。注释明确指出(见 Objects/typeobject.c#L11777-L11790):
一个槽位可能对应多个特殊方法,反之亦然。例如
tp_richcompare使用__lt__…__ge__6 个方法,而tp_as_number->nb_add使用__add__和__radd__;反过来__add__同时被数值协议和序列协议使用,__getitem__同时被序列协议和映射协议使用。
槽位更新的核心逻辑在update_one_slot()(Objects/typeobject.c#L11840):
- 沿类型的 MRO 查找对应名称的特殊方法;
- 若找到的是匹配的
wrapper_descriptor(如str.__repr__),则直接把槽位指向被包装的原生 C 函数; - 若找到的是普通 Python 方法,则安装一个通用 wrapper(安全但较慢,因为要走方法查找);
- 多个特殊方法结果不一致时,安装组合 wrapper。
因此,setattr(cls, "__repr__", f)或delattr(cls, "__repr__")会触发一整条槽位级联更新链,而这个链条在自由线程构建下会横跨该类的全部子类。
泄漏场景:设置/删除特殊方法
type_setattro()(Objects/typeobject.c#L6630)在类的__dict__被修改后调用update_slot_after_setattr()(Objects/typeobject.c#L6601),进而调用update_slot()(Objects/typeobject.c#L12022)。
update_slot()的逻辑是:
- 遍历
slotdefs,找出名称与被设置/删除的属性名一致的所有槽位定义(借助内部字符串驻留比较,见 Objects/typeobject.c#L12038 的 bpo-40521 注释); - 若该名称与任何槽位无关(如普通属性
foo),直接返回(Objects/typeobject.c#L12051-L12052); - 否则通过
update_subclasses(type, name, update_slots_callback, ...)(Objects/typeobject.c#L12057)把更新递归传播到所有受影响的子类。
update_subclasses()借助每个类型维护的tp_subclasses字典(见 Objects/typeobject.c#L704-L794 中init_tp_subclasses()、get_subclasses_unlocked()等辅助函数)遍历全部子类型。这正是泄漏与"许多子类"强相关的直接原因:子类越多,需要排队的槽位更新条目就越多。
自由线程构建:队列式批量更新
在非自由线程(默认 GIL)构建中,update_slot()直接同步改写槽位指针即可,因为 GIL 保证了可见性。但自由线程构建下,槽位指针可能被其他线程并发读取,因此 CPython 采用了"先排队、后停止世界统一应用"的策略:
update_slot()在遍历所有受影响类型(含子类)时,把每个(type, slot_ptr, slot_value)三元组追加到一个更新队列中,而不是立即写入;- 队列实现为链式 chunk:
slot_update_chunk_t每个 chunk 固定容纳SLOT_UPDATE_CHUNK_SIZE(30)个条目(Objects/typeobject.c#L3792),结构体定义见 Objects/typeobject.c#L3794-L3803; queue_slot_update()(Objects/typeobject.c#L3830)在 chunk 写满或队列为空时通过PyMem_Malloc分配新 chunk(Objects/typeobject.c#L3808);- 收集完成后,
apply_type_slot_updates()(Objects/typeobject.c#L3872)执行types_stop_world()停止世界,调用apply_slot_updates()把排队条目逐项写入槽位指针(期间对tp_call的更新还会清除Py_TPFLAGS_HAVE_VECTORCALL标志,见 Objects/typeobject.c#L3862-L3865),随后再恢复世界; - 最后通过
slot_update_free_chunks()(Objects/typeobject.c#L3819)释放所有 chunk。
关键点在内存布局上:常规路径(如update_slot_after_setattr,Objects/typeobject.c#L6605-L6609)第一个 chunk 是在栈上分配的(slot_update_chunk_t chunk = {0}; slot_update_t queued_updates = {&chunk};),只有子类数量超过SLOT_UPDATE_CHUNK_SIZE(30)时才需要从堆上分配后续 chunk(Objects/typeobject.c#L6606-L6609)。
泄漏根因与修复思路
综合 NEWS 条目与源码结构可以推断,泄漏发生在堆上分配的那些溢出 chunk上:
- 当类拥有超过 30 个子类时,
update_slot()会为额外子类的槽位更新分配额外的堆 chunk; - 在自由线程构建的某些路径上(从源码结构看,例如
update_all_slots()——即__bases__被重新赋值时的更新入口,Objects/typeobject.c#L12083-L12105——在update_slot()中途出错时仅释放了queued_updates.head指向的已挂接 chunk 链,却可能遗漏尚未挂接或已提前分配但未入队的 chunk;又如update_slot_after_setattr()的清理循环只遍历cur != &chunk的堆 chunk),部分堆内存块在异常/提前返回路径上没有被slot_update_free_chunks()回收,于是产生随"设置/删除特殊方法 + 大量子类"操作次数线性增长的泄漏; - 修复的核心是保证无论走正常完成路径还是错误返回路径,队列中所有堆分配的 chunk 都被完整释放,即让
slot_update_free_chunks()的调用覆盖所有分配点。
由于本变更记录属于内部内存管理修复,不改变任何公开语义:设置/删除特殊方法后槽位更新行为与修复前一致,只是不再漏内存。
影响范围与验证建议
- 受影响构建:仅自由线程构建(
--disable-gil编译,即源码中#ifdef Py_GIL_DISABLED分支,见 Objects/typeobject.c#L12079)。默认 GIL 构建不受影响,因为其update_all_slots()直接同步更新、不涉及队列分配(Objects/typeobject.c#L12107-L12121)。 - 触发条件:对一个拥有大量子类(超过
SLOT_UPDATE_CHUNK_SIZE = 30个)的类反复执行cls.__repr__ = f或del cls.__repr__之类的特殊方法增删操作。 - 验证方式:可在自由线程构建下用
tracemalloc或 RSS 观察反复执行上述操作后的内存增长趋势;修复后内存应保持稳定。
相关源码路径速查
- 变更记录:Misc/NEWS.d/next/Core_and_Builtins/2026-08-17-15-13-14.gh-issue-155978.4ztALD.rst
- 槽位定义与更新核心:Objects/typeobject.c(
slotdefs、update_one_slot()、update_slot()、update_all_slots()、fixup_slot_dispatchers()) - 更新队列与停止世界应用:Objects/typeobject.c#L3780-L3903
- 子类遍历:Objects/typeobject.c#L704-L794
- 类型属性赋值入口:Objects/typeobject.c#L6600-L6627
- 自由线程构建开关说明可参考仓库根目录 README.rst 中关于
--disable-gil的构建说明。
【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考