首页 / 资讯中心 / 文章详情

Python 3.12魔法方法__del__:调用时机不确定,资源清理慎用

Python 3.12魔法方法__del__:调用时机不确定,资源清理慎用 ★ FEATURED ARTICLE
今天来聊 Python 3.12 魔法方法系列里的第四个主角__del__。说实话这个魔法方法大概是整个系列里最容易被误用、也最值得敬畏的一个。先讲一个我自己的翻车经历我维护的一个服务里有一个批量日志写入器为了省事我把最终的落盘逻辑放在__del__里觉得对象被回收时顺理成章就能把缓冲区数据写完。结果用 Python 3.12 一跑程序正常退出后文件十有八九是空的偶尔还附带一段 Exception ignored in 的输出。当时我第一反应是落盘函数写错了排查了一下午之后才发现问题不在我写的代码而在于__del__的调用时机比我想象的狡猾得多。这篇就把我踩过的坑和验证过的结论从头梳理一遍顺便聊聊在 Python 3.12 里它到底有没有变。1. 一次把缓存刷丢的教训先说清楚__del__为什么不可靠1.1 我当时写的看起来很美的代码先还原一下当年的场景。我维护的服务会收集大量日志条目攒够一定数量后批量写入磁盘用来减少 IO 次数。类大概是这样的import json import os class BatchWriter: def __init__(self, path: str, batch_size: int 100): self.path path self.batch_size batch_size self.buffer [] self.closed False def write(self, entry): self.buffer.append(entry) if len(self.buffer) self.batch_size: self.flush() def flush(self): if not self.buffer: return with open(self.path, a, encodingutf-8) as f: for entry in self.buffer: f.write(json.dumps(entry, ensure_asciiFalse) \n) self.buffer.clear() def __del__(self): self.flush()设计逻辑很简单使用者只需要不断调用write等进程结束、对象被垃圾回收时__del__会把剩余的缓冲区数据刷进文件。省掉了使用者记得 close的心智负担听起来很贴心对吧实际上这是一种非常危险的设计。1.2 实测现象文件经常是空的当时我用python script.py跑完整个流程退出后去看日志文件。结果很有意思如果程序运行时间很短文件大概率是空的。如果程序运行时间较长、缓冲区已经自动flush过几次那么文件里只有前几次刷进去的内容最后那批剩余数据总是丢。偶尔控制台会吐出一句Exception ignored in: function BatchWriter.__del__ at 0x...。问题不是flush本身有 bug而是__del__在解释器退出阶段的触发条件太不靠谱。CPython 启动退出流程时会先把各模块的全局名字一个个替换成None再逐步回收对象。也就是说当我的BatchWriter实例终于被回收并触发__del__时模块级别可访问的json、open这些全局引用可能已经被清掉了self.flush()内部一旦用到这些名字拿到手就是None于是 flush 直接抛异常。那为什么偶尔还会看到 Exception ignored in因为__del__里抛出的异常不会被正常传播而是被变成一条 unraisable exception 输出。从使用者的角度看就是程序正常退出了但好像有什么东西没做完而且没告诉我是哪行出的问题。1.3 根本原因它根本不是析构函数很多人习惯把__del__叫析构函数这会带来严重的误导。C 的析构函数是确定性的对象离开作用域析构立刻执行编译器保证调用顺序可控。而 Python 的__del__本质上只是垃圾回收器在销毁对象前发的一条通知这条通知什么时候发、以什么顺序发、甚至到底发不发都不在你的掌控之中。参考官方文档的表述__del__是一个终结器finalizer它说得很直白不要依赖它在确定的时间被调用。对于资源管理官方更推荐上下文管理器、atexit、weakref.finalize这类确定性更强的机制。我那次事故之后把BatchWriter改写成了支持上下文管理器的类__del__只保留一个防御性兜底数据丢失的问题就再也没出现过。这个兜底怎么写后文会专门讲。2. 调用时机之谜引用计数、循环引用和解释器退出的完整链路想要用好任何一个语言特性先搞清楚底层机制。__del__的调用时机由对象生命周期的终止路径决定在 CPython 里大概有以下三条主线。2.1 引用计数归零唯一算得上及时的路径CPython 内部为每个对象维护一个引用计数refcount。当一个对象的引用计数降到 0它会立刻被回收回收前如果定义了__del__就会触发调用。这是所有路径里最接近确定性的一种但代码里能看到的引用往往比你想象的多。举个例子class Spy: def __del__(self): print(Spy.__del__ called) def hello(): spy Spy() print(inside) hello() print(done)输出是inside Spy.__del__ called donehello()函数执行到 return 时局部变量spy离开作用域引用计数归零于是立刻调用__del__。这个例子看起来很好用但问题在于什么时候引用计数归零并不直观。一个对象可能同时被局部变量、容器、参数、traceback、闭包、属性等等多个地方引用只有当最后一处引用消失时才会归零。尤其需要注意del关键字。很多人以为写了del obj就代表对象被删除了其实del只是删除当前这个命名空间的变量名让对象的引用计数减一。如果函数栈、sys.last_traceback、其他容器里还握着它__del__依然不会触发。在交互式解释器里尤其容易踩坑你运行一次代码异常对象和 traceback 会被解释器本身引用导致对象迟迟不被回收看起来就像__del__没被调用。2.2 循环引用只能靠垃圾回收器兜底引用计数机制有一个著名的死角循环引用。两个对象互相持有对方的引用外部引用全部消失后它们的引用计数依然不会归零这时候只能靠 CPython 的垃圾回收器gc模块通过标记清除算法找出这些不可达的对象环并回收。import gc class Node: def __init__(self, name): self.name name self.peer None def __del__(self): print(fNode {self.name} collected) a Node(a) b Node(b) a.peer b b.peer a del a, b print(before gc.collect()) gc.collect() print(after gc.collect())输出before gc.collect() Node a collected Node b collected after gc.collect()注意如果没有gc.collect()这两个 Node 会一直待在内存里__del__不一定会被触发。CPython 的 GC 会在满足阈值时自动运行通常是分配对象数量达到一定值但触发时间完全不由你控制。这里有个历史遗留问题值得一提在 Python 3.4 之前定义了__del__的对象一旦陷入循环引用会被 GC 判定为不可回收放进gc.garbage列表必须手动gc.collect()再手动处理否则就是内存泄漏。PEP 442 之后CPython 重新设计了终结器机制定义__del__的循环引用对象也能被安全回收了。今天写代码时可以不用再关心gc.garbage但这种历史背景能帮助你理解为什么社区里很多老文章都在说 __del__会造成内存泄漏。2.3 解释器退出阶段的清理顺序最容易翻车的路程序正常退出时解释器会执行Py_FinalizeEx流程整个退出阶段的主要步骤大致是先等待非守护线程结束再调用atexit注册的回调接着清理模块全局状态最后回收堆内存。关键点在于atexit回调执行时模块还完整可用而__del__触发得晚很多——它往往发生在模块全局名已经被替换成None、解释器进入堆内存全面回收的阶段。所以如果你在__del__里访问了模块级变量、导入的模块、全局缓存、logger 等大概率拿到None或者抛NameError。这就是我日志丢失事故的完整链条主程序结束进入退出流程。模块全局名字开始被清理BatchWriter还能被某些引用拖着但json、open等模块级引用已经开始变成None。对象最终被回收__del__被触发。self.flush()内部使用已经失效的全局引用抛异常。异常走 unraisable 通道只打印一行提示程序照常退出。把这三条路径串起来看结论很清晰__del__的调用时机是不保证、不确定、不排队的。它只适合做能容忍丢失、能容忍任意执行顺序的轻量清理绝对不能用来承载核心业务逻辑。3. 五个让我血压升高的__del__反模式有了上面的机制基础下面这些坑就很好理解了。我见过太多人前赴后继地踩这里列成清单每一个都用最简短的例子说明症状和原因。3.1 在__del__里抛异常反模式一也是最常见的__del__内部执行了可能抛异常的操作然后完全没有兜底。异常不会被正常传播给调用方只会变成控制台上那句晦涩的Exception ignored in。Python 3.8 之后未报告异常的通道被正式化称为 unraisable exception它会调用sys.unraisablehook。默认 hook 的行为在 3.12 里已经比较克制但依然很容易被忽略。正确做法是先try包裹或者用warnings.warn把问题显性化而不是让异常静默丢给解释器。3.2 假设self一定被初始化完整了__del__不一定在__init__成功之后才调用。如果__init__中途抛了异常对象照样会被回收__del__照样会被触发但此时的self可能只初始化了一半。你在__del__里访问self.xxx一旦这个属性没来得及创建就会再次异常。这类问题在多层继承里尤其隐蔽。子类的__init__忘了调用super().__init__()父类里定义的那些属性全部不存在但父类的__del__依然会被调用于是它访问self.attr时直接抛AttributeError。防御写法很简单用getattr(self, attr, None)或者检查属性是否存在。3.3 用__del__清理循环引用中的资源前面说过循环引用里的对象依赖 GC 才能回收。如果你把释放数据库连接关闭文件这类重要操作放在__del__里而对象又陷入了引用环那么__del__什么时候触发完全不可控甚至可能推迟到程序快退出时才触发。更麻烦的是如果__del__执行时恰好又产生了新的引用链比如访问了其他全局对象GC 可能判定整个过程不可完成把它再留到下一轮。尤其是老代码里常见的gc.collect()配合自定义类链路一不留神就成了gc.garbage钉子户。今天的 Python 对这种情形的处理好了很多但仍然不值得用__del__去赌这个时机。3.4 访问模块级全局状态这是最隐蔽也最致命的一类。__del__触发时模块全局引用可能已经被解释器清理掉导致self.logger.info(...)、global_cache.pop(...)、open(...)这类操作全部静默失败。这里有一条铁律__del__里只允许访问对象自身的属性不允许访问模块级、类级的可变全局状态也不要去 import 新模块。因为解释器退出阶段能 import 的东西已经很少而且模块清理顺序不能依赖。凡是需要访问全局对象才能完成的清理都应该用atexit或者weakref.finalize或者上下文管理器来做而不是放进__del__。3.5 试图复活对象有些场景下开发者会在__del__里把self保存到一个全局列表试图推迟释放。这在 CPython 里确实能让对象复活但会带来诡异的二次__del__调用对象复活后如果再次变成不可达它还会被回收一次__del__会被再次调用而第二次调用时的执行上下文很可能已经不具备完成清理的条件最终产生一连串 unraisable exception。官方文档明确提示这一点如果你把对象重新置为可达它将被保留到最后处理此时如果它还活着__del__会再次被调用。 这完全不是可预测的行为。复活通常发生在某种缓存清理逻辑里但正确的做法应该用weakref.finalize来注册清理回调回调不会绑定在对象身上也不会被这种复活机制干扰。4. Python 3.12 实测同样的代码行为到底变了没有这套系列既然是针对 Python 3.12 的我必须实际建环境验证一遍。用 conda 建立干净环境非常方便conda create -n magic312 python3.12 -y conda activate magic312 python --version4.1 三个基础行为验证回收时机、循环引用、退出顺序我在 3.12 环境里重新跑了前面那三个例子结果和我预期一致局部变量离开作用域__del__立刻触发。循环引用调用gc.collect()后触发不调用则不确定。模块级引用被全局拖住时__del__会等到退出阶段才触发而且顺序不可控。单独看 3.12 的这几个行为和 3.11 相比没有本质区别。__del__的不确定性是语言设计层面的不是某个版本修个 bug 就会反转的。4.2 3.12 里真正值得注意的两处底层变化虽然行为没变但 3.12 内部有两处改动会间接影响__del__的观测体验。第一处是 PEP 683也就是不朽对象机制。它让None、True、False以及解释器内部的类型对象等极少数核心单例在运行期内永远保持活跃、引用计数不再变化。听起来跟__del__关系不大也确实如此——普通用户自定义的对象不会变成 immortal。但它的意义在于GC 在标记阶段需要扫描的对象更稳定回收停顿更可控间接让__del__在复杂场景下更难因为解释器忙不过来而被无限延后。第二处是 3.12 对垃圾回收器内部实现的优化。官方发布说明里提到小对象收集阶段现在有更精细的处理部分收集动作不再完全停止其他线程。这种优化虽然不会把__del__变成确定性调用但能让依赖 GC 回收的循环引用对象更快地被收尾缩短了__del__被卡住的时间窗口。真实感受是跑同样一批压力测试3.12 下攒到最后才统一触发的现象比 3.11 轻微一些。4.3 用-X dev和自定义 unraisable hook 揪出问题如果你怀疑某个对象莫名其妙的没被清理或者__del__里出了错但看不到 traceback第一件事就是打开开发模式运行一遍python -X dev your_script.py-X dev会默认开启ResourceWarning、把faulthandler挂在信号上、优化字节码缓存等特别适合开发阶段抓资源泄漏。配合-W error::ResourceWarning还能把警告升级为异常强制你面对问题python -X dev -W error::ResourceWarning your_script.py另外一个非常实用的手段是自定义sys.unraisablehook。__del__里发生的异常走的就是这条路默认行为经常只打一行字原因和 traceback 都没有。你可以注册一个 hook 把完整信息打印出来import sys def show_unraisable(unraisable): err unraisable.exc_type.__name__ msg str(unraisable.exc_value) obj_repr unraisable.object print( fUnraisable during __del__: {err}: {msg}, fobject: {obj_repr}, filesys.stderr, ) tb unraisable.exc_traceback if tb is not None: traceback.print_tb(tb) sys.unraisablehook show_unraisable把这个 hook 放到一个常驻服务的启动入口里以后任何__del__悄悄出错都不会再被忽略。这是我在生产环境里用过最值回票价的一个调试小技巧。关于 3.12 还有一个很少被提及的细节异常回溯相关的生命周期做了调整正常情况下 traceback 不再对帧里的局部变量保持强引用。这意味着异常对象被 traceback 拖着不放导致相关对象迟迟不能触发__del__的问题在 3.12 里大幅缓解。以前你在except块里保存了异常对象等于间接把一大串局部变量全部钉死在内存里3.12 则不会因为保留 traceback 就无限期持有这些局部对象的引用。这个变化对排查 为什么我的对象始终没有被回收 的问题非常有帮助。5. 要用怎么用正确姿势、替代方案与兜底设计既然__del__这么危险那是不是完全不能碰也不能这么绝对。它作为最后一道防线还是有一定存在价值的关键是分清职责边界。5.1 三条替代路径优先于__del__我推荐的清理手段优先级是上下文管理器 atexit/weakref.finalize__del__。上下文管理器是资源清理的第一选择。把对象设计成支持with语句调用方必须显式进入和退出清理时机完全可控class BatchWriter: def __init__(self, path): self.path path self.buffer [] self.closed False def write(self, entry): if self.closed: raise RuntimeError(writer already closed) self.buffer.append(entry) def flush(self): # 真实落盘逻辑 def close(self): if self.closed: return self.flush() self.closed True def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.close()调用方只需要with BatchWriter(out.log) as w:无论正常退出还是抛异常__exit__都会被执行。这个模式的缺点是依赖调用方配合没人保证他一定写with而不是漏掉 close。weakref.finalize是官方推荐的替代__del__的机制。它允许你给对象注册一个回调在对象真正销毁时调用但回调并不绑定在对象上也不会因为对象复活而重复执行比__del__干净得多import os import weakref class TempDir: def __init__(self, base_dir): self.dir os.path.join(base_dir, tmp_data) os.makedirs(self.dir, exist_okTrue) weakref.finalize(self, self._cleanup, self.dir) staticmethod def _cleanup(path): if os.path.isdir(path): os.rmdir(path)注意finalize的回调接收普通参数而不是接收对象本身这天然规避了访问半初始化 self的问题。回调执行时机仍然由垃圾回收触发但不是像__del__那样作为对象的魔法方法参与 GC 判定整体稳定很多。atexit.register用于进程退出时需要执行的清理动作它的最大优势是执行时机在模块全局清理之前可以安全访问模块级状态。适合做进程级的收尾比如关闭全局连接池、打包临时产物。5.2 如果真的用__del__请按这个姿势写有些场景绕不开__del__比如底层封装 C 资源、或者你无法保证调用方会使用上下文管理器。这时候至少把以下规则列进代码审查清单__del__内部只访问self的普通属性绝不访问模块级、类级全局可变状态。把所有可能出错的操作包进try异常用warnings.warn或记录到sys.stderr不要裸奔。用getattr(self, xxx, None)处理属性不存在的可能。清理动作必须是幂等的即重复执行不会出问题因为__del__有可能被调用一次以上。不要在__del__里做轻量操作以外的事比如发网络请求、开新线程、等待锁这些行为在解释器退出阶段可能直接卡死。提供公开的close()/cleanup()方法__del__只是调用它不要让使用方只能依赖__del__完成清理。设置一个_closed标志防止close()重复执行。一个参考模板import warnings class ManagedFile: def __init__(self, path): self._path path self._handle None self._closed False def _open(self): # 打开底层句柄 def close(self): if self._closed: return self._closed True if self._handle is not None: self._handle.close() self._handle None def __enter__(self): if self._closed: raise RuntimeError(file already closed) return self def __exit__(self, exc_type, exc_val, exc_tb): self.close() def __del__(self): try: self.close() except Exception: warnings.warn( ManagedFile.close failed while finalizing object, ResourceWarning, stacklevel2, )这样既提供了确定的清理路径上下文管理器又留了最后一道防线__del__兜底而__del__内部不会访问任何全局状态异常也不会被吞得无声无息。我用这套模式重构过多个资源类稳定性明显提升。6. 我现在的选择一份防御式清理的排查三件套踩过这么多坑之后我给自己定了一套固定的作业流程在这里完整分享给你。第一所有资源类必须实现上下文管理器这是面向使用方的主路径。第二对于进程级资源用weakref.finalize注册一个独立的清理回调它和对象生命周期解耦即使主路径被绕过最差也能在对象销毁时兜住。第三如果确实定义__del__它只负责调用一个公开的close()方法且这个方法必须是安全、幂等、不依赖全局状态的。排查已有代码时我的启动顺序是先在开发环境里加上自定义sys.unraisablehook再以-X dev -W error::ResourceWarning运行整套测试最后用gc.collect()配合gc.get_objects()检查是否有对象长期残留。这个过程基本能把 90% 的隐藏资源问题暴露出来。最后补充一个小技巧。如果实在搞不清楚某个对象为什么没被回收可以在类里临时加一段打印__del__调用代码但不要在线上直接这么干。更优雅的方法是使用weakrefimport weakref refs [] # 创建对象 obj SomeResource() # 注册清理观察 refs.append(weakref.ref(obj, lambda r: print(object collected)))当对象真正被回收时弱引用的回调会执行。利用这个机制你可以在不修改业务类的前提下精准观察对象生命周期。结合gc.collect()手动触发很快能定位是哪个外部引用把对象留住了。__del__这个魔法方法本质上不是让你拿来当析构函数用的。它更像一个保险丝平时不该有电流经过但真到了极端情况它能防止更坏的结果。把它放在这个位置上Python 3.12 里基本不会给你惹麻烦。
阅读完成 · 觉得有帮助?
咨询建站