《移动游戏产品的架构·客户端与移动运行时》6.1 资源句柄、引用计数与生命周期

资源泄漏与黑块的根因往往不是加载失败,而是句柄没有代际、释放没有归属。本节把 key、generation、owner 与引用计数收敛成一个可审计的资源图,用场景级批量回收替代散落的 destroy 调用,并让重复释放与失效句柄返回可定位的错误。

事故:旧场景的回调释放了新场景的图集

《潮汐消消》在春节活动版本遇到一类黑块:玩家从家园装饰返回主界面,主界面的按钮图集偶尔变成纯黑,重启后恢复。日志里没有加载失败,也没有崩溃,只有一条不起眼的「释放 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。系统可能在任意时刻回收进程,模型只能保证客户端内部的释放责任清晰,不能保证进程一直存活。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查