症状
悖论本身就是线索:每个对象都 del 了,tracemalloc 的 current 回到基线,RSS 却逐请求爬升。此时该给“对象群体”而不是“字节”装仪表:objgraph.show_growth 显示某类型计数随请求数线性增长——那就是泄漏群体。再任取一个成员审问它的保活者:gc.get_referrers 返回持有它的注册表、栈帧或闭包,这些就是需要切断的环边。另一个信号是 gen2 回收在发生而内存不归还,以及 gc.garbage 非空——含 del 的循环会被永久停进不可回收列表。
诊断思路
先按类型统计对象增长(objgraph 前后对比),锁定泄漏群体;再用 get_referrers 找出保活链——长生命周期注册表与每请求对象互相引用、缓存键值互指加自指、异常对象通过 traceback 持有局部帧并与处理器闭包成环,是三大常见边;最后 gc.collect() 后看 gc.garbage 长度,非空即存在收集器拒绝触碰的 del 循环。
根因
引用计数只在无引用时立即释放对象。循环让每个成员计数恒大于零,回收只能寄希望于分代 GC 在恰当时机运行;含 del 的循环可能被永久停进 gc.garbage。长生命周期容器把每请求对象卷进循环,就把瞬态数据变成了永久驻留——del 无济于事,因为计数清不到零。
修复与验证
在设计层破环:反向指针(父链接、实例缓存)用 weakref.ref 或 WeakValueDictionary;容器清空用显式 close 或上下文管理器在退出时 del 成员;资源所有者放弃 del 改用上下文管理器协议(直接删除 del 会失去清理保证,必须替换而非移除);存储异常对象时先 exc.traceback = None 截断帧链。验证时重复跑批次,断言各类型对象计数回到基线、RSS 进入平台期、完全收集后 gc.garbage 保持为空——三个断言合起来证明环已断且不可回收列表不再积累。
代码示例
import objgraph
objgraph.show_growth(limit=10)
# func_with_closure 1250 +1250
# RequestContext 1250 +1250
import gc
sample = [o for o in gc.get_objects() if isinstance(o, RequestContext)][0]
print(gc.get_referrers(sample)[:3])
# [dict of registry, <frame ...>, the handler closure]
import weakref
class Node:
def __init__(self, parent):
self.parent = weakref.ref(parent) # does not keep parent alive
self.children = []
from weakref import WeakValueDictionary
instances = WeakValueDictionary() # id -> instance, no retention
class Session:
def close(self):
for child in self.children:
child.parent = None # break cycles deterministically
self.children.clear()
def __enter__(self): return self
def __exit__(self, *exc): self.close()