
当使用pytest或unittest.mock模拟函数并抛出异常时,Python的调用帧会意外持有对被测对象的强引用,阻碍__del__执行,本文详解其成因、验证方法及可靠规避策略。
当使用pytest或unittest.mock模拟函数并抛出异常时,python的调用帧会意外持有对被测对象的强引用,阻碍`__del__`执行,本文详解其成因、验证方法及可靠规避策略。
在Python中,__del__方法的触发时机由垃圾回收器(GC)决定,而非del语句本身——del仅减少引用计数,仅当对象引用计数归零且无循环引用时,GC才可能立即调用__del__。然而,在测试场景中,一个隐蔽但关键的因素常破坏这一预期:异常发生时,当前栈帧(frame)会自动捕获并持有对局部变量、异常对象及其关联对象的强引用。
这正是你遇到问题的根本原因:在test_del_fails中,mock.Mock(side_effect=[TimeoutError()])在del_test.run()内抛出TimeoutError,该异常被except TimeoutError: pass捕获。但注意——*异常对象(TimeoutError实例)在被捕获后,会通过sys.exc_info()和当前帧的f_locals/`fexc属性隐式关联到整个调用栈**。更关键的是,del_test作为run()方法的self,其引用被嵌入在该帧的局部作用域中;而该帧在异常处理期间不会被释放,从而持续持有对del_test的引用,导致其引用计数无法降为0,del`永不触发。
你可以通过gc.get_referrers()直观验证这一点:
import gc
import unittest.mock as mock
class DelTest:
def __init__(self, flags, test_fn):
self.flags = flags
self.test_fn = test_fn
def __del__(self):
self.flags[0] = 1
def run(self):
try:
self.test_fn()
except TimeoutError:
# 手动触发GC并检查引用者
print("Referrers of del_test:", gc.get_referrers(self))
pass
def test_del_fails_with_debug():
flags = [0]
test_fn = mock.Mock(side_effect=[TimeoutError()])
del_test = DelTest(flags, test_fn)
del_test.run()
assert flags[0] == 0
del del_test
# 此时仍可能为0 —— 因为帧引用未释放
print("After del del_test, flags =", flags) # 可能仍输出 [0]运行后,gc.get_referrers(self)极大概率返回类似如下结果:
立即学习“Python免费学习笔记(深入)”;
[<frame at 0x7f8b1c2a3e40, file 'test.py', line 17, code run>]
明确证实:当前栈帧是del_test的直接引用者。
✅ 可靠解决方案
1. 显式清空异常上下文(推荐)
在except块末尾调用sys.exc_clear()(Python 2)或sys.exc_info()后手动置空(Python 3)。Python 3.12+ 更推荐使用sys.unraisablehook,但最通用做法是:
import sys
def run(self):
try:
self.test_fn()
except TimeoutError:
# 清除当前帧的异常引用链
sys.exc_info() # 触发一次,确保异常信息被读取
# Python 3.12+ 可选:sys.settrace(None) 非必要,不推荐2. 避免在__del__中做关键断言(生产实践)
__del__本身具有不确定性(如程序退出时可能不调用),绝不应依赖它执行资源清理或状态变更。改用上下文管理器(with语句)或显式close()方法:
class DelTest:
def __init__(self, flags, test_fn):
self.flags = flags
self.test_fn = test_fn
self._alive = True
def close(self):
if self._alive:
self.flags[0] = 1
self._alive = False
def run(self):
try:
self.test_fn()
except TimeoutError:
pass
# 测试写法(稳定可靠)
def test_del_fails_fixed():
flags = [0]
test_fn = mock.Mock(side_effect=[TimeoutError()])
del_test = DelTest(flags, test_fn)
del_test.run()
assert flags[0] == 0
del_test.close() # 显式触发,100%可控
assert flags[0] == 13. 强制GC并验证(仅用于调试)
若必须验证__del__行为,可在del后手动触发GC:
import gc
def test_del_fails_gced():
flags = [0]
test_fn = mock.Mock(side_effect=[TimeoutError()])
del_test = DelTest(flags, test_fn)
del_test.run()
assert flags[0] == 0
del del_test
gc.collect() # 强制垃圾回收
assert flags[0] == 1 # 现在大概率通过⚠️ 注意:gc.collect()不能保证__del__立即执行(尤其存在循环引用时),且不应出现在生产代码中。
总结
- __del__不是析构函数,而是“终结器”,行为受GC策略和引用图影响;
- Mock抛异常 → 帧对象持引用 → del_test无法被回收 → __del__不执行;
- 根本解法是放弃对__del__的时序依赖,改用显式资源管理;
- 调试时善用gc.get_referrers(obj)定位隐藏引用者;
- 在测试框架中,优先选择可预测、可控制的清理机制(如contextlib.closing、addCleanup等)。
遵循以上原则,即可彻底规避Mock异常引发的引用泄漏陷阱,写出健壮、可维护的Python测试代码。


















