事故:旧场景的回调释放了新场景的图集
《潮汐消消》在春节活动版本遇到一类黑块:玩家从家园装饰返回主界面,主界面的按钮图集偶尔变成纯黑,重启后恢复。日志里没有加载失败,也没有崩溃,只有一条不起眼的「释放 ui/main 成功」。
复盘发现问题出在时序上。家园场景退出时,一张装饰图集的异步加载仍在路上;主界面此时已经加载了同名的 ui/main。旧回调回来后,拿着「key 等于 ui/main」的句柄调用了一次释放,把新场景刚建立的引用计数减到零,图集被销毁。旧代码里句柄只是一个字符串,系统无法区分「春节活动那次加载」和「主界面这次加载」。
同一个缺陷还有第二种表现:活动运营手动刷新资源时,同一张图集被连续释放两次,第二次没有报错,因为引用计数被减成了负数,而负数被当成「没有引用」。随后任何一次 GC 扫描都读不到负数,泄漏从此隐形。
这两个现象指向同一条设计缺口:句柄必须能表达「哪一次加载」,释放必须能回答「谁在持有」。只记录一个 key 的引用计数,无法同时满足这两点。
句柄不是 key,而是 key 加代际
mga-lab 的句柄包含四个字段。它们各自解决一个具体问题,缺一个就会在某个时序下失效。
| 字段 | 解决的问题 | 缺失后的典型症状 |
|---|---|---|
| key | 资源身份,指向注册表里的规格 | 无法定位加载对象 |
| generation | 条目每次销毁后递增,区分同 key 的不同生命周期 | 旧回调释放新资源,出现黑块 |
| owner | 记录持有者,支持按场景批量回收 | 场景退出只能逐个 destroy,容易漏 |
| id | 句柄唯一标识,识别同一句柄的重复调用 | 重复释放被静默吞掉 |
generation 是关键。资源被完整释放时,图把该 key 的代际加一;之后重新加载得到的是新一代条目。旧句柄携带旧代际,释放时必然对不上,返回 stale-handle,而不是继续操作新条目。这样「延迟回调」从不可控的竞态变成了一个可判定的错误。
owner 不是给日志看的标签,而是回收的索引。场景退出时,图只需要遍历该 owner 的句柄,按后进先出释放;共享资源因为还有其他 owner 的计数,会自然保留。
引用计数要能回答三个问题
一个可用的引用计数至少要回答:当前谁在持有、释放的是不是同一个句柄、计数归零后是否真的可以销毁。mga-lab 把这三个问题分别落实为持有者表、句柄记录与条目销毁。
- 谁在持有:条目内维护
holdings: Map<owner, count>,refCount必须等于所有 owner 计数之和;audit()在每次测试里校验这个等式。 - 释放的是不是同一个句柄:句柄 id 与记录一一对应,先比对 key,再比对代际,最后看
released标记。 - 是否可以销毁:只有
refCount归零且持有者表为空才销毁条目,并把代际加一。
三类释放错误必须分开,否则日志无法指导修复。
| 错误 | 触发条件 | 正确的处理 |
|---|---|---|
| double-release | 同一句柄第二次调用 release,条目仍存活 | 视为调用方缺陷,记录 owner 与调用栈 |
| stale-handle | 句柄代际落后于当前条目,或条目已被卸载 | 忽略释放,但累计计数用于定位旧回调 |
| unknown-handle | 句柄 id 不存在,或 key 与记录不一致 | 视为跨图误用或伪造句柄,直接失败 |
把 double-release 与 stale-handle 合并成一个「无效句柄」会让排查失去方向:前者是调用方重复写释放,后者是异步回调跨越了资源生命周期,修复手段完全不同。
谁拥有资源:owner 与场景边界
资源图的 owner 由业务层显式传入,通常只有四类。把 owner 设计成有层次的名字,比在释放时判断「这个资源是不是场景的」可靠得多。
| owner 类别 | 典型例子 | 生命周期 | 释放责任 |
|---|---|---|---|
| global | 登录配置、全局字体、常驻音频 | 进程级 | 退出登录或切账号时显式释放 |
| scene: | 关卡图集、家园装饰、战斗背景 | 场景进入至退出 | 场景退出时 releaseOwner |
| hud | 主界面按钮、货币栏、红点 | 主界面存续期 | 主界面销毁时释放 |
| temp | 单次弹窗、预览图 | 一次交互 | 关闭回调内释放 |
场景退出调用 releaseOwner("scene:home"),只释放该 owner 的句柄。若同一张图集同时被 hud 持有,释放后 refCount 仍为 1,条目保留;等到主界面销毁再释放 hud 的句柄,才真正销毁。这个规则让「共享资源被场景误删」不再依赖开发者的记忆。
反过来,如果场景退出后某个 scene: 句柄仍留在图里,releaseOwner 的报告会给出 handles 数量,6.3 的快照再把「没释放的 key」列出来。批量回收不是万能清理,它只是把「谁该释放」变成一个可以核对的清单。
场景切换的批量回收顺序
releaseOwner 按句柄 id 倒序释放,也就是后进先出。原因不是美学,而是依赖:后加载的资源更可能引用先加载的资源,先释放上层句柄可以避免在销毁底层资源时仍有上层对象指向它。
报告包含四个字段:handles 表示本次处理的句柄数,freedEntries 表示真正销毁的条目数,freedBytes 表示回收字节数,errors 汇总释放失败的类别。前三个用于水位与预算,errors 用于回归测试断言为零。若 handles 大于 freedEntries,说明存在跨 owner 共享,这是正常现象;若 errors 非空,则说明场景代码里有重复释放或旧句柄,必须修。
这里有一个容易忽略的边界:批量回收只处理「已经 acquire 成功」的句柄。仍在加载中的请求没有句柄,场景退出时不能假设它不会回来。工程上的做法是给加载回调带上场景令牌,回调执行前比对当前场景;令牌不匹配就丢弃结果。句柄代际负责保护「资源条目」,场景令牌负责保护「业务回调」,两者不能互相替代。
可运行模型:带代际与 owner 的资源图
下面的 graph.ts 把上述规则落成可执行代码。它只依赖第 1 章的 Result,加载器通过接口注入,测试里可以用失败加载器模拟 404;audit() 校验引用计数与持有者表一致,stats() 暴露销毁条目数、字节数与两类释放错误计数。
// file: src/resource/graph.ts
// tier: L1
// verify: node --test test/resource/graph.test.ts
import { fail, ok, type Result } from "../core/result.ts";
export type ResourceKind = "texture" | "atlas" | "audio" | "config";
export interface ResourceSpec {
readonly id: string;
readonly kind: ResourceKind;
readonly sizeBytes: number;
}
export interface Handle {
readonly id: number;
readonly key: string;
readonly generation: number;
readonly owner: string;
}
export interface Loader {
load(spec: ResourceSpec): Result<number, string>;
}
export type ResourceError =
| "unknown-key"
| "unknown-handle"
| "stale-handle"
| "double-release"
| "load-failed"
| "invalid-size"
| "duplicate-key";
export interface ReleaseOutcome {
readonly key: string;
readonly freed: boolean;
readonly freedBytes: number;
}
export interface OwnerReleaseReport {
readonly owner: string;
readonly handles: number;
readonly freedEntries: number;
readonly freedBytes: number;
readonly errors: readonly ResourceError[];
}
export interface ResourceView {
readonly key: string;
readonly kind: ResourceKind;
readonly sizeBytes: number;
readonly generation: number;
readonly refCount: number;
readonly owners: Readonly<Record<string, number>>;
}
interface Entry {
readonly spec: ResourceSpec;
refCount: number;
readonly holdings: Map<string, number>;
}
interface HandleRecord {
readonly key: string;
readonly generation: number;
readonly owner: string;
released: boolean;
}
const DEFAULT_LOADER: Loader = { load: (spec) => ok(spec.sizeBytes) };
export class ResourceGraph {
#loader: Loader;
#specs = new Map<string, ResourceSpec>();
#entries = new Map<string, Entry>();
#generations = new Map<string, number>();
#handles = new Map<number, HandleRecord>();
#nextHandleId = 1;
#disposedEntries = 0;
#disposedBytes = 0;
#staleReleases = 0;
#doubleReleases = 0;
constructor(loader: Loader = DEFAULT_LOADER) {
this.#loader = loader;
}
register(spec: ResourceSpec): Result<ResourceSpec, ResourceError> {
if (!Number.isInteger(spec.sizeBytes) || spec.sizeBytes < 0) {
return fail("invalid-size", `${spec.id}: ${spec.sizeBytes}`);
}
const existing = this.#specs.get(spec.id);
if (existing !== undefined) {
if (existing.sizeBytes !== spec.sizeBytes || existing.kind !== spec.kind) {
return fail("duplicate-key", spec.id);
}
return ok(existing);
}
this.#specs.set(spec.id, spec);
return ok(spec);
}
acquire(key: string, owner: string): Result<Handle, ResourceError> {
const spec = this.#specs.get(key);
if (spec === undefined) return fail("unknown-key", key);
let entry = this.#entries.get(key);
if (entry === undefined) {
const loaded = this.#loader.load(spec);
if (!loaded.ok) return fail("load-failed", `${key}: ${loaded.reason}`);
entry = { spec, refCount: 0, holdings: new Map() };
this.#entries.set(key, entry);
}
entry.refCount += 1;
entry.holdings.set(owner, (entry.holdings.get(owner) ?? 0) + 1);
const id = this.#nextHandleId;
this.#nextHandleId += 1;
const generation = this.#generationOf(key);
this.#handles.set(id, { key, generation, owner, released: false });
return ok({ id, key, generation, owner });
}
release(handle: Handle): Result<ReleaseOutcome, ResourceError> {
const record = this.#handles.get(handle.id);
if (record === undefined || record.key !== handle.key) return fail("unknown-handle", String(handle.id));
if (record.generation !== this.#generationOf(handle.key)) {
this.#staleReleases += 1;
return fail("stale-handle", `${handle.key}@${record.generation}`);
}
if (record.released) {
this.#doubleReleases += 1;
return fail("double-release", `${handle.key}#${handle.id}`);
}
const entry = this.#entries.get(handle.key);
if (entry === undefined) {
this.#staleReleases += 1;
return fail("stale-handle", `${handle.key}@${record.generation}`);
}
const held = (entry.holdings.get(handle.owner) ?? 0) - 1;
if (entry.refCount === 1 && held !== 0) {
return fail("stale-handle", `${handle.key}: holdings disagree with refcount`);
}
record.released = true;
entry.refCount -= 1;
if (held <= 0) entry.holdings.delete(handle.owner);
else entry.holdings.set(handle.owner, held);
if (entry.refCount > 0) return ok({ key: handle.key, freed: false, freedBytes: 0 });
this.#entries.delete(handle.key);
this.#generations.set(handle.key, this.#generationOf(handle.key) + 1);
this.#disposedEntries += 1;
this.#disposedBytes += entry.spec.sizeBytes;
return ok({ key: handle.key, freed: true, freedBytes: entry.spec.sizeBytes });
}
releaseOwner(owner: string): OwnerReleaseReport {
const owned: Handle[] = [];
for (const [id, record] of this.#handles) {
if (record.released || record.owner !== owner || !this.#entries.has(record.key)) continue;
owned.push({ id, key: record.key, generation: record.generation, owner });
}
owned.sort((a, b) => b.id - a.id);
const errors: ResourceError[] = [];
let freedEntries = 0;
let freedBytes = 0;
for (const handle of owned) {
const outcome = this.release(handle);
if (!outcome.ok) {
errors.push(outcome.error);
continue;
}
if (outcome.value.freed) {
freedEntries += 1;
freedBytes += outcome.value.freedBytes;
}
}
return { owner, handles: owned.length, freedEntries, freedBytes, errors };
}
has(key: string): boolean {
return this.#entries.has(key);
}
refCount(key: string): number {
return this.#entries.get(key)?.refCount ?? 0;
}
generationOf(key: string): number {
return this.#generationOf(key);
}
snapshot(): readonly ResourceView[] {
return [...this.#entries.entries()]
.map(([key, entry]) => ({
key,
kind: entry.spec.kind,
sizeBytes: entry.spec.sizeBytes,
generation: this.#generationOf(key),
refCount: entry.refCount,
owners: Object.fromEntries([...entry.holdings.entries()].sort()),
}))
.sort((a, b) => a.key.localeCompare(b.key));
}
audit(): readonly string[] {
const problems: string[] = [];
for (const [key, entry] of this.#entries) {
const held = [...entry.holdings.values()].reduce((sum, count) => sum + count, 0);
if (entry.refCount !== held) problems.push(`${key}: refCount=${entry.refCount} holdings=${held}`);
if (entry.refCount <= 0) problems.push(`${key}: non-positive refCount=${entry.refCount}`);
}
return problems;
}
stats(): Readonly<Record<string, number>> {
let bytes = 0;
let refs = 0;
for (const entry of this.#entries.values()) {
bytes += entry.spec.sizeBytes;
refs += entry.refCount;
}
return {
entries: this.#entries.size,
refs,
bytes,
disposedEntries: this.#disposedEntries,
disposedBytes: this.#disposedBytes,
staleReleases: this.#staleReleases,
doubleReleases: this.#doubleReleases,
};
}
#generationOf(key: string): number {
return this.#generations.get(key) ?? 0;
}
}
配套测试 test/resource/graph.test.ts 覆盖七条路径:共享资源最后释放、重复释放、卸载后旧句柄失效、按 owner 批量回收、加载失败不留半初始化条目、注册校验,以及快照排序。运行命令是 node --test test/resource/graph.test.ts。
这里是模型,不是设备上的测量。真实的纹理上传、GPU 资源释放与平台内存回收不在 Node 进程里发生;模型只保证「谁在什么时候应该释放」这件事可判定、可回归。
双案例:同一种句柄,不同的回收粒度
消消的总资源是 480MB、首包 120MB,单局只有 90 秒,场景切换频繁,回收粒度自然落在「场景 + 主界面」两级;一次活动更新只替换少量图集,旧句柄主要在切场景时暴露。
方舟的总资源是 1.8GB、首包 350MB,单场 8–12 分钟,战斗内还有卡牌立绘、特效与音频的多层引用;它更依赖 owner 分层与 bundle 级卸载,句柄代际用于保护战前预加载与战斗内按需加载之间的竞态。两者共用同一套句柄语义,但方舟必须把回收报告接入资源包管理,消消只需要接场景栈。
与相邻章节的关系
6.2 会把本节 stats().bytes 作为水位输入:引用计数回答「能不能释放」,水位回答「必须释放到什么程度」。6.3 会用本节快照做场景前后差值,把「应该释放但没释放」变成回归用例。资源包的分包与远程加载边界可以参考资源包与 Asset Bundle
,那里讨论的是包如何组织,本节讨论包内资源由谁持有。分层与依赖方向的设计约束可以参考依赖方向
,资源图同样遵循「业务层不直接操作引擎资源」的规则。
小结
- 句柄必须带代际,否则旧回调会释放新资源;代际失配返回
stale-handle,不是静默成功。 - 引用计数要与持有者表一致,
double-release与stale-handle必须分开统计。 - owner 是回收索引,不是日志标签;场景退出用
releaseOwner,共享资源靠计数自然保留。 - 批量回收按后进先出,报告中的
errors必须为零,handles大于freedEntries是共享的正常表现。 - 句柄代际保护资源条目,场景令牌保护业务回调,二者不能互相替代。
阅读导航:上一节:5.3 策划工作流:从表格到线上 · 下一节:6.2 内存水位与低端机降级 。
常见错误与边界
- 用字符串 key 当句柄。同 key 重新加载后,旧回调无法与新一代区分;必须把代际写进句柄并在释放时比对。
- 把引用计数减到零以下还继续运行。负数会被当成「无引用」,泄漏从此不可见;释放前先校验
refCount === 1时的持有者计数。 - 场景退出逐个 destroy,而不是按 owner 批量释放。漏掉一个异步回调就是一次黑块;批量释放至少能把漏网句柄计入报告。
- 把
double-release和stale-handle合并处理。前者是调用方重复释放,后者是跨生命周期的旧回调,修复方向相反。 - 只靠释放回调,不给加载回调加场景令牌。资源条目已销毁不代表业务回调不会执行;场景令牌必须在回调入口比对。
- 边界:本模型不模拟 GPU 显存与平台 Low Memory Killer。系统可能在任意时刻回收进程,模型只能保证客户端内部的释放责任清晰,不能保证进程一直存活。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。