- 游戏开发
- 后端
【免费下载链接】kbengine
A MMOG engine of server.
导读
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>") # 排除空 tracebackDomainFilter(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 帧开销最小;
nframe越大,每个内存块记录的调用栈越长,内存与 CPU 开销越高。帧数只在需要'traceback'分组或累积统计时才值得加大; - 自身占用:用
get_tracemalloc_memory()实测追踪器自身消耗的字节数,作为调优依据。C 层实现为压缩内存也做了针对性优化,如 _tracemalloc.c 用__attribute__((packed))/#pragma pack把frame_t在 64 位平台上从 16 字节压缩到 12 字节; - 多线程安全:
_tracemalloc.c中 trace 表在TRACE_RAW_MALLOC模式下由独立tables_lock保护,避免与PyThreadState_DeleteCurrent()发生死锁(见 _tracemalloc.c)——在多线程/多进程的 KBEngine 服务端环境中这是重要保障。
在 KBEngine 服务端的推荐工作流
综合以上内容,在 KBEngine 项目中排查 Python 脚本层内存问题可遵循:
- 在最早执行的公共脚本中调用
tracemalloc.start(25)(KBEngine 嵌入解释器设置了Py_IgnoreEnvironmentFlag = 1,勿依赖PYTHONTRACEMALLOC环境变量,参考 script.cpp 第 155-161 行); - 在疑似泄漏的功能模块前后各拍一张快照,
snapshot1.dump()落盘,功能执行后再compare_to看差异榜; - 用
Filter(False, ...)过滤importlib、日志系统等固有噪声,聚焦业务脚本; - 对增长明显的分组展开
stat.traceback.format(),还原分配调用链; - 结合 C++ 层的内存池 watcher(watch_pools.cpp 中的
watchMemoryStreamPool_*)判断问题属于脚本层还是引擎层; - 定位完成后及时
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.
相关推荐
CPython tracemalloc 模块完全指南:内存分配追踪、快照对比与泄漏定位
CPython tracemalloc 模块完全指南:内存分配追踪、快照对比与泄漏定位 CPython 的 tracemalloc 模块(3.4 起引入)是官方
编程语言语言运行时标准库解释器MimicKit深度解析:轻量级运动模仿框架如何彻底改变控制器训练?
MimicKit深度解析:轻量级运动模仿框架如何彻底改变控制器训练? MimicKit是一款轻量级运动模仿框架,专为训练控制器提供一套全面的运动模仿方法。该框架
Lua内存快照技术:快速定位内存泄漏的终极方案
Lua内存快照技术:快速定位内存泄漏的终极方案 内存泄漏是Lua开发中常见的棘手问题,lua snapshot项目提供了强大的内存快照技术,让您能够快速检测和定
开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考