一、先说结论:内存泄漏不一定是代码写错了
最近在维护一个 Python 服务时,发现进程内存占用一路飙升,从最初的 300MB 涨到 2GB 多,重启之后过两天又涨回去。一开始我怀疑是某个第三方库没释放资源,排查了半天才发现,问题出在代码里的循环引用上。这篇文章记录一下我当时的排查思路和定位过程,希望能给同样踩坑的朋友一点参考。
二、现象:内存只涨不降
服务跑起来之后,我用ps aux观察进程的 RSS 内存,发现它呈现一种"阶梯式上涨"的规律:每次处理完一批任务,内存就涨一截,但任务结束后并不会回落。这基本可以排除临时大对象的问题,更像是对象被"留住"了,垃圾回收器一直收不掉。
为了确认是不是 Python 的垃圾回收机制出了问题,我先在代码里手动触发了一次gc.collect(),结果内存纹丝不动。这说明问题不在引用计数,而在循环引用导致的"不可达但无法回收"的对象。
三、定位工具:gc 模块和 objgraph
Python 自带的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], filename="refs.png", max_depth=3 )图里清清楚楚地显示,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__。 - 写回调、事件监听、观察者模式这类代码时,多想想引用方向,能用弱引用就别用强引用。
内存问题排查起来往往比较隐蔽,但只要掌握了正确的工具和思路,定位起来并不难。希望这篇文章能帮你少走一些弯路。