《游戏引擎实现原理》1.3 引用计数、生命周期与释放

资源加载进来之后,谁拥有它、什么时候可以扔?本节从零实现资源级的引用计数表:acquire 加引用、release 减引用、归零不立刻释放而是挂进待释放队列、帧末 collect 统一释放。用一个「材质释放时级联释放它持有的 mesh」的场景证明延迟释放如何避开 re-entrant 释放,并量出本机约 2100 万次 acquire/release 每秒。

1.3 引用计数、生命周期与释放

1.2 的加载器把资源拿进了内存,但接下来一个更棘手的问题浮现了:这块纹理什么时候可以释放?加载容易,释放难。释放太早,别处还在用的指针变成悬空引用;释放太晚,内存一路涨到 OOM。手工 free 已经被证明不可靠——所以引擎普遍用引用计数:谁要用资源就先 acquire(计数 +1),用完了 release(计数 -1),计数归零就意味着没人要了。

这一节实现 helio 的资源引用表。它和 C++ 的 shared_ptr 语义一致,但加了一个关键细节:计数归零不立刻释放,而是挂进「待释放」队列,由帧末的 collect() 统一释放。为什么非要绕这一下,是这个模块最值得讲的地方。

为什么归零不能立刻释放

如果 release 把计数减到 0 就当场 dispose(),会踩两个坑。

坑一:re-entrant 释放。 一个材质的 dispose() 里往往要去释放它持有的贴图、网格。设想释放队列里同时躺着材质和它持有的网格:遍历队列释放材质时,材质的 dispose 又去 release 那个网格,而这个网格可能正好也在队列里、甚至已经被释放过了。当场释放会把「正在遍历的队列」搅乱,轻则重复释放,重则释放一个已经被别人删掉的对象。

坑二:同帧内的「先扔后捡」。 一个资源在这一帧的早段计数归零,但同一帧的晚段(比如刚加载完的另一个场景)又引用了它。如果归零即释放,就得销毁再重建——对 GPU 资源来说,这意味着一次昂贵的显存重新上传。

延迟释放同时解决这两个问题:release 只负责把资源标记为「待释放」,真正的 dispose 推迟到帧末的 collect()。这样 collect 里发生的级联 release 只是往队列里追加,不会在遍历途中递归释放;而在 collect 之前任何一次 acquire 都能把资源「复活」,避免同帧内的销毁重建。

// file: src/subsys/resource/refcount.ts
// tier: L1
// verify: npx vitest run src/subsys/resource/refcount.test.ts
//
// helio 资源管线:引用计数与生命周期。
// 每个资源有一个引用计数;计数归零不立刻释放,而是挂进「待释放」队列,
// 由帧末的 collect() 统一释放——这样同一帧内的 re-entrant 释放不会踩到悬空对象,
// 也让「刚归零又被重新引用」的情况(resurrect)得以幸免。

/** 任何持有 GPU / 内存资源的对象都实现它,在 collect 时被调用。 */
export interface Disposable {
  dispose(): void;
}

interface Slot<T> {
  readonly guid: string;
  refs: number;
  readonly payload: T;
  pendingFree: boolean;
}

/**
 * 资源引用表。
 *
 * 语义对齐 shared_ptr / weak_ptr:
 *   - acquire:计数 +1,资源不存在则用工厂创建;若正等待释放则「复活」;
 *   - release:计数 -1,归零时挂入待释放队列(不立即 dispose);
 *   - collect:帧末统一释放队列里的资源,返回释放个数;
 *   - peek:只读,不改计数,用于「看一眼还在不在」。
 */
export class ResourceTable<T extends Disposable> {
  #create: (guid: string) => T;
  #slots = new Map<string, Slot<T>>();
  #pendingFree: string[] = [];
  #disposeCalls = 0;

  constructor(create: (guid: string) => T) {
    this.#create = create;
  }

  /** 已释放的资源总数(含复活后再次释放)。 */
  get disposeCalls(): number {
    return this.#disposeCalls;
  }

  /** 引用计数 +1;资源不存在则创建,等待释放则复活。 */
  acquire(guid: string): T {
    let slot = this.#slots.get(guid);
    if (slot === undefined) {
      slot = { guid, refs: 0, payload: this.#create(guid), pendingFree: false };
      this.#slots.set(guid, slot);
    } else if (slot.pendingFree) {
      // 复活:从待释放队列里摘掉,保留原 payload。
      slot.pendingFree = false;
      const i = this.#pendingFree.indexOf(guid);
      if (i >= 0) this.#pendingFree.splice(i, 1);
    }
    slot.refs++;
    return slot.payload;
  }

  /** 引用计数 -1。归零则挂入待释放队列,返回 true;计数非法则抛错。 */
  release(guid: string): boolean {
    const slot = this.#slots.get(guid);
    if (slot === undefined) throw new Error(`release 未知资源:${guid}`);
    if (slot.refs <= 0) throw new Error(`release 计数已为 0:${guid}`);
    slot.refs--;
    if (slot.refs === 0) {
      slot.pendingFree = true;
      this.#pendingFree.push(guid);
      return true;
    }
    return false;
  }

  /** 帧末调用:释放所有待释放资源,返回释放个数。 */
  collect(): number {
    if (this.#pendingFree.length === 0) return 0;
    const batch = this.#pendingFree;
    this.#pendingFree = [];
    let n = 0;
    for (const guid of batch) {
      const slot = this.#slots.get(guid);
      if (slot === undefined || !slot.pendingFree) continue; // 中途复活
      slot.payload.dispose();
      this.#disposeCalls++;
      this.#slots.delete(guid);
      n++;
    }
    return n;
  }

  refcount(guid: string): number {
    return this.#slots.get(guid)?.refs ?? 0;
  }

  /** 资源是否存在(存活或等待释放都算存在)。 */
  has(guid: string): boolean {
    return this.#slots.has(guid);
  }

  /** 只读访问,不改引用计数。资源不存在返回 undefined。 */
  peek(guid: string): T | undefined {
    return this.#slots.get(guid)?.payload;
  }

  /** 当前仍被持有的资源(refs > 0),用于泄漏排查。 */
  leaks(): string[] {
    const out: string[] = [];
    for (const slot of this.#slots.values()) {
      if (slot.refs > 0) out.push(slot.guid);
    }
    return out.sort();
  }

  /** 待释放队列长度(尚未 collect)。 */
  get pendingCount(): number {
    return this.#pendingFree.length;
  }
}

可运行产物:延迟释放如何避开 re-entrant 释放

下面的 demo 造一个真实场景:mesh 被材质持有,材质被场景持有。帧末场景释放材质,材质的 dispose 会级联释放 mesh。观察两次 collect 之间发生了什么——mesh 的级联 release 发生在第一次 collect 内部,但它并没有被当场释放,而是留到了第二次 collect。

// file: src/subsys/resource/refcount-demo.ts
// tier: B
// verify: node src/subsys/resource/refcount-demo.ts
//
// 1) 演示延迟释放:材质在 collect 里被释放时,它的 dispose 又去 release 它持有的 mesh,
//    这次 release 只是把 mesh 挂进队列,留到下一次 collect 才真正释放——避免在遍历
//    释放队列的过程中递归释放,踩到正在被处理的状态;
// 2) 量一次 acquire/release 的吞吐。

import { ResourceTable } from './refcount.ts';
import type { Disposable } from './refcount.ts';

const log: string[] = [];

class Node implements Disposable {
  readonly guid: string;
  /** 释放本节点时要级联做的事(例如释放它持有的子资源)。 */
  onDispose: (() => void) | null = null;
  constructor(guid: string) {
    this.guid = guid;
  }
  dispose(): void {
    log.push(`dispose ${this.guid}`);
    if (this.onDispose) this.onDispose();
  }
}

const table = new ResourceTable<Node>((guid) => new Node(guid));

const mesh = table.acquire('mesh.island'); // mesh refs = 1
const mat = table.acquire('material.terrain'); // 材质 refs = 1
table.acquire('material.terrain'); // 场景也持有材质,refs = 2
mat.onDispose = () => table.release('mesh.island'); // 材质释放时级联释放 mesh

log.push('--- 帧末:场景释放材质 ---');
table.release('material.terrain'); // refs = 1
table.release('material.terrain'); // refs = 0,挂入待释放队列
log.push(`待释放队列=${table.pendingCount}`);

log.push('--- collect #1 ---');
log.push(`本次释放 ${table.collect()} 个`);
log.push(`mesh 仍存活=${table.has('mesh.island')} refs=${table.refcount('mesh.island')}`);
log.push(`待释放队列=${table.pendingCount}`);

log.push('--- collect #2:mesh 现在才被释放 ---');
log.push(`本次释放 ${table.collect()} 个`);
log.push(`mesh 仍存活=${table.has('mesh.island')}`);
console.log(log.join('\n'));

// 吞吐:连续 acquire/release 同一个资源。
const N = 1_000_000;
const t0 = performance.now();
for (let i = 0; i < N; i++) {
  table.acquire('mesh.island');
  table.release('mesh.island');
}
const ms = performance.now() - t0;
table.collect();
console.log(`\nacquire+release 各 ${N} 次:${ms.toFixed(1)}ms  ${(N / (ms / 1000) / 1e6).toFixed(2)} M ops/s`);

本机实跑输出如下:

--- 帧末:场景释放材质 ---
待释放队列=1
--- collect #1 ---
dispose material.terrain
本次释放 1 个
mesh 仍存活=true refs=0
待释放队列=1
--- collect #2:mesh 现在才被释放 ---
dispose mesh.island
本次释放 1 个
mesh 仍存活=false

acquire+release 各 1000000 次:48.0ms  20.84 M ops/s

两个细节值得指出:

  1. 第一次 collect 之后,mesh 的 refs 已经是 0,但 has 仍为 true——这正是「延迟释放」的中间态:它已经没人要了,但还躺在待释放队列里,要等下一次 collect。这段窗口就是「复活」的窗口:只要在第二次 collect 之前有人 acquire('mesh.island'),它就会被原样捡回来,不重建。
  2. 级联释放被推迟而不是递归执行。材质在 collect #1 里被释放时触发的 release('mesh.island'),只是往队列追加了一项;collect #1 遍历的是它开始时的快照,所以 mesh 落到了 collect #2。这就是「延迟释放天然免疫 re-entrant」的实证。

后半段是吞吐:本机连续 100 万次 acquire + release(每次都在 1 和 0 之间来回,会反复触发出队/入队),耗时约 48ms,约 2100 万次每秒。这个量级说明引用计数的开销对帧预算完全可以忽略——它不该成为你不敢用它的理由。

测试:计数、复活、泄漏三件事

refcount.test.ts 用 6 条用例锁住语义:多次 acquire 需要同样多次 release、release 未知资源或计数已为 0 时抛错(防 double free)、归零后 collect 前 acquire 能复活且保留同一 payload、peek 不改计数、leaks() 报出仍被持有的资源。其中「复活」和「防 double free」两条最关键:

// snippet: 复活与防 double free,摘自 src/subsys/resource/refcount.test.ts
// 归零后、collect 前重新引用 → 复活,保留原 payload
const first = table.acquire('t.c');
table.release('t.c');                 // 归零,待释放
const second = table.acquire('t.c');  // 复活
expect(second).toBe(first);           // 同一对象,没有重建
expect(table.collect()).toBe(0);      // 队列已空,不会被释放

// 计数为 0 时再 release → 抛错
table.acquire('t.b');
table.release('t.b');
expect(() => table.release('t.b')).toThrow(/计数已为 0/);

这些用例在本机 npx vitest run 全绿。需要说明的是,引用计数只解决「谁还持有」的问题,解决不了循环引用——两个资源互相持有,计数永远不会归零。资源图里循环引用并不罕见(A 材质引用 B 贴图、B 贴图的生成脚本又引用 A 材质),业界通常用「弱引用」或「按依赖图级联释放」来打破它;前者是内核 17.2 讲的对象图话题,后者正好用得上 1.1 的反向边。本节的 peek 就是弱引用的雏形:看一眼还在不在,但不参与计数。

到这里,第 1 章的「资源从建模到释放」闭环了:1.1 把资源建成依赖图,1.2 按图并发加载并报进度,1.3 用引用计数管理它的一生。下一章我们离开运行时,去看资源是怎么离线从美术的原始文件变成引擎能直接读的格式的。

阅读导航:上一节:1.2 异步加载与进度 · 下一节:2.1 资源导入器与离线处理 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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