Python内存泄漏真相:循环引用才是元凶
一、先说结论内存泄漏不一定是代码写错了最近在维护一个 Python 服务时发现进程内存占用一路飙升从最初的 300MB 涨到 2GB 多重启之后过两天又涨回去。一开始我怀疑是某个第三方库没释放资源排查了半天才发现问题出在代码里的循环引用上。这篇文章记录一下我当时的排查思路和定位过程希望能给同样踩坑的朋友一点参考。二、现象内存只涨不降服务跑起来之后我用ps aux观察进程的 RSS 内存发现它呈现一种阶梯式上涨的规律每次处理完一批任务内存就涨一截但任务结束后并不会回落。这基本可以排除临时大对象的问题更像是对象被留住了垃圾回收器一直收不掉。为了确认是不是 Python 的垃圾回收机制出了问题我先在代码里手动触发了一次gc.collect()结果内存纹丝不动。这说明问题不在引用计数而在循环引用导致的不可达但无法回收的对象。三、定位工具gc 模块和 objgraphPython 自带的gc模块是排查这类问题最趁手的工具。我写了一段简单的脚本把当前所有未被回收的对象按类型统计出来import gc import collections gc.collect() counts collections.Counter(type(obj).__name__ for obj in gc.get_objects()) for name, cnt in counts.most_common(20): print(f{name}: {cnt})跑完之后我发现TaskHandler和Callback这两个类的实例数量异常多而且数量还在持续增长。直觉告诉我问题就藏在这两个类的关系里。四、找到元凶互相持有引用我翻了一下业务代码发现TaskHandler里保存了一个Callback的引用而Callback又反过来持有TaskHandler的引用。两个对象互相抱死引用计数永远不为零垃圾回收器虽然能检测到循环引用但如果对象还定义了__del__方法就会进入不可回收的名单。我用objgraph画了一下引用关系图确认了这条引用链import objgraph objgraph.show_refs( [handler], filenamerefs.png, max_depth3 )图里清清楚楚地显示TaskHandler和Callback之间有一条双向箭头这就是内存泄漏的根源。五、修复方案打破循环引用定位到问题之后修复其实很简单。我选择了两种方式把Callback里对TaskHandler的强引用改成弱引用用weakref.ref()或者weakref.proxy()来持有。在对象不再需要时显式把引用置为None主动断开引用链。改完之后我重新跑了一遍压力测试内存曲线终于变得平稳任务处理完内存也会正常回落。下面是修复后的核心代码片段import weakref class TaskHandler: def __init__(self): self.callback None def set_callback(self, callback): # 用弱引用持有回调避免循环引用 self.callback weakref.ref(callback) class Callback: def __init__(self, handler): # 这里不再强引用 handler改用弱引用 self.handler weakref.proxy(handler)六、几点排查建议这次踩坑让我总结出几条经验分享给大家内存泄漏不一定是没释放资源循环引用同样会让对象永远无法回收。排查时优先用gc.get_objects()统计对象数量比盲目加日志高效得多。如果对象定义了__del__循环引用会导致它彻底无法回收尽量避免在业务类里定义__del__。写回调、事件监听、观察者模式这类代码时多想想引用方向能用弱引用就别用强引用。内存问题排查起来往往比较隐蔽但只要掌握了正确的工具和思路定位起来并不难。希望这篇文章能帮你少走一些弯路。