本节目标:掌握用
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 的实现与影响边界
。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。