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
两个细节值得指出:
- 第一次
collect之后,mesh 的refs已经是 0,但has仍为true——这正是「延迟释放」的中间态:它已经没人要了,但还躺在待释放队列里,要等下一次collect。这段窗口就是「复活」的窗口:只要在第二次collect之前有人acquire('mesh.island'),它就会被原样捡回来,不重建。 - 级联释放被推迟而不是递归执行。材质在
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 资源导入器与离线处理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。