☰
KBEngine 内嵌 Python 3.7 的 tracemalloc 内存追踪:从快照统计到内存泄漏定位
2026/10/19 1:18:42 网站建设 项目流程
  • 游戏开发
  • 后端

【免费下载链接】kbengine

A MMOG engine of server.

项目地址:https://gitcode.com/gh_mirrors/kb/kbengine
点击查看免费下载

导读

tracemalloc是 Python 标准库提供的调试工具,用于追踪由 Python 分配的内存块,能够回答"某个对象是在哪一行代码分配的""当前哪些文件/行号占用了最多内存""两次快照之间内存增长来自哪里"这三个内存分析中的核心问题。本仓库(KBEngine,一款 C++ 编写、以 Python 脚本驱动游戏逻辑的开源 MMOG 服务端引擎)内嵌了完整的 Python 3.7.3 源码,本文以其中 tracemalloc 官方文档 为主线,结合 tracemalloc.py 与 C 实现 _tracemalloc.c 深入讲解其工作原理、完整 API 与实战用法,并给出在 KBEngine 服务端脚本中落地内存排查的具体建议。读完本文,你将能够独立完成"找出内存大户 → 对比两次快照 → 定位泄漏调用链"的完整内存分析闭环。


一、tracemalloc 是什么:它能追踪什么

按官方文档的定义,tracemalloc是用于追踪 Python 分配的内存块的调试工具(Python 3.4 引入,本仓库内嵌版本为 Python 3.7.3,见 patchlevel.h)。它提供三类信息:

  • 分配 Traceback:每个对象是在哪个调用栈中分配的;
  • 按文件名与行号的统计:每个文件/行号分配的内存块总大小、数量与平均大小;
  • 两次快照之间的差异:通过compare_to对比前后快照,用于检测内存泄漏。

底层原理:钩子装在分配器上

从源码看,tracemalloc由两层组成:

  • C 层 _tracemalloc.c 负责真正采集数据。它维护PyMemAllocatorEx类型的mem、raw、obj三套分配器结构(第 26-30 行),并在开启时把钩子安装到 Python 的内存分配器上;第 13-14 行的TRACE_RAW_MALLOC宏还让它可以追踪PyMem_RawMalloc()的原始分配。所有的 trace 表由 GIL 或专用的tables_lock保护(第 54-65 行),后者是为了避免PyMem_RawFree()在无 GIL 场景下的死锁问题。
  • Python 层 tracemalloc.py 提供Statistic、Snapshot、Filter、Traceback等面向用户的 API,第 9-10 行通过from _tracemalloc import *直接复用 C 层的start/stop/take_snapshot/get_traceback_limit等原语,纯 Python 侧负责数据的分组、排序、格式化与过滤。

tracemalloc使用地址空间domain概念区分不同的分配来源:默认用domain 0追踪 Python 自身的分配,C 扩展可以用其他 domain 追踪各自资源(见 _tracemalloc.c 的DEFAULT_DOMAIN 0定义)。

关键限制

  • 快照不包含tracemalloc 启动之前分配的内存块;
  • 每个 trace 默认只保留最近1 帧栈帧,如需更深的调用链,必须显式增大帧数;
  • 追踪本身有内存与 CPU 开销,可用get_tracemalloc_memory()测量追踪器自身的内存占用。

二、如何启动追踪

追踪应该尽早启动,这样才不会漏掉进程启动早期(如模块导入)产生的分配。官方文档给出三种方式:

方式写法说明
环境变量PYTHONTRACEMALLOC=1进程启动即追踪,默认 1 帧
环境变量(带帧数)PYTHONTRACEMALLOC=25启动时即存储 25 帧调用栈
命令行选项python -X tracemalloc等价于PYTHONTRACEMALLOC=1
命令行选项(带帧数)python -X tracemalloc=25等价于PYTHONTRACEMALLOC=25
运行时启动tracemalloc.start(nframe)在代码中任意时刻调用,nframe默认 1,必须 ≥ 1

start(nframe)的语义是"在 Python 内存分配器上安装钩子",而 trace 的 traceback 会被限制为nframe帧。需要特别注意的是:

  • 存储超过 1 帧,只在按'traceback'分组统计或计算累积统计(cumulative=True)时才有意义;
  • 帧数越多,tracemalloc的内存与 CPU 开销越大,建议用get_tracemalloc_memory()实测开销后再定帧数;
  • 相关 API:stop()(卸载钩子并清除已采集的 trace)、is_tracing()(查询是否在追踪)、get_traceback_limit()(读取当前帧上限,未追踪时调用会抛异常)。

在 KBEngine 中的注意事项

KBEngine 将 Python 以嵌入式方式集成进 C++ 服务端进程,初始化代码位于 script.cpp,关键调用包括Py_NoSiteFlag = 1、Py_IgnoreEnvironmentFlag = 1(第 155-156 行)、Py_SetPath()(第 158 行)与Py_Initialize()(第 161 行)。从源码结构可以推断:由于Py_IgnoreEnvironmentFlag = 1,KBEngine 嵌入的 Python 解释器会忽略PYTHONTRACEMALLOC之类的PYTHON*环境变量。因此在 KBEngine 的服务端脚本场景中,更可靠的做法是:

  • 在游戏逻辑初始化脚本的最早阶段调用tracemalloc.start(nframe),而不是依赖环境变量;
  • 或者通过服务端支持的脚本命令通道(如 telnet/console 的脚本执行入口,对应 C++ 层onExecScriptCommand等消息处理器)运行时动态开关。

此外,KBEngine 的 C++ 层自身也维护了基于ObjectPool<MemoryStream>的内存池,并提供watchMemoryStreamPool_*系列 watcher(见 watch_pools.cpp)监控池对象数量、峰值分配次数与占用字节数。因此 C++ 层的内存排查可走引擎自带的 watcher 体系,而 Python 脚本层的内存排查则交给tracemalloc,两者互补。


三、实战示例一:显示分配内存最多的 Top 10

官方文档给出的第一个示例:启动追踪、运行应用、取快照并按'lineno'分组统计,打印前 10 名:

import tracemalloc tracemalloc.start() # ... run your application ... snapshot = tracemalloc.take_snapshot() top_stats = snapshot.statistics('lineno') print("[ Top 10 ]") for stat in top_stats[:10]: print(stat)

文档附带的输出样例(来自 Python 测试套件)长这样:

[ Top 10 ] <frozen importlib._bootstrap>:716: size=4855 KiB, count=39328, average=126 B <frozen importlib._bootstrap>:284: size=521 KiB, count=3199, average=167 B /usr/lib/python3.4/collections/__init__.py:368: size=244 KiB, count=2315, average=108 B /usr/lib/python3.4/unittest/case.py:381: size=185 KiB, count=779, average=243 B /usr/lib/python3.4/unittest/case.py:402: size=154 KiB, count=378, average=416 B /usr/lib/python3.4/abc.py:133: size=88.7 KiB, count=347, average=262 B <frozen importlib._bootstrap>:1446: size=70.4 KiB, count=911, average=79 B <frozen importlib._bootstrap>:1454: size=52.0 KiB, count=25, average=2131 B <string>:5: size=49.7 KiB, count=148, average=344 B /usr/lib/python3.4/sysconfig.py:411: size=48.0 KiB, count=1, average=48.0 KiB

从输出可以读出:Python 从模块中加载了4855 KiB数据(字节码与常量),其中collections模块分配了244 KiB用于构建namedtuple类型。这个示例同时说明Statistic的字符串表示形式:文件:行号: size=总量, count=数量, average=平均大小,其格式化逻辑可以在 tracemalloc.py 的Statistic.__str__中看到,大小单位自动在 B/KiB/MiB/GiB/TiB 之间切换。


四、实战示例二:计算两次快照的差异,定位内存泄漏

内存泄漏排查的核心手段是前后对比:先取一张基线快照,运行疑似泄漏的功能,再取一张新快照,用compare_to计算差异:

import tracemalloc tracemalloc.start() # ... start your application ... snapshot1 = tracemalloc.take_snapshot() # ... call the function leaking memory ... snapshot2 = tracemalloc.take_snapshot() top_stats = snapshot2.compare_to(snapshot1, 'lineno') print("[ Top 10 differences ]") for stat in top_stats[:10]: print(stat)

官方输出样例(测试套件运行前后的对比):

[ Top 10 differences ] <frozen importlib._bootstrap>:716: size=8173 KiB (+4428 KiB), count=71332 (+39369), average=117 B /usr/lib/python3.4/linecache.py:127: size=940 KiB (+940 KiB), count=8106 (+8106), average=119 B /usr/lib/python3.4/unittest/case.py:571: size=298 KiB (+298 KiB), count=589 (+589), average=519 B <frozen importlib._bootstrap>:284: size=1005 KiB (+166 KiB), count=7423 (+1526), average=139 B /usr/lib/python3.4/mimetypes.py:217: size=112 KiB (+112 KiB), count=1334 (+1334), average=86 B /usr/lib/python3.4/http/server.py:848: size=96.0 KiB (+96.0 KiB), count=1 (+1), average=96.0 KiB /usr/lib/python3.4/inspect.py:1465: size=83.5 KiB (+83.5 KiB), count=109 (+109), average=784 B /usr/lib/python3.4/unittest/mock.py:491: size=77.7 KiB (+77.7 KiB), count=143 (+143), average=557 B /usr/lib/python3.4/urllib/parse.py:476: size=71.8 KiB (+71.8 KiB), count=969 (+969), average=76 B /usr/lib/python3.4/contextlib.py:38: size=67.2 KiB (+67.2 KiB), count=126 (+126), average=546 B

差异项会以(+增量)的形式标注相对旧快照的增长:比如linecache模块为格式化 traceback 缓存了940 KiB的源码文本,全部来自两次快照之间。StatisticDiff的字符串格式(size=新值 (+差值), count=新值 (+差值))实现在 tracemalloc.py。

离线分析:如果系统可用内存紧张,可以把快照落盘再离线分析:

snapshot.dump('snapshot.pkl') # 写入文件(内部使用 pickle 序列化) snapshot2 = tracemalloc.Snapshot.load('snapshot.pkl') # 重新加载

对应实现见 tracemalloc.py:dump以pickle.HIGHEST_PROTOCOL序列化 Snapshot 对象,load反序列化还原。快照文件可以在 KBEngine 服务端与开发机之间传输,实现"线上采集、线下分析"的工作流。


五、实战示例三:获取某个内存块的完整 Traceback

默认 1 帧只能看到"最后一层",要还原完整调用链,需在启动时指定帧数(如 25 帧),再按'traceback'分组,取出最大的内存块并打印其调用栈:

import tracemalloc # Store 25 frames tracemalloc.start(25) # ... run your application ... snapshot = tracemalloc.take_snapshot() top_stats = snapshot.statistics('traceback') # pick the biggest memory block stat = top_stats[0] print("%s memory blocks: %.1f KiB" % (stat.count, stat.size / 1024)) for line in stat.traceback.format(): print(line)

官方输出样例(限制 25 帧):

903 memory blocks: 870.1 KiB File "<frozen importlib._bootstrap>", line 716 ... File "/usr/lib/python3.4/doctest.py", line 101 import pdb ... File "/usr/lib/python3.4/test/regrtest.py", line 1276 test_runner() ...

从调用链可以清晰看到:importlib为了从模块加载数据(字节码与常量)分配了870.1 KiB,最近一次加载发生在doctest模块的import pdb这一行;如果之后又加载了新模块,这条 traceback 可能会变化。这正是"定位分配现场"的典型用法——在 KBEngine 场景中,可以据此找出某个高频游戏对象(Entity、邮件、聊天消息等)是在哪段脚本逻辑中被反复分配、累积的。

Traceback.format(limit=None, most_recent_first=False)的格式化细节(见 tracemalloc.py):

  • 通过linecache.getline()顺带打印每帧对应的源代码行;
  • limit为正数时格式化最近limit帧,为负数时格式化最旧的abs(limit)帧;
  • most_recent_first=True时倒序输出,最近帧在前;
  • 与traceback.format_tb()类似,但输出不带换行分隔。

六、实战示例四:Pretty Top —— 过滤噪声的整洁输出

大型应用中importlib等系统模块会占据统计榜,干扰定位业务代码。官方文档给出了一个"过滤 + 美化"的完整函数:用Filter(False, ...)排除<frozen importlib._bootstrap>与<unknown>两类噪声,同时把绝对路径压缩成模块/文件名,并附带分配行对应的源码:

import linecache import os import tracemalloc def display_top(snapshot, key_type='lineno', limit=10): snapshot = snapshot.filter_traces(( tracemalloc.Filter(False, "<frozen importlib._bootstrap>"), tracemalloc.Filter(False, "<unknown>"), )) top_stats = snapshot.statistics(key_type) print("Top %s lines" % limit) for index, stat in enumerate(top_stats[:limit], 1): frame = stat.traceback[0] # replace "/path/to/module/file.py" with "module/file.py" filename = os.sep.join(frame.filename.split(os.sep)[-2:]) print("#%s: %s:%s: %.1f KiB" % (index, filename, frame.lineno, stat.size / 1024)) line = linecache.getline(frame.filename, frame.lineno).strip() if line: print(' %s' % line) other = top_stats[limit:] if other: size = sum(stat.size for stat in other) print("%s other: %.1f KiB" % (len(other), size / 1024)) total = sum(stat.size for stat in top_stats) print("Total allocated size: %.1f KiB" % (total / 1024)) tracemalloc.start() # ... run your application ... snapshot = tracemalloc.take_snapshot() display_top(snapshot)

官方输出样例:

Top 10 lines #1: Lib/base64.py:414: 419.8 KiB _b85chars2 = [(a + b) for a in _b85chars for b in _b85chars] #2: Lib/base64.py:306: 419.8 KiB _a85chars2 = [(a + b) for a in _a85chars for b in _a85chars] #3: collections/__init__.py:368: 293.6 KiB exec(class_definition, namespace) ... #10: Lib/base64.py:142: 59.8 KiB _b32tab2 = [a + b for a in _b32tab for b in _b32tab] 6220 other: 3602.8 KiB Total allocated size: 5303.1 KiB

从输出可以看出:过滤后业务逻辑(base64 的字符表推导、namedtuple 构造、unittest 执行等)成为主角,并且每个条目都带上了分配语句的源码,一眼就能判断"这个分配是否合理"。此函数稍加改动即可直接移植到 KBEngine 脚本项目中,例如把过滤规则换成tracemalloc.Filter(False, "<frozen importlib._bootstrap>")加tracemalloc.Filter(False, "KBEngine")等自定义噪声。


七、API 全景:函数与类

7.1 模块级函数

函数行为关键语义
tracemalloc.start(nframe=1)在 Python 内存分配器上安装钩子,开始追踪nframe为每个 trace 保留的栈帧数,默认 1,必须 ≥ 1
tracemalloc.stop()卸载钩子,并清除所有已采集的 trace清空前如需保留数据,先take_snapshot()
tracemalloc.take_snapshot()返回一个新的Snapshot实例未追踪时调用会抛RuntimeError(见 tracemalloc.py);不含启动前分配
tracemalloc.is_tracing()是否正在追踪,返回True/False—
tracemalloc.clear_traces()清除已采集的 trace,但保持追踪状态与stop()的区别:不卸载钩子
tracemalloc.get_traceback_limit()返回 traceback 的最大帧数未追踪时抛异常;上限由start()设定
tracemalloc.get_traced_memory()返回(current, peak)元组当前被追踪内存块的总大小与峰值
tracemalloc.get_tracemalloc_memory()追踪器自身占用的内存字节数用于评估追踪开销,配合start(nframe)调优
tracemalloc.get_object_traceback(obj)返回对象obj分配处的Traceback,未追踪或未捕获则返回None可与gc.get_referrers()、sys.getsizeof()配合使用

其中get_object_traceback的纯 Python 侧实现见 tracemalloc.py,它通过 C 层的_get_object_traceback(obj)拿到帧元组再包装成Traceback。stop()文档明确提示:先take_snapshot()再清空,否则数据会永久丢失。

7.2Snapshot:快照对象

由take_snapshot()创建,属性与方法:

  • traces:所有被追踪内存块的 trace 序列(Trace实例),顺序未定义,统计分析请用statistics();
  • traceback_limit:拍快照时刻的栈帧上限;
  • statistics(key_type, cumulative=False):返回按key_type分组的、从大到小排序的Statistic列表;
  • compare_to(old_snapshot, key_type, cumulative=False):返回按差异排序的StatisticDiff列表;
  • filter_traces(filters):按过滤规则生成新的Snapshot(过滤列表为空时返回 traces 的副本);
  • dump(filename)/Snapshot.load(filename):快照落盘与加载。

key_type取值表(来自官方文档,需完整保留):

key_type描述
'filename'按文件名分组
'lineno'按文件名 + 行号分组
'traceback'按完整 traceback 分组

cumulative累积模式:为True时,把 trace 的 traceback 中所有帧的尺寸与数量都累积到对应分组(而非只算最近一帧),适合回答"某个函数在整条调用链上总共带出了多少内存"。限制:累积模式只能与'filename'、'lineno'搭配,与'traceback'组合会抛ValueError(校验逻辑见 tracemalloc.py)。

排序规则(从源码可确认):

  • statistics()依次按Statistic.size、Statistic.count、Statistic.traceback降序(tracemalloc.py);
  • compare_to()依次按abs(size_diff)、size、abs(count_diff)、count、traceback降序(tracemalloc.py)。

过滤语义(filter_traces,见 tracemalloc.py):

  • 所有包含型(inclusive=True)过滤器一次性应用:某条 trace 若不被任何包含过滤器命中则被丢弃;
  • 只要被任意一个排除型(inclusive=False)过滤器命中,该 trace 即被丢弃;
  • 支持传入DomainFilter与Filter混合列表。

7.3Filter与DomainFilter:过滤规则

Filter(inclusive, filename_pattern, lineno=None, all_frames=False, domain=None):

  • filename_pattern使用fnmatch.fnmatch的 glob 语法(.pyc后缀会自动替换为.py;3.5 起.pyo不再替换,见文档 versionchanged 说明)。实际匹配前路径会经过os.path.normcase()归一化(tracemalloc.py);
  • inclusive=True时只保留"文件名匹配filename_pattern且行号匹配lineno"的块;lineno=None时匹配任意行号;
  • all_frames=True时检查 traceback 的所有帧,False时只检查最近一帧;traceback 上限为 1 时该属性不生效;
  • domain限定地址空间(int或None)。

官方文档给出的经典用法示例:

tracemalloc.Filter(True, subprocess.__file__) # 只保留 subprocess 模块的 trace tracemalloc.Filter(False, tracemalloc.__file__) # 排除 tracemalloc 模块自身的 trace tracemalloc.Filter(False, "<unknown>") # 排除空 traceback

DomainFilter(inclusive, domain)(3.6 新增):只按地址空间过滤。inclusive=True保留分配在domain地址空间的块,False则排除。

7.4 数据类:Statistic、StatisticDiff、Trace、Traceback、Frame

  • Statistic:单次统计条目,属性count(块数)、size(总字节数)、traceback(分配处);
  • StatisticDiff:两次快照的差异条目,属性count/count_diff、size/size_diff、traceback。size_diff表示新旧快照总大小之差(新块被分配时为原值,旧块被释放时新值为0、差值为负);
  • Trace:单块内存的 trace,属性domain、size、traceback;
  • Traceback:Frame的有序序列(3.7 起按"最旧 → 最近"排序,见 tracemalloc.py),至少含 1 帧,取帧失败时使用文件名"<unknown>"与行号0;提供format(limit=None, most_recent_first=False)方法;
  • Frame:filename与lineno两个只读属性。

八、开销与调优:帧数与追踪器自身内存

tracemalloc是有代价的,官方文档与源码均明确指出:

  1. 栈帧数:默认 1 帧开销最小;nframe越大,每个内存块记录的调用栈越长,内存与 CPU 开销越高。帧数只在需要'traceback'分组或累积统计时才值得加大;
  2. 自身占用:用get_tracemalloc_memory()实测追踪器自身消耗的字节数,作为调优依据。C 层实现为压缩内存也做了针对性优化,如 _tracemalloc.c 用__attribute__((packed))/#pragma pack把frame_t在 64 位平台上从 16 字节压缩到 12 字节;
  3. 多线程安全:_tracemalloc.c中 trace 表在TRACE_RAW_MALLOC模式下由独立tables_lock保护,避免与PyThreadState_DeleteCurrent()发生死锁(见 _tracemalloc.c)——在多线程/多进程的 KBEngine 服务端环境中这是重要保障。

在 KBEngine 服务端的推荐工作流

综合以上内容,在 KBEngine 项目中排查 Python 脚本层内存问题可遵循:

  1. 在最早执行的公共脚本中调用tracemalloc.start(25)(KBEngine 嵌入解释器设置了Py_IgnoreEnvironmentFlag = 1,勿依赖PYTHONTRACEMALLOC环境变量,参考 script.cpp 第 155-161 行);
  2. 在疑似泄漏的功能模块前后各拍一张快照,snapshot1.dump()落盘,功能执行后再compare_to看差异榜;
  3. 用Filter(False, ...)过滤importlib、日志系统等固有噪声,聚焦业务脚本;
  4. 对增长明显的分组展开stat.traceback.format(),还原分配调用链;
  5. 结合 C++ 层的内存池 watcher(watch_pools.cpp 中的watchMemoryStreamPool_*)判断问题属于脚本层还是引擎层;
  6. 定位完成后及时tracemalloc.stop(),避免追踪开销影响线上进程。

九、小结

tracemalloc把"内存分配"变成了一组可量化的 trace 数据:通过start安装分配器钩子,通过take_snapshot冻结现场,通过statistics/compare_to完成"当前大户"与"增长差异"两套分析,通过filter_traces剥离系统噪声,通过Traceback.format还原分配现场。本仓库 tracemalloc.rst 提供了完整 API 参考,tracemalloc.py 与 _tracemalloc.c 则展示了从纯 Python API 到底层 C 分配器钩子的完整实现链路。对以 Python 承载游戏逻辑、长生命周期进程居多的 KBEngine 而言,这套工具是脚本层内存泄漏排查与内存分布分析最直接的起点。

  • 游戏开发
  • 后端

【免费下载链接】kbengine

A MMOG engine of server.

项目地址:https://gitcode.com/gh_mirrors/kb/kbengine
点击查看免费下载

相关推荐

上一篇:Blurable性能基准测试:不同模糊半径对iOS应用性能的影响分析
下一篇:Zabbix community-templates:使用 SNMP HP MSA 2040 SAN 模板监控 HP MSA 2040 SAN 存储

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询