5.3 内存标签、预算与泄漏检测
前两节我们解决的是「怎么高效地把内存借出去、还回来」:对象池管住了 JS 对象,块分配器管住了裸字节。但一个真实引擎跑起来后,最先被问到的往往不是「分配快不快」,而是三个更朴素的问题:这些内存到底是谁在占?有没有超预算?有没有谁借了不还?
这一节我们给每一笔分配挂上一个标签(它是渲染的、音频的、还是物理的),用一个 MemoryTracker 按标签汇总当前与峰值占用、对照预算告警;再做一个分配计数快照的泄漏检测器:每帧开始时记下「未归还的笔数」,帧末对比,多出来的就是这一帧忘记释放的分配。最后我们真跑一遍——制造一次泄漏,看它能不能被抓住。
为什么需要标签而不是一个总数
只用 heapUsed 一个总数,你会陷入「内存涨了,但不知道谁涨的」的困境。按子系统打标签,等于给内存账本分了科目:
| 标签 | 典型占用方 | 预算参考(《微光岛》原型) |
|---|---|---|
Rendering | 顶点/索引缓冲、临时贴图 | 最敏感,通常最先设预算 |
Audio | 解码后的 PCM、播放器状态 | 突发大块,峰值重要 |
Physics | 碰撞体、宽相网格 | 相对平稳 |
Gameplay | 实体组件、AI 状态 | 数量随关卡波动 |
Asset | 常驻资源(模型、字体) | 跨帧存活,不参与帧级泄漏检测 |
标签的好处有两层。第一层是归因:预算告警能直接告诉你「Rendering 超了 2 MB」,而不是干瞪眼。第二层是分类检测:Asset 这类跨帧常驻的内存不该被帧级泄漏检测误报,而 Gameplay 里每帧生灭的组件一旦不配对释放就是真泄漏——用标签把它们分开,检测才有意义。
可运行产物:MemoryTracker
下面这个文件实现标签枚举、跟踪器与帧级泄漏检测器,纯 TS、Node 直跑,是本节第一块可运行产物。注意标签用 as const 对象而不是 enum——Node 26 只做类型擦除,enum 会直接报错。
// file: src/core/memory-tags.ts
// tier: L1
// verify: npx vitest run src/core/memory-tags.test.ts
//
// helio 内核:内存标签、预算与泄漏检测。
// 每个分配都要挂一个「它属于哪个子系统」的标签,跟踪器按标签汇总
// 当前/峰值占用、比对预算;再用「分配计数快照对比」在帧末抓漏。
export const MemoryTag = {
Rendering: "Rendering",
Audio: "Audio",
Physics: "Physics",
Gameplay: "Gameplay",
Asset: "Asset",
Unknown: "Unknown",
} as const;
export type MemoryTag = (typeof MemoryTag)[keyof typeof MemoryTag];
export interface TagUsage {
readonly tag: MemoryTag;
readonly current: number;
readonly peak: number;
readonly live: number;
readonly budget: number | null;
readonly overBudget: boolean;
}
export interface LeakReport {
readonly tag: MemoryTag;
readonly delta: number;
}
/** 一次分配的凭据;调用 `free()` 归还。重复归还会被忽略。 */
export interface Allocation {
readonly tag: MemoryTag;
readonly bytes: number;
free(): void;
}
export class MemoryTracker {
readonly #current = new Map<MemoryTag, number>();
readonly #peak = new Map<MemoryTag, number>();
readonly #live = new Map<MemoryTag, number>();
readonly #budget = new Map<MemoryTag, number>();
#warn: (msg: string) => void;
constructor(warn?: (msg: string) => void) {
this.#warn = warn ?? (() => {});
}
setBudget(tag: MemoryTag, bytes: number): void {
this.#budget.set(tag, bytes);
}
/** 记一笔分配,返回可归还的凭据。 */
allocate(tag: MemoryTag, bytes: number): Allocation {
if (!Number.isFinite(bytes) || bytes < 0) {
throw new RangeError(`bytes must be a non-negative number, got ${bytes}`);
}
const cur = (this.#current.get(tag) ?? 0) + bytes;
this.#current.set(tag, cur);
if (cur > (this.#peak.get(tag) ?? 0)) this.#peak.set(tag, cur);
this.#live.set(tag, (this.#live.get(tag) ?? 0) + 1);
const budget = this.#budget.get(tag);
if (budget !== undefined && cur > budget) {
this.#warn(`[memory] ${tag} over budget: ${cur} > ${budget} B`);
}
let freed = false;
return {
tag,
bytes,
free: () => {
if (freed) return;
freed = true;
const now = (this.#current.get(tag) ?? 0) - bytes;
this.#current.set(tag, now < 0 ? 0 : now);
this.#live.set(tag, (this.#live.get(tag) ?? 0) - 1);
},
};
}
usage(tag: MemoryTag): TagUsage {
const budget = this.#budget.get(tag) ?? null;
const current = this.#current.get(tag) ?? 0;
return {
tag,
current,
peak: this.#peak.get(tag) ?? 0,
live: this.#live.get(tag) ?? 0,
budget,
overBudget: budget !== null && current > budget,
};
}
totalCurrent(): number {
let t = 0;
for (const v of this.#current.values()) t += v;
return t;
}
/** 当前「未归还」的分配笔数快照,供帧末比对。 */
snapshot(): Map<MemoryTag, number> {
return new Map(this.#live);
}
/** 对比快照:返回计数变大的标签(= 本帧没有配对的释放)。 */
diff(before: Map<MemoryTag, number>): LeakReport[] {
const out: LeakReport[] = [];
for (const [tag, now] of this.#live) {
const was = before.get(tag) ?? 0;
if (now > was) out.push({ tag, delta: now - was });
}
return out;
}
}
/**
* 一帧的泄漏检测器:`begin()` 记下快照,`end()` 对比。
* 用法:begin → 跑一帧逻辑 → end;未配对的分配会被列出。
*/
export class FrameLeakDetector {
readonly #tracker: MemoryTracker;
#before: Map<MemoryTag, number> | null = null;
#onLeak: (reports: LeakReport[]) => void;
constructor(tracker: MemoryTracker, onLeak?: (reports: LeakReport[]) => void) {
this.#tracker = tracker;
this.#onLeak = onLeak ?? (() => {});
}
begin(): void {
this.#before = this.#tracker.snapshot();
}
end(): LeakReport[] {
if (this.#before === null) throw new Error("FrameLeakDetector.end() before begin()");
const reports = this.#tracker.diff(this.#before);
this.#before = null;
if (reports.length > 0) this.#onLeak(reports);
return reports;
}
}
三处设计要点。第一,free 幂等。凭据内部用一个 freed 布尔量挡住重复归还,第二次调用直接返回。这比在跟踪器里兜底更稳——错误在源头就被吸收了。
第二,diff 只报「计数变大」的标签。它对比的是未归还笔数,不是字节数。为什么用笔数而不是字节?因为泄漏的本质是「分配了没释放」,字节数相同的两笔分配可能是两个不同的泄漏,而笔数能精确计数。而且 Asset 这类跨帧常驻内存只要在帧初帧末都存在,就不会出现在 diff 里——这正是我们想要的。
第三,FrameLeakDetector 的 end() 会重置状态,所以一个检测器可以跨帧复用:每帧 begin() / end() 一轮,互不干扰。
真跑:制造一次泄漏并抓住它
现在跑一遍。下面这个脚本模拟《微光岛》的四帧:前两帧干净,第三帧故意「忘记释放」一笔 Gameplay 内存,第四帧观察残留。
// file: bench/memory-leak-demo.ts
// tier: B
// 运行:node bench/memory-leak-demo.ts
// 模拟《微光岛》一帧:渲染/音频各分配并释放,Gameplay 故意忘记释放。
import { FrameLeakDetector, MemoryTag, MemoryTracker } from '../src/core/memory-tags.ts';
const tracker = new MemoryTracker((m) => console.log(`WARN ${m}`));
tracker.setBudget(MemoryTag.Rendering, 16 * 1024);
const leaks: string[] = [];
const detector = new FrameLeakDetector(tracker, (reports) => {
for (const r of reports) leaks.push(`${r.tag} +${r.delta}`);
});
function runFrame(n: number, forgetGameplay: boolean): void {
detector.begin();
const rt = tracker.allocate(MemoryTag.Rendering, 4096); // 本帧渲染临时内存
const at = tracker.allocate(MemoryTag.Audio, 1024);
const gp = tracker.allocate(MemoryTag.Gameplay, 256); // 实体组件
rt.free();
at.free();
if (!forgetGameplay) gp.free(); // 忘记释放 → 泄漏
const reports = detector.end();
console.log(
`frame ${n}: Rendering=${tracker.usage(MemoryTag.Rendering).current} B ` +
`Gameplay=${tracker.usage(MemoryTag.Gameplay).current} B ` +
`live=${tracker.usage(MemoryTag.Gameplay).live} leaks=${JSON.stringify(reports)}`,
);
}
runFrame(0, false); // 干净帧
runFrame(1, false); // 干净帧
runFrame(2, true); // 泄漏帧
runFrame(3, false); // 后续帧:Gameplay 仍挂着未释放的 256 B
console.log(`total leaks detected: ${leaks.length} -> ${leaks.join(', ')}`);
console.log(`Gameplay peak = ${tracker.usage(MemoryTag.Gameplay).peak} B`);
本机实测输出(Node 26.5.0):
frame 0: Rendering=0 B Gameplay=0 B live=0 leaks=[]
frame 1: Rendering=0 B Gameplay=0 B live=0 leaks=[]
frame 2: Rendering=0 B Gameplay=256 B live=1 leaks=[{"tag":"Gameplay","delta":1}]
frame 3: Rendering=0 B Gameplay=256 B live=1 leaks=[]
total leaks detected: 1 -> Gameplay +1
Gameplay peak = 512 B
逐行读一遍,三件事同时被验证了。
第 2 帧抓住了泄漏:leaks=[{"tag":"Gameplay","delta":1}]——这一帧有一笔 Gameplay 分配没有配对释放,检测器准确点名了标签与笔数。
第 3 帧没有重复报警:此时 Gameplay 仍有 256 B 挂着(live=1),但因为它是上一帧遗留、本帧帧初就已存在,diff 只比对「本帧新增」,所以 leaks=[]。这避免了同一个泄漏被每帧刷屏——它是新泄漏检测器,不是「当前泄漏总数」显示器。
Gameplay peak = 512 B:泄漏的那 256 B 没有释放,第 3 帧又新分配了一笔 256 B(虽在帧末释放),峰值因此到过 512 B。峰值是泄漏的另一个信号面——泄漏会让峰值单调爬升。
断言层面,src/core/memory-tags.test.ts 把这些语义都固定下来,本机实测 npx vitest run src/core/memory-tags.test.ts 为 Test Files 1 passed (1)、Tests 9 passed (9)。其中最关键的两条是「帧内未配对的分配被检出」与「跨帧持久的分配不会被误报」——它们一正一反,共同定义了「什么才算本帧的泄漏」。
小结
- 每笔分配挂标签,内存账本就有了科目:归因与分类检测都靠它。
MemoryTracker按标签维护当前/峰值/在册笔数,超预算即告警。- 用
as const对象当枚举,不用enum——Node 26 只做类型擦除。 - 泄漏检测 = 未归还笔数快照对比:
begin记、end比,只报本帧新增。 - 用笔数而非字节数计数,且只报增量,才不会把跨帧常驻内存误判成泄漏。
到这里,「内存」这一章就闭环了:4.x 解决了「怎么把字节借出去」,5.1、5.2 解决了「怎么高效复用」,5.3 解决了「怎么看得见、管得住」。下一章我们换一个更凶险的问题——借出去的内存,句柄为什么会在你手里失效。
阅读导航:上一节:5.2 块分配器与大小类 · 下一节:6.1 为什么指针会失效 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。