本章导读

第 5 章把全库内存收口到五个入口,本章兑现收口的另外两笔红利:故障注入——xrtMemDebugFailAfter 让第 N+1 次分配精确失败,用来验证失败路径的回滚是否干净;统计——xrtMemStatsGet 用一组计数器回答"分配了多少、池化命中率多高、峰值多大"。两个模块分工明确:调试模块记录每笔明细(开销大,定位问题用),统计模块只计数(几乎零开销,生产可常开)。全书后面的每个 _oom 测试变体都建立在本章的注入入口上——这是 XRT 工程品质的方法论基石之一。

6.1引入

问一个残酷的问题:你的程序在第 1000 次分配失败时会发生什么?大多数代码的诚实答案是"不知道"——失败路径从没被系统性执行过,因为正常运行时第 1000 次分配总是成功。等到生产环境内存耗尽时,那些从未执行过的回滚代码第一次运行,而它们恰恰是最容易写错的部分:漏释放、乱序释放、带着半初始化的结构继续跑。

测试界的老办法是把 malloc 换成可失败的宏,但那只覆盖自己的代码。XRT 因为全库收口(第 5 章),一行 xrtMemDebugFailAfter(N) 就能让库与业务一起在第 N+1 次分配处失败——解析到一半的 JSON、握到一半的 TLS,它们的失败回滚第一次可以被逐个验证。这就是仓库里数百个 OOM 测试变体的共同底座。

与故障注入相对的是生产侧观测:常驻服务内存缓慢上涨,你需要先回答"涨的是什么"再决定要不要抓个案。XRT 的一对模块正好对应这两个方向——本章的最后一节会把它们组成一条完整的排查漏斗。

6.2概念

两个模块,两种观测

模块记录什么开销何时开
memory_stats计数器:调用数、字节数、峰值、池化命中几乎为零生产常开或按窗口开
memory_debug每笔分配/释放明细与调用位置明显定位泄漏、故障注入时开

分工的原则:用数字看趋势,用明细找个案。怀疑服务内存缓慢增长,先开统计看活跃字节曲线;确认有泄漏,再开调试堆用事件流锁定出生地。两个模块的开关彼此独立,可以只开其一——日常推荐只开统计,把调试堆留给出问题的那一天。

故障注入:FailAfter 的精确语义

未注入FailAfter(N)(前 N 次成功) →已注入
已注入第 N+1 次分配返回 NULL →已触发
已触发FailClear(连同触发标志一并复位) →未注入
已注入FailClear(未触发也可清除) →未注入

两条语义细节决定测试写法。其一,FailAfter(1) 是"第 1 次成功、第 2 次失败"——参数 N 表示前 N 次成功,失败发生在第 N+1 次,不是第 N 次。其二,FailClear 把注入与"已触发"标志一并复位,xrtMemDebugFailTriggered 查询是否已触发——测试断言"失败确实发生在预期的那一次"就靠它。

事件流:按时间序回放

xrtMemDebugVisit 以回调形式按时间序访问调试事件(每笔分配、释放、注入),xrtMemDebugEventName 把事件枚举转成稳定名称。配合第 5 章的 At 族位置信息,一次泄漏定位的信息链完整成立:事件流告诉你"哪笔分配没被释放",位置信息告诉你"它出生在哪一行"。

同族还有三个更趁手的工具:xrtMemDebugVisitLive 只访问当前仍存活的分配(配对排查直接跳过已释放的噪音);xrtMemDebugSnapshot 一次取回"当前存活笔数与字节"的快照,适合在业务节点打点对比("处理完这批请求后存活字节应该回到基线");xrtMemDebugReport 把明细格式化输出到文件或回调,适合生成可归档的泄漏报告。选型口诀:对比用快照、找漏用 Live、留档用 Report。

统计口径:读数的门道

xmemstats 的计数器里最有诊断价值的是三个数的关系:业务侧分配次数、池化命中数、直通后端数与实际系统调用数。小块命中尺寸类缓存时不产生系统调用——"业务 100 次分配、系统只调 3 次"就是池化在起作用。统计窗口用 xrtMemStatsReset 划定起点,测量一段负载后 xrtMemStatsGet 一次取回。峰值(peak)与当前值配合还能回答容量规划问题:进程常驻内存的上界由谁贡献、是否与负载成正比——这类问题在卷十二性能分析一章会系统展开。

6.3示例

完整程序:FailAfter 全链路

来自仓库范例 examples/memory/fail_inject/main.c——注入、触发、查询、清除、复位的完整闭环:

examples/memory/fail_inject/main.c源码 ↗
























 */

#include <stdio.h>
#include <xrt.h>

/* 事件访问器:只打印前几个 alloc 事件的名字。 */
static bool printEvent(const xmemdebugevent* pEvent, ptr pUserData)
{
	size_t* pCount = (size_t*)pUserData;

	if ( *pCount < 1u && pEvent->Kind == XMEMDEBUG_ALLOC ) {
		printf("event[%zu]: %s\n", *pCount,
			xrtMemDebugEventName(pEvent->Kind));
	}
	(*pCount)++;
	return true;
}

int main(void)
{
	ptr pFirst;
	ptr pSecond;
	ptr pThird;
	size_t iEvents = 0;

	(void)xrtMemDebugReset();
	(void)xrtMemDebugEnable(true);
	printf("enabled=%d\n", xrtMemDebugEnabled() ? 1 : 0);

	/* 注入:第 2 次分配失败。 */
	(void)xrtMemDebugFailAfter(1u);
	pFirst = xrtMalloc(16);
	pSecond = xrtMalloc(16);
	printf("fail-after-2: 1st=%s 2nd=%s triggered=%d\n",
		pFirst != NULL ? "ok" : "NULL",
		pSecond != NULL ? "ok" : "NULL",
		xrtMemDebugFailTriggered() ? 1 : 0);
	xrtFree(pFirst);

	/* 事件流:最早的 alloc 事件可按名访问。 */
	(void)xrtMemDebugVisit(printEvent, &iEvents);

	/* 清除未触发注入后再分配恢复成功;已触发的标志保持。 */
	xrtMemDebugFailClear();
	pThird = xrtMalloc(16);
	printf("cleared: 3rd=%s triggered-still=%d\n",
		pThird != NULL ? "ok" : "NULL",
		xrtMemDebugFailTriggered() ? 1 : 0);
	xrtFree(pThird);

	(void)xrtMemDebugEnable(false);
	printf("reset=%d\n", xrtMemDebugReset() ? 1 : 0);
	return 0;
}
$ gcc -O1 -DXRT_MODULE_ALL -I single -include xrt.h impl.c examples/memory/fail_inject/main.c -lws2_32 -liphlpapienabled=1fail-after-2: 1st=ok 2nd=NULL triggered=1event[0]: alloccleared: 3rd=ok triggered-still=0reset=1

刚才发生了什么。 ① xrtMemDebugEnable 开启调试堆——注入与事件流都依赖它。② FailAfter(2) 后:第 1 次分配成功,第 2 次返回 NULL,FailTriggered 确认触发——三段断言把"失败发生在预期的那一次"钉死。③ xrtMemDebugVisit 回放事件流,打印第一个分配事件的稳定名称 alloc。④ FailClear 之后第 3 次分配恢复正常,且触发标志一并清零。⑤ 关闭调试堆复位。这个 50 行的程序就是所有 OOM 测试的骨架:把中间那段替换成你的业务调用,就能验证它的失败回滚。

把骨架升级成完整测试只需要两步。第一步,把"单次注入"变成"逐次扫描":N 从 0 递增到分配总数,每个 N 完整跑一遍业务函数——任何一个 N 下出现崩溃、断言失败或活跃内存未归零,就抓到了一个失败路径缺陷。第二步,加"恢复断言":扫描结束后关闭注入,业务应完全恢复正常行为。仓库里的 _oom 测试变体(JSON、TLS、网络各模块都有)全部是这个形状,你在卷十二读测试体系时会见到成建制的实例。

完整程序:统计读数

来自 examples/memory/stats/main.c——用统计窗口测量一段分配负载的池化效果:

examples/memory/stats/main.c源码 ↗
























 */

#include <stdio.h>

#include <xrt.h>



int main(void)
{
	xmemstats tStats;
	ptr pSmall;
	ptr pLarge;

	/* 开关可查询:先确认当前状态,再打开并清零。 */
	printf("before=%d\n", xrtMemStatsEnabled() ? 1 : 0);
	xrtMemStatsEnable(true);
	printf("after=%d\n", xrtMemStatsEnabled() ? 1 : 0);
	xrtMemStatsReset();

	/* 一次小块(走池)+ 一次大块(直通)。 */
	pSmall = xrtMalloc(48);
	pLarge = xrtMalloc(4096);
	if ( (pSmall == NULL) || (pLarge == NULL) ) {
		xrtFree(pSmall);
		xrtFree(pLarge);
		return 1;
	}
	xrtFree(pSmall);
	xrtFree(pLarge);

	/* 读取窗口内计数(字段语义见文件头读数解释)。 */
	xrtMemStatsGet(&tStats);
	printf("malloc_calls=%llu pooled=%llu direct=%llu backing=%llu\n",
		(unsigned long long)tStats.MallocCalls,
		(unsigned long long)tStats.PooledAllocCalls,
		(unsigned long long)tStats.DirectAllocCalls,
		(unsigned long long)tStats.BackingAllocCalls);

	/* 用完关闭,回到零开销模式。 */
	xrtMemStatsEnable(false);
	return 0;
}
$ gcc -O1 -DXRT_MODULE_ALL -I single impl.c examples/memory/stats/main.c -lws2_32 -liphlpapibefore=0 after=1malloc_calls=2 pooled=1 direct=1 backing=3

刚才发生了什么。 ① xrtMemStatsEnable 开采集,before=0 after=1 确认开关生效。② 业务做两次 xrtMalloc:48 字节的小块命中尺寸类缓存(pooled=1,零系统调用),4096 字节的大块直通后端(direct=1)。③ backing=3 是实际打到系统分配器的次数——直通 1 次加池内部为尺寸类预热的分配;"业务 2 次、系统 3 次"说明池刚预热,稳态下这个比值会持续缩小。读统计先看这三个数的关系,再看字节量与峰值。

把这套读数用到真实服务上的方式是窗口测量:在两个业务节点各打一个快照——比如"处理一批请求前"与"全部处理并回收后"——对比存活字节是否回到基线。回去了,这批请求无泄漏;没回去,差值就是嫌疑量,再切调试堆用 Live 访问把个案拎出来。统计负责"有没有问题、量多大",调试负责"具体是哪一笔"——两层漏斗,避免一上来就开高开销的明细模式。

6.4契约

  • 开销纪律:调试堆仅定位问题时开启;性能敏感路径与生产常态用统计模块;开调试堆期间的任何性能数字不作数。
  • 注入语义:FailAfter(N) = 前 N 次成功、第 N+1 次失败;FailClear 连同触发标志复位;FailTriggered 是断言依据。
  • 断言三件套:注入前期望未触发、失败确实发生、触发标志翻转为真——OOM 测试的标准骨架;扫描全部 N 后业务恢复正常。
  • 统计窗口:Reset 定起点、跑负载、Get 取读数;窗口期间不要再动开关;对比"前后快照"判定批次是否归还内存。
  • 事件只读:Visit / VisitLive 回调里不得做分配(会递归进调试堆),只做记录与匹配。

常见错误

坑 1:FailAfter 的差一误解

症状:OOM 测试"偶尔"测不到目标路径——注入了却没拦到想拦的那次分配,或拦早了一次。

原因:把 FailAfter(N) 理解成"第 N 次失败"。实际语义是前 N 次成功、第 N+1 次失败——想拦第一次分配要用 N=0。

错误写法
xrtMemDebugFailAfter(1);   /* 想让第一次分配失败 */
p = xrtMalloc(64);         /* 实际:这次成功了 */
p = xrtMalloc(64);         /* 失败发生在这里——晚了一拍 */
正确写法
xrtMemDebugFailAfter(0);   /* 前 0 次成功 */
p = xrtMalloc(64);         /* 第一次分配即失败:正是想要的 */
坑 2:带着调试堆跑性能基准

症状:基准数字大幅劣化,"优化"后重新测量仍然差;对比历史数据找不到变化点。

原因:调试堆记录每笔分配明细与位置,开销是常态分配的数倍——它开着的时候测的 不是程序的真实性能。

错误写法
xrtMemDebugEnable(true);
run_benchmark();          /* 数字里混入了调试堆自身的开销 */
xrtMemDebugEnable(false);
正确写法
xrtMemStatsEnable();      /* 要观测就用统计:几乎零开销 */
xrtMemStatsReset();
run_benchmark();
xrtMemStatsGet(&Stats);   /* 计数器照拿,性能数字可信 */

编程练习

基础:注入与恢复

参照 fail_inject 范例:FailAfter(1) 后连续两次分配,断言第一次成功、第二次失败、触发标志为真;FailClear 后第三次成功且触发标志归零。把四条断言全部打印成 ok 才算完成。

进阶:给第 5 章的代码做 OOM 体检

把第 5 章 MemDup 范例的主流程套进注入循环:从 N=0 逐次递增注入点,每次运行后断言程序退出码为预期失败码且活跃字节归零(用你的计数分配器或统计验证)。提示:这就是仓库 _oom 测试变体的写法;如果某次注入下程序没有按预期失败退出(比如继续跑了下去),那本身就是一个失败路径缺陷——记下来,它就是本练习最有价值的产出。

挑战:泄漏定位器

写一个 leak_check() 辅助函数:开启调试堆 → 跑一段被测函数(故意藏一处漏释放)→ Visit 事件流统计未配对的分配 → 打印每笔泄漏的大小与位置(__FILE__:__LINE__)。验收标准:对"漏一次 Free"的测试代码精确报出一行泄漏位置;对正常代码报告零泄漏。

小结

知识点速查
分工stats 计数常开看趋势;debug 明细按需找个案——两层漏斗
注入FailAfter(N):前 N 成功、第 N+1 失败;FailClear 全复位
断言注入前未触发 → 失败发生 → FailTriggered 翻真
事件流Visit 按时间序回放;EventName 稳定名称;回调内禁止分配
快照/Live/报表对比用快照、找漏用 Live、留档用 Report
统计窗口Enable → Reset → 负载 → Get;看 pooled/direct/backing 三数关系
OOM 测试骨架注入 + 业务调用 + 回滚断言 + 活跃归零验证;逐次扫描全部 N

📖 本章涉及的 API 参考手册:memory_debug(19 个条目)memory_stats(5 个条目)

点击查看完整签名、参数约束、返回值与错误说明