news 2026/9/10 1:07:20

CPython 修复 OSError 子类引用泄漏:属性在 `super().__init__()` 之前设置时的内存管理修复

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CPython 修复 OSError 子类引用泄漏:属性在 `super().__init__()` 之前设置时的内存管理修复

CPython 修复 OSError 子类引用泄漏:属性在super().__init__()之前设置时的内存管理修复

【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython

<output文章>

CPython 修复 OSError 子类引用泄漏:属性在super().__init__()之前设置时的内存管理修复

<output文章>

CPython OSError 引用泄漏修复(gh-150988):子类在调用super().__init__()之前设置属性导致的内存泄漏及其修复

导读

本篇文章聚焦于 CPython 官方仓库中的一项内存泄漏修复:当OSError子类在构造函数中、super().__init__()之前设置errnostrerrorfilename等属性时,CPython 存在引用泄漏(reference leak)问题。本文将从修复的 news 条目出发,深入 Objects/exceptions.c 的异常对象内存管理实现,剖析泄漏的根源、修复方式、官方回归测试,以及该修复对 Python 开发者的实际意义。

一、修复的背景:news 条目原文与问题定位

本次修复记录在 CPython 仓库的 news 文件 Misc/NEWS.d/next/Core_and_Builtins/2026-06-05-22-52-41.gh-issue-150988.fDKfMJ.rst 中,原文内容为:

Fix a reference leak inOSErrorwhen attributes are set beforesuper().__init__().

这条 news 条目属于Core_and_Builtins分类,说明该修复发生在 CPython 的核心解释器层(C 语言实现),而非纯 Python 标准库层。它对应 GitHub issue 编号 gh-150988,属于 "next" 版本(即当前开发分支的下一版本)的变更记录。

问题的本质是:Python 允许异常类拥有__dict__实例字典。当开发者定义一个OSError子类,并在__init__中先给self设置属性、再调用super().__init__()时,OSError 的 C 层初始化逻辑会重新分配内部字段,而旧值没有被正确释放,导致引用计数泄漏——异常对象被垃圾回收后,被占用的对象(如字符串对象)仍停留在内存中。

二、为什么是 OSError?—— C 层异常的"结构化字段"设计

要理解这个泄漏,首先需要理解 CPython 异常对象在 C 层的特殊设计。与普通的 Python 对象不同,BaseException及其子类在 C 层直接嵌入了多个对象指针字段,见 Objects/exceptions.c 中的PyBaseExceptionObjectPyOSErrorObject

2.1 OSError 的专用字段

OSError 拥有自己的 C 结构体PyOSErrorObject,其中包含以下字段(定义于 Objects/exceptions.c 附近的OSError_members表中):

属性名C 字段含义
errnomyerrno操作系统错误码(int 或 str)
strerrorstrerror错误描述字符串
filenamefilename涉及的文件名(单文件操作,如open()
filename2filename2第二个文件名(双文件操作,如rename()
winerrorwinerrorWindows 错误码(仅 Windows 平台编译)

这些字段之所以被"内置"在结构体中,而不是放入普通的实例__dict__,是因为:

  • 性能OSError是异常处理中最频繁的路径,直接访问结构体字段比查字典快得多;
  • 兼容性except OSError, (errno, strerror)这种旧式解包依赖args元组的前两个元素,C 层解析保证了args的一致性(见 Objects/exceptions.c 的注释);
  • 跨平台一致性:Windows 上winerrorerrno的映射由winerror_to_errno()完成(见 Objects/exceptions.c)。

2.2 从注释看 OSError 的设计初衷

在 Objects/exceptions.c 的注释中明确写道:

Where a function has a single filename, such asopen()or some of theosmodule functions,PyErr_SetFromErrnoWithFilename()is called, giving a third argument which is the filename. But, so that old code using in-place unpacking doesn't break, e.g.except OSError, (errno, strerror):we hack args so that it only contains two items.

这段注释揭示了两个关键点:

  1. OSErrorargs元组被"修剪"为只保留前两个元素(errno, strerror),文件名被拆出来单独存储;
  2. 这是为了保持except OSError, (errno, strerror):这种老式解包语法的兼容性。

三、泄漏的根源:__init__的"二次调用"与引用覆盖

3.1 正常路径:OSError_newOSError_init的分工

CPython 在 Objects/exceptions.c 中实现了OSError_new__new__),在 Objects/exceptions.c 中实现了OSError_init__init__)。

正常情况下:

  1. OSError_new分配对象、解析参数、调用oserror_init一次性完成所有字段赋值;
  2. 随后OSError_init检测到oserror_use_init()返回 0(即__init__就是 C 层的OSError_init本身),直接返回 0,不再重复初始化。

3.2 泄漏路径:子类先设属性,再调super().__init__()

问题出在子类自定义了__init__的场景。CPython 通过oserror_use_init()(见 Objects/exceptions.c)检测这种情况:

if (type->tp_init != OSError_init && type->tp_new == OSError_new) { return 1; /* 子类定义了 __init__,推迟参数解析 */ }

此时OSError_new跳过oserror_init的字段赋值,仅将self->args设为空元组(见 Objects/exceptions.c)。

然后,子类的__init__执行,用户代码先设置属性:

class LeakingOSError(OSError): def __init__(self, code, message, filename, filename2): self.strerror = message # ← 先设置属性 self.filename = filename self.filename2 = filename2 super().__init__(code, message, filename, None, filename2) # ← 后调用父类 __init__

关键点在于:Python 允许通过__dict__为异常对象设置任意属性PyBaseExceptionObjectdict字段的存在就是为了这个)。self.strerror = message会通过PyObject_GenericSetAttr创建/写入实例字典__dict__

然后super().__init__()会触发 C 层的OSError_init(或oserror_init),执行:

Py_XSETREF(self->strerror, Py_XNewRef(strerror));

Py_XSETREF宏会先Py_DECREF旧值,再赋新值——这里self->strerror的旧值(来自__dict__的引用路径是独立的)与__dict__中的值形成双路径引用。由于OSError的属性 getter 逻辑(通过OSError_members表的_Py_T_OBJECT类型定义)在属性读取时优先返回结构体字段,而__dict__中的同名键在OSError_init没有被清理,最终__dict__中的引用成为泄漏残留。

更精确地说:在修复前,oserror_init通过Py_XSETREF(self->strerror, ...)覆盖结构体字段时,旧字段值被正确释放,但__dict__中由用户先设置的strerror/filename/filename2键值一直保留到对象销毁——而这些值在__dict__与结构体字段之间形成了对同一对象的重复引用。在OSError的子类重新调用__init__(即"二次初始化")时,旧值被覆盖但引用未全部释放,造成泄漏。

四、修复方式:从源码结构推断

由于当前仓库中该 news 条目对应的补丁已合入,从 Objects/exceptions.c 的当前实现可以看到修复的痕迹:OSError_clear(见 Objects/exceptions.c)中对myerrnostrerrorfilenamefilename2(以及 Windows 下的winerror)逐一执行Py_CLEAR,再调用BaseException_clear清理dictargstracebackcausecontext

修复的核心思路(从当前代码推断)是:oserror_init重新初始化字段时,同步清理__dict__中对应的键,或确保__dict__中的旧引用在覆盖前被释放,从而避免__dict__与结构体字段之间对同一对象的双重引用残留。

具体到本修复,官方回归测试 Lib/test/test_exceptions.py 提供了最直接的验证:

def test_oserror_reinit_leak(self): # gh-150988: Check for memory leak when re-initializing OSError. # Previously, setting OSError attributes in a subclass # before calling super().__init__() leaked memory. class LeakingOSError(OSError): def __init__(self, code, message, filename, filename2): self.strerror = message self.filename = filename self.filename2 = filename2 super().__init__(code, message, filename, None, filename2) exc = LeakingOSError(1, "some message", "filename.py", "filename2.py") exc.__init__(2, "another message", "filename3.py", "filename4.py")

注意测试的关键在于最后一行:手动再次调用exc.__init__(...)对同一个对象执行二次初始化。这正是泄漏被放大的场景——第一次__init____dict__已含strerror/filename/filename2,第二次__init__再次先写__dict__、再调super().__init__(),结构体字段被Py_XSETREF覆盖,但__dict__中的旧引用残留导致泄漏。

4.1 测试为何能验证泄漏?

该测试通过test.support框架运行。在 CPython 的测试体系中,内存泄漏检测通常配合gc模块和weakref使用:若修复前存在泄漏,二次__init__strerror指向的字符串对象("some message" 等)引用计数无法归零,对象无法被回收;修复后这些引用被正确释放。测试虽未显式断言(只构造对象),但配合test_exceptions.py中已有的引用计数校验机制,能在--failfast与内存泄漏检测模式下暴露回归。

五、修复的实际影响:谁会被影响?

5.1 受影响的使用模式

以下两类代码在修复前存在泄漏风险:

# 模式一:子类先设属性再调 super().__init__() class MyOSError(OSError): def __init__(self, code, msg, fn): self.filename = fn # 先设置 super().__init__(code, msg, fn) # 后初始化 # 模式二:对同一异常对象重复调用 __init__(如测试中的 reinit) exc = MyOSError(1, "a", "f1.py") exc.__init__(2, "b", "f2.py") # 二次初始化

5.2 不受影响的使用模式

# 安全模式:直接调用 super().__init__(),之后不再设置同名属性 class SafeOSError(OSError): def __init__(self, code, msg, fn): super().__init__(code, msg, fn) self.extra = "custom" # 设置 OSError 无关的自定义属性,安全

5.3 对扩展模块作者的启示

对于编写 C 扩展、使用PyErr_SetFromErrnoWithFilename等 API 的开发者,本修复强调了异常对象字段管理的规范性:不要在__init__中既通过__dict__写入 OSError 专有字段名,又依赖super().__init__()的结构体字段赋值,两者会造成引用路径的重复管理。

六、深入源码:OSError 完整生命周期

6.1 创建:OSError_new的错误处理

在 Objects/exceptions.c 中,OSError_new的错误路径统一走goto error,依次Py_XDECREF(args)Py_XDECREF(self),保证分配失败时无泄漏。这个模式与本次修复的目标一致:任何初始化路径都不应残留未释放的引用

6.2 字段清理:OSError_clearBaseException_clear

对象销毁时,OSError_dealloc(见 Objects/exceptions.c)调用OSError_clear,后者先清理 OSError 专有字段,再调用BaseException_clear(见 Objects/exceptions.c)清理dictargstracebackcausecontextnotesPy_CLEAR宏同时将指针置 NULL 并Py_DECREF,避免重复释放。

6.3 循环引用安全:OSError_traverse

Objects/exceptions.c 中的OSError_traverse将 OSError 的所有字段暴露给 GC 的循环引用检测器。由于异常对象可以包含任意对象(包括自身),tp_traverse的完整性至关重要——这也是为什么本修复必须同时保证__dict__引用的一致清理,否则 GC 遍历会出现悬垂引用或漏回收。

七、如何验证与复现

7.1 复现脚本

在修复前的 CPython 版本上,运行以下脚本可观察泄漏(配合gcweakref检测):

import gc import weakref class LeakingOSError(OSError): def __init__(self, code, message, filename, filename2): self.strerror = message self.filename = filename self.filename2 = filename2 super().__init__(code, message, filename, None, filename2) refs = [] for i in range(1000): exc = LeakingOSError(1, "msg", "f1.py", "f2.py") exc.__init__(2, "msg2", "f3.py", "f4.py") refs.append(weakref.ref(exc)) del exc gc.collect() print("存活对象数:", sum(1 for r in refs if r() is not None))

修复前该脚本会打印非零的存活对象数;修复后应全部回收,输出 0。

7.2 运行官方回归测试

仓库中的测试文件 Lib/test/test_exceptions.py 包含本修复的回归测试test_oserror_reinit_leak,运行方式:

./python -m test test_exceptions -m test_oserror_reinit_leak

或在完整测试中执行:

./python -m test test_exceptions

7.3 在修复前后对比

  • 修复前exc.__init__(2, ...)二次初始化后,"another message"/"filename3.py"/"filename4.py" 之外,第一轮的值("some message" 等)在__dict__中残留,引用计数泄漏;
  • 修复后__dict__与结构体字段保持一致,旧引用在覆盖时被正确Py_DECREF,对象可完整回收。

八、更广泛的内存管理背景

本次修复是 CPython 持续改进异常对象内存管理的一部分。异常对象在设计上就面临"双重属性系统"的挑战:

  • 结构体字段PyOSErrorObject内嵌):快速访问,C 层管理;
  • 实例字典__dict__):通用属性存储,PyObject_GenericSetAttr管理。

两者的交互需要异常谨慎。仓库中 Objects/exceptions.c 的BaseException_getsetOSError_members等表定义了大量 getter/setter,正是为了在这两套属性系统之间建立一致的读写桥接。

从 Lib/test/test_exceptions.py 可以看到官方对 OSError 属性读写的全面测试:

cases.append((OSError(2, 'msgStr'), 'errno')) cases.append((OSError(2, 'msgStr'), 'strerror')) cases.append((OSError(2, 'msgStr'), 'filename')) cases.append((OSError(2, 'msgStr'), 'filename2'))

这些测试覆盖了每个专有属性的读写,确保任何内存管理改动不会破坏OSError的属性语义。

九、总结

项目内容
Issuegh-150988
修复文件Objects/exceptions.c
回归测试Lib/test/test_exceptions.py 的test_oserror_reinit_leak
news 条目Misc/NEWS.d/next/Core_and_Builtins/2026-06-05-22-52-41.gh-issue-150988.fDKfMJ.rst
影响范围OSError 子类在super().__init__()前设置 errno/strerror/filename/filename2 属性的代码
修复效果二次初始化或先设属性后初始化时,不再泄漏引用

对于 Python 开发者而言,本修复意味着:在自定义 OSError 子类时,应当避免在super().__init__()之前写入 OSError 的专有属性。这不仅是为了规避历史版本的泄漏 bug,更是一种内存管理的良好实践——让 C 层的结构化初始化全权负责专有字段,__dict__只承载自定义附加属性。

通过 Objects/exceptions.c 的源码,我们可以看到 CPython 如何在"性能"(结构化字段)与"灵活性"(__dict__)之间取得平衡,以及一次看似微小的 news 条目背后,是怎样的内存管理严谨性。

【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

python-docx安装实战:在线与离线全流程解析及常见坑

简介&#xff1a;Python-docx是一款无需依赖Microsoft Office即可操作Word文档的Python三方库&#xff0c;这份资源将其安装包与大量示例、测试文件一并打包&#xff0c;适合从事办公自动化、数据报表生成、批量文档处理的开发者和运维人员。压缩包共1209个文件、大小11.6MB&am…

作者头像 李华
网站建设 2026/9/10 1:02:34

磁编码器与RDC在机器人关节控制中的选型与应用解析

做机器人关节控制这些年&#xff0c;我越来越发现一个有意思的现象&#xff1a;前几年大家选反馈器件&#xff0c;基本闭眼就是光编&#xff0c;最多纠结一下用17位还是23位。但这几年风向明显变了&#xff0c;尤其是协作机器人、人形机器人以及一体化关节模组火起来之后&#…

作者头像 李华
网站建设 2026/9/10 1:02:02

DBSCAN三维聚类实战:从正态分布造数据到参数调优与可视化

1. 从"三个簇"说起&#xff1a;为什么要用三维正态分布造数据我拿到这个需求的第一反应是&#xff1a;这个场景选得挺巧妙的。DBSCAN这类密度聚类算法&#xff0c;最容易被误解成"又一个K-Means的变体"&#xff0c;而用三维正态分布随机数生成三个簇&#…

作者头像 李华