我第一次接触 Python 项目内存泄露的时候,说实话,我是懵的。因为你跟我说 C++ 内存泄露我能理解,new 了没 delete,malloc 了没 free,这很直白对吧。但 Python 不是有 GC(垃圾回收机制)吗?不是传说中“没有内存泄露”的语言之一吗?这事儿后来我才明白,真不是那么回事。Python 虽然有 GC,但也不是万能的,照样会出现内存泄露,而且还挺难察觉的。今天咱们就来聊聊这个事儿,用一些实际案例和经验教训来把这个话说明白。
首先要搞清楚,啥叫“内存泄露”?通俗点说,就是你申请了内存,结果后来你再也用不到它了,但这块内存却一直被占着,系统以为你还会用,就不回收。最后越攒越多,直到 OOM(Out Of Memory),程序挂了,运维同学半夜给你打电话。这种问题最恶心的点就在于,它不像语法错或者逻辑错那样立刻就爆出来,而是悄悄地、慢慢地、默默地搞崩你的程序,等你发现的时候已经太晚了。
Python 的 GC 机制主要是基于引用计数的。也就是说,每个对象都有一个引用计数,谁用它,计数就加一,谁不再用它,计数就减一,减到 0 就可以回收了。但问题也正出在这:如果两个对象互相引用,而外部没有引用它们,那么它们的引用计数就永远不会变成 0,GC 就没法清掉它们。这就是“循环引用”,是 Python 内存泄露最经典的来源之一。
我当年就踩过这么一个坑。我们系统里有个缓存机制,是用字典来保存计算结果的,key 是参数 hash,value 是一个对象。这个对象里偏偏还保存着原始参数对象的引用,等于一个循环回路形成了。平时没事,但一跑大数据量压测,内存怎么清也清不掉。最开始我们怀疑是缓存没有清理,于是各种清缓存策略都试了一遍,甚至把 LRU 缓存上限调成 100,结果程序还是越跑越慢。最后定位下来,才发现是那个看起来无害的引用搞了个环出来,垃圾回收器压根没办法释放。
说到这,不得不提 Python 的 gc 模块。这个模块其实是 Python 为了解决循环引用问题额外加上的机制,它会定期扫描对象之间的引用关系,找出那些“你中有我、我中有你”但外部没人用的对象组合,然后释放它们。但问题是,这个扫描是有代价的,如果你对象之间的引用关系过于复杂,gc.collect() 的时间开销可能会很大,甚至造成卡顿。所以,在性能敏感的系统里,我们不能太依赖这个自动机制,得自己上点心。
一个常见的解决办法是弱引用(weakref)。Python 提供了 weakref 模块,允许你创建一个不会增加引用计数的引用。比如缓存的时候你可以用 WeakValueDictionary,它自动帮你在原始对象没引用的时候清掉缓存值,这就避免了“你引用我我引用你”的死循环。这个机制我现在在写服务端或者处理大型数据对象时都会默认考虑进去,尤其是涉及闭包和回调的地方,特别容易无意中留下引用痕迹。
然后我们还得说说闭包这个大坑。Python 的闭包看起来很美,但也特别容易出问题。比如你在函数里定义一个函数,然后把它作为回调函数传出去,但这个闭包捕获了外部变量的一些状态对象,这些状态对象本身又引用了这个函数或者其上下文,boom,一个循环就形成了。我们团队有次写协程任务调度器时,为了方便传状态就用了大量 lambda 作为回调,结果过了一段时间之后发现调度器内存暴涨。这才意识到,每个 lambda 都把整个状态对象都“吃”进去了,而这些对象又存着 lambda 的引用,形成闭环,GC 无能为力。
要避免闭包问题,其实也简单,一种做法是把闭包函数做得足够纯粹,不捕获任何复杂对象;还有一种是用 functools.partial 明确参数传递,避免隐式引用。说白了,就是写代码别太“聪明”,保持清爽一点,有时候反而更安全。
除了循环引用和闭包之外,还有个隐蔽点是全局变量或者“长生命周期对象”不小心引用了短生命周期的大对象。这种情况在服务类应用特别常见。比如你在某个类变量或者模块变量里保存了一个临时的数据引用,本意是为了调试或者缓存,结果这个变量一直不释放,导致内存一直涨。我就遇到过有人为了调试方便,在 Flask 的全局 g 对象里挂了整个 request 的上下文,结果是开发环境跑得挺稳,一上线直接炸。调试工具用完记得清,缓存搞完记得删,这种习惯真的能救命。
说到调试内存泄露,不得不提 objgraph 这个神器。你可以用它来追踪某个对象的引用链,看它是被谁“挂住”了。如果你怀疑某个类的对象一直没释放,就用 objgraph.show_backrefs 或者 objgraph.find_backref_chain 跟踪一下,往往能发现隐藏在角落的引用。这个工具在我排查生产环境的内存问题时立过大功,基本是定位闭环问题的第一选择。
当然还有更暴力的办法,比如 tracemalloc,它能记录内存分配的堆栈,帮你追溯哪个函数分配了最多的内存。你甚至可以对比两个时间点的内存分布情况,看看内存增长到底是从哪里开始的。不过这个工具开销比较大,一般用于分析内存分配热点而不是长时间运行。
我个人觉得,Python 项目最容易引起内存泄露的地方,除了我上面说的这些,还有就是第三方库的坑。你以为你用了个“成熟”的库,它就不会出问题,但实际很多库内部管理对象的方式一言难尽。特别是那些 C 扩展包装的库,它们一旦处理不当,你 Python 层根本看不到对象是怎么泄漏的。比如某次我们用一个图像处理库处理海量图片,结果就是处理函数每调用一次,内存涨一点,最后涨满了也不回收。检查代码啥也没发现,最后一看是 C 代码里没释放 buffer。所以,不管你用啥库,最好都写点资源释放逻辑,比如 with 语句包一层、手动调用 close()、加点断点监控内存曲线,别指望别人为你兜底。
总的来说吧,Python 虽然不像 C/C++ 那么容易出内存问题,但也绝对不是“内存安全”的代名词。只要你的代码里涉及复杂对象、回调函数、缓存机制、闭包引用、第三方库,那你就得提防内存泄露。别被 GC 的“自动”给骗了,它只是帮你做了 80% 的清理工作,剩下 20% 还得靠你自己擦屁股。
最后一点建议是,不要等到出问题才想着“我是不是内存泄露了”,平时就要养成资源管理的好习惯。函数用完的东西立刻 del,缓存记得设过期,闭包别乱写,异步回调控制引用链,尤其是写服务的时候多留心内存曲线,设点监控报警,这样你晚上才能睡个安稳觉。
好了,这话题说到这儿,你要是刚好准备面试,把我说的几个坑都过一遍,写段小 demo 练练手,面试官问你“Python 会不会内存泄露”,你就可以笑着说:“我不光知道它会,我还知道它是怎么漏的”。这样才有底气。你觉得还有啥地方容易被坑吗?我最近倒是还在研究 asyncio 那一套,不知道那里会不会藏着更大的雷……
扫码二维码 获取免费视频学习资料
- 本文固定链接: http://www.phpxs.com/post/13287/
- 转载请注明:转载必须在正文中标注并保留原文链接
- 扫码: 扫上方二维码获取免费视频资料