《Python高级编程》4.3 内存泄漏定位

本节用标准库 tracemalloc 做快照对比,把一次真实泄漏定位到具体行:两次各 500 个事件后,泄漏行的分配增量是 +527.9 KiB、+500 次分配;再用 objgraph 的 show_growth 与 find_backref_chain 画出 module→globals→_REGISTRY→Session 的引用链,并说明本机无 graphviz 时只能用文本 API。

本节目标:掌握用 tracemalloc 快照对比把内存增长定位到具体代码行、用 objgraph 找出增长对象并画出引用链的完整流程。
适用版本:Python 3.12+(实测 3.14.6)

4.3 内存泄漏定位

前两节把「对象怎么被回收」和「对象占多少字节」讲完了。这一节解决一个更现实的问题:线上进程 RSS 一直涨,怎么找到是谁在涨? Python 几乎没有传统 C 意义上的「泄漏」——没有指针忘记释放。Python 里的「泄漏」几乎都是同一个意思:对象还被人引用着,所以 GC 认为它还有用。定位泄漏,本质就是找出「谁还引用着它」。

工具只有两个是必需的:标准库 tracemalloc(找分配在哪个文件哪一行)和 objgraph 3.6.2(找谁还引用着它)。它们回答的是两个不同的问题,缺一不可。

4.3.1 Python 的「泄漏」长什么样

在动手之前先把常见泄漏源列清楚,后面看到增长对象时能直接对号入座:

泄漏源表现典型写法
无上限的全局缓存进程越跑越大,重启就好_CACHE[key] = obj 从不清理
注册表 / 监听器未注销对象数只增不减_REGISTRY.append(self)
闭包或全局持有大对象局部对象迟迟不释放回调里 append 了数据帧
循环引用 + gc.disable()见 4.1 节,RSS 稳步涨关掉 GC 的服务器
C 扩展忘记 Py_DECREF引用计数永远不降自写扩展模块

记住一句话:能靠引用计数回收的对象不会泄漏,泄漏的都是「还可达」的对象。

4.3.2 tracemalloc:把增长定位到行

tracemalloc 记录每次内存分配的调用栈。开一个带栈深度的追踪(1 表示只记 1 帧,够定位分配行),然后对比两个时间点的快照即可。下面是一个真实的泄漏场景——全局字典只增不减,外加一个被全局持有的临时列表:

import tracemalloc
import gc

# ---- 模拟两类泄漏 ----
_CACHE = {}                      # 泄漏源 1:只增不减
_LEAKY_HOLDERS = []              # 泄漏源 2:临时列表被全局持有

class Event:
    def __init__(self, name):
        self.name = name
        self.payload = bytearray(1024)   # 每实例 1 KiB 独立负载

def handle(tag):
    # 缺陷 1:塞进全局字典,键唯一 -> 无限增长
    ev = Event(tag)
    _CACHE[tag] = ev
    # 缺陷 2:临时列表被全局意外持有
    _LEAKY_HOLDERS.append([ev] * 10)

def run(n):
    for i in range(n):
        handle(f"evt-{i}")

gc.collect()
tracemalloc.start(1)
run(500)
s1 = tracemalloc.take_snapshot()     # 第一次快照
run(500)                             # 再跑同样多
s2 = tracemalloc.take_snapshot()     # 第二次快照

注意负载用的是 bytearray(1024) 而不是 b"x" * 1024——原因见 4.2.8:后者会被常量折叠成同一个对象,根本不会随实例增长。

先看单次快照的 Top 分配:

for stat in s1.statistics("lineno")[:4]:
    tb = stat.traceback[0]
    print(f"{stat.size/1024:8.1f} KiB  {tb.filename.split('/')[-1]}:{tb.lineno}")

实测输出:

   527.9 KiB  leak_tracemalloc.py:11
    70.5 KiB  leak_tracemalloc.py:18
    46.2 KiB  leak_tracemalloc.py:15
    23.3 KiB  leak_tracemalloc.py:22

但单次快照只能告诉你「谁占得多」,不能告诉你「谁在涨」。真正定位泄漏要用两次快照的差:

diff = s2.compare_to(s1, "lineno")
for stat in diff[:4]:
    tb = stat.traceback[0]
    print(f"+{stat.size_diff/1024:8.1f} KiB  {tb.filename.split('/')[-1]}:{tb.lineno}  ({stat.count_diff:+d} 次)")

实测输出:

+   527.9 KiB  leak_tracemalloc.py:11  (+1001 次)
+    71.0 KiB  leak_tracemalloc.py:18  (+1001 次)
+    43.0 KiB  leak_tracemalloc.py:15  (+500 次)
+    23.3 KiB  leak_tracemalloc.py:22  (+500 次)

解读这份 diff:size_diff 是「第二次比第一次多分配了多少字节」,count_diff 是「多了多少次分配」。两轮输入完全一样,却每一轮都稳定增长,就说明这里有泄漏——不是「占用大」,而是「不随轮次收敛」。

三个增长点对应三处代码(行号对应上面的脚本):

  • :11(bytearray(1024),+527.9 KiB):负载本体。每个 Event 带 1 KiB,两轮共 1000 个,涨得最多——但它是结果不是原因,删掉泄漏源它自然消失。
  • :18(_LEAKY_HOLDERS.append([ev] * 10),+71.0 KiB):被全局持有的列表,泄漏源 2。
  • :15(ev = Event(tag),+43.0 KiB):Event 实例本身,被 _CACHE 吊住,泄漏源 1。
  • :22(handle(f"evt-{i}"),+23.3 KiB):每轮新生成的 f-string,属噪声级别。

拿到文件名 + 行号,定位就结束了。tracemalloc 还有一个好处:它统计的是 Python 分配器层面的字节数,比 RSS 精确得多(RSS 受分配器保留 arena 影响,见 4.1.8)。用完记得 tracemalloc.stop()。

4.3.3 objgraph:找出增长对象与引用链

tracemalloc 告诉你是哪一行在分配;但很多时候你想知道的是哪一类对象在涨、谁还引用着它。这时用 objgraph 3.6.2。先看增长:

import objgraph, gc

class Session:
    def __init__(self, sid):
        self.sid = sid
        self.requests = []

_REGISTRY = {}          # 泄漏源:全局注册表,只增不减

def open_session(sid):
    s = Session(sid)
    _REGISTRY[sid] = s
    return s

def build():
    for i in range(300):
        s = open_session(i)
        s.requests.append("GET /")

gc.collect()
objgraph.show_growth(limit=5)   # 基线
build()
objgraph.show_growth(limit=5)   # 300 个 Session 之后

实测输出:

== 300 个 Session 之后的增长 ==
list         611      +300
Session      300      +300

show_growth 打印的是「自上次调用以来各类型数量的增量」。注意第一行 list +300 与 Session +300 一一对应(每个 Session 带一个 requests 列表),这就是泄漏的指纹。第一次调用 show_growth 时通常会刷出一堆 function、dict、wrapper_descriptor 的增长——那是 import objgraph 本身产生的,属于噪声,要忽略。

objgraph.count 给出精确的类型计数,可以和业务预期直接对账:

print("Session 实例数:", objgraph.count("Session"))   # 300
print("_REGISTRY 条目:", len(_REGISTRY))               # 300

数量相等就说明这些对象全部被 _REGISTRY 吊着。最后一步是画引用链——找出从「根」到泄漏对象的完整路径:

def trace_chain():
    t = objgraph.by_type("Session")[0]
    return objgraph.find_backref_chain(t, objgraph.is_proper_module)

for o in trace_chain():
    print("  ->", type(o).__name__, "|", repr(o)[:60])

实测输出:

  -> module | <module '__main__' from '...'>
  -> dict | {'__name__': '__main__', '__doc__': None, ...
  -> dict | {0: <__main__.Session object at 0x...>, 1: <__main__.Sessi...
  -> Session | <__main__.Session object at 0x...>

引用链读作:模块 → 模块全局字典 → _REGISTRY 字典 → Session 实例。从模块一路追到对象,中间那个 300 项的 dict 就是罪魁祸首。find_backref_chain 从目标反向沿着引用者(referrer)走,直到碰到第一个「像模块」的对象为止。

这里有一个必须知道的坑:如果 trace_chain 里不是用局部变量、而是用全局变量持有目标对象,最短路径会被这个全局名抄近路,链会直接变成 module → globals → Session,看不到中间的 _REGISTRY。所以追踪函数要把目标声明成局部变量(如上面的 t = ...),让最短路径只能经过真正的持有者。

关于 graphviz:本机不可用

objgraph 画图(show_backrefs(..., filename="x.png")、show_graph)依赖 graphviz 的 dot 命令。本机实测 dot 不存在(dot not found),因此所有生成图片的 API 未实测。本节使用的 show_growth / count / by_type / find_backref_chain 都是纯文本 API,不需要 graphviz,可直接用。生产环境要出图,需自行安装 graphviz 后再调用那些函数。

4.3.4 RSS 视角:psutil 与分配器的保留

tracemalloc 精确,但它只看 Python 分配器;要确认「进程真实占用」还得看 RSS。用 psutil 7.2.2 量一下 4.1 节那个实验:

import gc, os, psutil
proc = psutil.Process(os.getpid())

class Node:
    def __init__(self):
        self.me = self        # 自环

gc.disable()
base = proc.memory_info().rss / 1024 / 1024
for _ in range(300_000):
    Node()
after = proc.memory_info().rss / 1024 / 1024
print(f"RSS {base:.1f} MB -> {after:.1f} MB")
print("collect 回收:", gc.collect())
print(f"回收后 RSS {proc.memory_info().rss/1024/1024:.1f} MB")

实测输出:

RSS 17.6 MB -> 40.4 MB
collect 回收: 300000
回收后 RSS 19.4 MB

回收了全部 30 万个对象,RSS 却只从 40.4 回到 19.4 MB,没回到起点 17.6 MB——差值就是分配器留着备用的 arena。这解释了一个反复出现的困惑:「我明明清空了,为什么 RSS 不降?」答案通常是分配器没把内存还给操作系统,而不是还有泄漏。判断泄漏要看多轮操作后的趋势,而不是单次回落。

4.3.5 定位流程小结

把两个工具串成一条流程:

步骤工具回答的问题
1. 确认在涨psutil 看 RSS,多轮对比是不是真的泄漏,还只是分配器保留
2. 定位到行tracemalloc.compare_to(..., "lineno")增长分配发生在哪个文件哪一行
3. 定位到类型objgraph.show_growth / count哪一类对象在涨,涨了多少
4. 定位到持有者objgraph.find_backref_chain谁还引用着它(引用链)
5. 修复清缓存 / 断环 / 注销监听器断掉那条引用链

一个经验判据:如果 tracemalloc 的 diff 在多轮等量输入下每轮都稳定增长,几乎可以确定是泄漏;如果只是第一轮涨、之后持平,那多半是缓存或惰性初始化,不是泄漏。

小结

  • Python 的「泄漏」= 对象仍然可达。定位泄漏就是找「谁还引用着它」,不是找「谁忘了释放」。
  • tracemalloc.start(1) + take_snapshot() + compare_to(s1, "lineno") 能把增长定位到具体行;size_diff 是字节增量,count_diff 是分配次数增量。
  • 实测:两轮各 500 个事件后,泄漏行 [ev]*10 增长 +71.0 KiB(+1001 次),负载行 +527.9 KiB——每轮等量输入却稳定增长才是泄漏的判据。
  • objgraph.show_growth 看类型增量,count 对账数量,find_backref_chain 画出 module → globals → _REGISTRY → Session 的完整持有链。
  • 用 find_backref_chain 时目标必须是局部变量,否则全局名会抄近路,看不到真正的持有者。
  • 本机 无 graphviz(dot 不存在),objgraph 的图片输出 API 未实测;文本 API 不受影响。
  • RSS 不降不等于泄漏:CPython 分配器会保留 arena,实测回收全部对象后 RSS 从 40.4 只回到 19.4 MB。
  • 造负载时避免常量折叠(b"x" * 1024 会被折叠成共享常量),否则测量失真。

内存与 GC 这条线到这里就收束了。下一章我们把视角切到并发——先看那把从 1992 年就在的锁:GIL 到底锁住了什么、又放行了什么。

阅读导航:上一节:4.2 对象布局、__slots__ 与内存占用测量 · 下一节:5.1 GIL 的实现与影响边界 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时