《游戏引擎实现原理》5.3 内存标签、预算与泄漏检测

本节给每笔分配挂上 Rendering/Audio/Physics/Gameplay 等内存标签,用 MemoryTracker 统计各标签的当前与峰值占用并按预算告警;再用分配计数快照做帧级泄漏检测,真跑制造一次忘记 release 的泄漏并当场抓出。

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 为什么指针会失效 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径