# Python tracemalloc:一款被低估的内存追踪利器
它到底是什么
开始讲tracemalloc之前,得先聊聊Python的内存管理。很多人可能觉得Python有垃圾回收机制,内存问题就不用太操心。但这个想法其实挺危险的——垃圾回收只管“回收”那些不再使用的对象,但如果你不小心创建了大量不会销毁的对象,或者无意中持有了一些永远不会释放的引用,内存还是会悄悄涨上去。
tracemalloc是Python 3.4开始内置的一个模块,专门用来追踪内存分配情况。它不是那种花哨的调试工具,默默无闻地藏在标准库的角落里,但我敢说,熟练用好它的人并不多。
它的底层原理挺有意思——通过钩子机制拦截Python的内存分配调用。每当代码里创建新对象时(比如list、dict,甚至一个简单的整数),tracemalloc都能记录下来。这有点像给内存装了个行车记录仪,每一笔内存分配的“案发时间”和“案发地点”都被保存下来。
它能做什么
有个实际例子能说明问题。之前帮朋友排查一个FastAPI服务的内存泄漏,服务跑个两三天内存就涨到3G多。常规手段基本都试过了——gc.get_objects()看对象数量、objgraph画引用关系图、甚至用sys.getsizeof手工估算。但这些方法要么太粗糙只能看个大概,要么太复杂查起来很累。
tracemalloc这时候就显身手了。它能直接告诉你:
- 哪行代码分配了最多内存
- 哪些对象占据了大部分内存空间
- 对比两个时间点的内存快照,找出新增的内存分配
更有用的是,它能对比“快照”。就像给内存拍两张照片,一张是服务刚启动时,一张是跑了24小时后,然后直接比对差异,找出新增的内存都在哪儿分配的。
怎么使用
使用起来相当直接,不需要装额外的东西。直接导入就行:
importtracemalloc# 启动追踪tracemalloc.start()# 跑你的业务代码...do_something()# 拍个快照snapshot=tracemalloc.take_snapshot()但我发现很多人不知道的一个技巧——start()其实可以设参数,比如tracemalloc.start(25)能设置追踪的栈帧深度。默认是1,意味着只能看到当前函数所在的文件行。设到25才能看到完整的调用栈,这对分析复杂的库调用特别有用。
对比快照的方式是我最常用的:
# 先在某个时间点拍个快照snapshot1=tracemalloc.take_snapshot()# 跑一段时间...process_data()# 再拍一个snapshot2=tracemalloc.take_snapshot()# 计算差异stats=snapshot2.compare_to(snapshot1,'lineno')compare_to方法有个key_type参数可以选,像'lineno'按文件行号分组,'traceback'按完整调用栈分组,'filename'按文件名分组。'traceback'最详细但结果也最难看懂。
打印结果时,Statistics对象自带top方法和sort方法:
forstatinstats[:10]:print(stat)输出的格式大概是这样的:
/Users/me/project/app.py:42: size=12.5 MiB (+5.2 MiB), count=230 (+87), average=55.7 KiB意思是app.py第42行,当前占12.5MB内存,比上一次快照多了5.2MB,总共230个对象,比之前多了87个。
最佳实践
真正要高效用tracemalloc,有几个经验值得分享。
运行在独立进程中。这个坑踩过才知道——tracemalloc本身会引入额外的内存占用和性能损耗。在生产环境直接开着追踪,相当于给服务多套了一层负担。更好的做法是在独立脚本或者单独的进程里做快照。比如启动主服务时加个环境变量控制,默认不开启追踪。需要排查时才单独开一个进程做快照。
设置合理的帧深度。默认的帧深度1基本等于没用,只能看到哪个函数在分配内存。我一般设到10到15,既能看到完整的调用链,又不会让追踪本身的性能开销太大。设到30以上真的会明显卡顿。
结合上下文使用。tracemalloc有个特别实用的功能——get_traced_memory(),可以看看当前分配了多少内存。但这个值在程序启动时和运行一段时间后会差很多。我常用的是搭配start()和stop()来控制追踪的范围:
tracemalloc.start(15)# 只追踪这一段代码的分配result=heavy_function()snapshot=tracemalloc.take_snapshot()tracemalloc.stop()这样只追踪关键函数,避免追踪整个运行周期的噪声数据。
利用filter_traces。有时候并不关心标准库或第三方包的内部实现,只想知道自己代码的问题。这时可以用:
snapshot=snapshot.filter_traces([tracemalloc.Filter(True,"<match>"),tracemalloc.Filter(False,"<ignore>"),])第一个参数是inclusive,True表示包含匹配的,False表示排除匹配的。比如排除所有/usr/lib/python3下的文件:
snapshot=snapshot.filter_traces([tracemalloc.Filter(False,"/usr/lib/python3*")])和同类技术对比
讲tracemalloc就绕不开和memory_profiler以及objgraph的比较。
memory_profiler的优势是能把内存使用情况画成图,而且是装饰器风格,用起来很优雅。但它有个致命缺点——对多线程支持不好,而且在追踪大型任务时,每行代码都记录内存变化,性能开销很大。拿跑一个训练模型来说,加了装饰器之后运行时间可能翻倍。
objgraph擅长画对象的引用关系图,对排查循环引用或者不释放的对象特别有用。但它的信息维度比较单一,只看引用关系,不注重分配位置。换句话说,你知道有几个对象在互相引用,却不清楚它们是从哪里冒出来的。
tracemalloc正好互补——它不关心对象之间的引用关系,只关心这些对象“出生”在哪里。所以我会这么搭配使用:
- 先拿tracemalloc找出内存增长最明显的代码位置
- 再用
objgraph深入了解那个位置创建的对象是怎么被引用的
另外,tracemalloc还有个不容易注意到的优点——它是Python内置的。不需要pip安装任何东西,不需要担心版本兼容问题。在生产环境(只要不是极端低版本),直接import tracemalloc就能用。
要说缺点的话,就是tracemalloc的输出不够直观。memory_profiler可以一行一行地标注内存占用,而tracemalloc给出的只是统计信息。好在Python 3.6之后,tracemalloc增加了get_object_traceback()方法,能直接查特定对象在哪个位置分配的:
importtracemalloc tracemalloc.start()obj=allocate_something()tb=tracemalloc.get_object_traceback(obj)print(tb)这个功能在调试时真的很好用——如果发现某个变量一直占用大量内存,直接查它的“出生档案”,就知道它在哪行代码创建的。
最后提一下,tracemalloc不是万能的。它没法追踪C扩展模块通过malloc直接分配的那部分内存(比如numpy数组的底层内存),那些内存只在numpy的Python对象层面被记个总账。真要查C扩展模块的内存占用,可能还得配合valgrind这样的工具。但在纯Python代码的范畴内,tracemalloc算是定位内存问题的第一块敲门砖,也是最实用的一块。