1.2 异步加载与进度
上一节的依赖图给出了「先加载谁」,但没回答「怎么加载」。最朴素的写法是 for (const a of order) await load(a)——一条串行链,加载 64 个资源就要等 64 次 IO 依次完成,玩家盯着一个几乎不动的进度条。真实的加载器要同时处理三件事:并发(多个 IO 同时在飞)、优先级(进战斗前先把角色资源插队加载)、进度(让 UI 知道还有多久)。
这一节把这三件事收进一个 AssetLoader。它不关心 IO 到底是网络还是磁盘——fetchBytes 是一个注入的函数,测试里是可控的假 IO,真实运行时换成 fetch + arrayBuffer 即可。这种「把 IO 抽象出去」的做法让调度逻辑可以用纯 TS 在 Node 直跑,也是本卷 L1 代码能覆盖到加载器的关键。
加载器要回答的三个问题
并发上限:无限并发会让带宽和内存瞬间打满。加载器维护一个大小为 concurrency 的 worker 池,每个 worker 从队列里取一个请求、跑完再取下一个,于是在飞的 IO 数量永远不超过上限。优先级:队列不按入队顺序出队,而是按 priority 降序;同优先级保持 FIFO,避免饥饿。去重:同一份资源可能被多个场景同时请求,enqueue 用一张 Map<guid, request> 保证同一 GUID 只加载一次,重复入队时保留优先级更高的那个请求。
进度回调则按字节而不是按个数统计——因为资源大小差好几个数量级,一个 8MB 的纹理和一个 2KB 的配置各算「1 个」,进度条会严重失真。清单里预先记了每个资源的期望字节数,加载器在加载前就能撑起进度条,每完成一个资源就按它的字节数推进。
// file: src/subsys/resource/async-loader.ts
// tier: L1
// verify: npx vitest run src/subsys/resource/async-loader.test.ts
//
// helio 资源管线:带并发上限、优先级与进度回调的异步加载器。
// 依赖图的拓扑序给出「先加载谁」,本模块回答「怎么并发地加载,并让 UI 看到进度」。
// IO 通过注入的 fetchBytes 抽象,测试与真实运行共用同一份调度逻辑。
/** 一次加载请求。bytes 是清单里已知的期望字节数,用于在加载前就撑起进度条。 */
export interface LoadRequest {
readonly guid: string;
readonly path: string;
/** 期望字节数(来自资源清单,非实际读到的大小)。 */
readonly bytes: number;
/** 优先级,数值越大越先加载。 */
readonly priority: number;
}
/** 加载完成的资源。 */
export interface LoadedAsset {
readonly guid: string;
readonly path: string;
readonly data: Uint8Array;
}
/** 进度快照:已完成的资源数、字节数与比值。 */
export interface LoadProgress {
readonly done: number;
readonly total: number;
readonly bytesDone: number;
readonly bytesTotal: number;
/** 0..1,按字节计。 */
readonly ratio: number;
}
/** 读取一个路径的字节内容。真实实现是 fetch + arrayBuffer,测试里是可控的假 IO。 */
export type FetchBytes = (path: string) => Promise<Uint8Array>;
export type ProgressFn = (p: LoadProgress) => void;
/**
* 并发加载器。
*
* 三条规则:
* 1. 并发上限:最多 concurrency 个 fetch 同时在飞,其余排队;
* 2. 优先级:队列按 priority 降序出队,同优先级按入队顺序(FIFO);
* 3. 去重:同一 guid 只加载一次,重复入队时保留优先级更高的那个请求。
*/
export class AssetLoader {
#fetch: FetchBytes;
#concurrency: number;
#pending = new Map<string, LoadRequest>();
#order: string[] = [];
#progressFns: ProgressFn[] = [];
constructor(fetchBytes: FetchBytes, concurrency = 4) {
if (!Number.isInteger(concurrency) || concurrency < 1) {
throw new RangeError(`concurrency 必须是 >= 1 的整数,收到 ${concurrency}`);
}
this.#fetch = fetchBytes;
this.#concurrency = concurrency;
}
get pendingCount(): number {
return this.#pending.size;
}
/** 入队一批请求。同一 guid 重复入队只保留优先级更高的。 */
enqueue(reqs: readonly LoadRequest[]): void {
for (const req of reqs) {
const existing = this.#pending.get(req.guid);
if (existing === undefined) {
this.#pending.set(req.guid, req);
this.#order.push(req.guid);
} else if (req.priority > existing.priority) {
this.#pending.set(req.guid, req);
}
}
}
/** 注册进度回调,返回取消函数。 */
onProgress(fn: ProgressFn): () => void {
this.#progressFns.push(fn);
return () => {
const i = this.#progressFns.indexOf(fn);
if (i >= 0) this.#progressFns.splice(i, 1);
};
}
/** 跑完队列,返回所有已加载资源(按完成顺序)。 */
async run(): Promise<LoadedAsset[]> {
const queue = [...this.#order]
.map((guid) => this.#pending.get(guid)!)
.sort((a, b) => b.priority - a.priority);
this.#pending.clear();
this.#order = [];
const total = queue.length;
const bytesTotal = queue.reduce((s, r) => s + r.bytes, 0);
let done = 0;
let bytesDone = 0;
const results: LoadedAsset[] = [];
const emit = (): void => {
const p: LoadProgress = {
done,
total,
bytesDone,
bytesTotal,
ratio: bytesTotal > 0 ? bytesDone / bytesTotal : 1,
};
for (const fn of this.#progressFns) fn(p);
};
emit();
let cursor = 0;
const worker = async (): Promise<void> => {
while (cursor < queue.length) {
const req = queue[cursor++]!;
const data = await this.#fetch(req.path);
results.push({ guid: req.guid, path: req.path, data });
done++;
bytesDone += req.bytes;
emit();
}
};
const workers = Array.from({ length: Math.min(this.#concurrency, total) }, worker);
await Promise.all(workers);
return results;
}
}
可运行产物:并发上限把 IO 时间重叠起来
下面这个基准加载 64 个资源,每个的假 IO 固定耗时 10ms,分别在并发 1 / 2 / 4 / 8 下跑,看墙钟耗时怎么变。用假 IO 而不是真文件,是为了让「IO 时长」成为一个可控常量,测出来的差异纯粹来自调度。
// file: src/subsys/resource/loader-bench.ts
// tier: B
// verify: node src/subsys/resource/loader-bench.ts
//
// 对比不同并发上限下加载 64 个资源(每个假 IO 3ms)的墙钟耗时与吞吐。
import { AssetLoader } from './async-loader.ts';
import type { LoadRequest } from './async-loader.ts';
import { assetGuid } from './resource-model.ts';
const COUNT = 64;
const IO_MS = 10;
const requests: LoadRequest[] = Array.from({ length: COUNT }, (_, i) => ({
guid: assetGuid(`assets/chunk/tile_${i}.bin`),
path: `assets/chunk/tile_${i}.bin`,
bytes: 4096,
priority: 0,
}));
const io = async (): Promise<Uint8Array> => {
await new Promise<void>((r) => setTimeout(r, IO_MS));
return new Uint8Array(4096);
};
for (const concurrency of [1, 2, 4, 8]) {
const loader = new AssetLoader(io, concurrency);
loader.enqueue(requests);
const t0 = performance.now();
const out = await loader.run();
const ms = performance.now() - t0;
const mbps = (out.length * 4096) / 1024 / 1024 / (ms / 1000);
console.log(
`并发=${String(concurrency).padStart(2)} 耗时=${ms.toFixed(1).padStart(6)}ms 吞吐=${mbps.toFixed(2)} MiB/s`,
);
}
本机实跑输出(node src/subsys/resource/loader-bench.ts,连续三次结果几乎一致,取一次):
并发= 1 耗时= 787.8ms 吞吐=0.32 MiB/s
并发= 2 耗时= 389.3ms 吞吐=0.64 MiB/s
并发= 4 耗时= 190.8ms 吞吐=1.31 MiB/s
并发= 8 耗时= 94.6ms 吞吐=2.64 MiB/s
数字几乎是理想的线性缩放:并发 1 时 64 个 10ms 的 IO 串行,约 640ms(实测 787.8ms,多出的部分是调度与 setTimeout 的固定开销);并发 2 时砍半,并发 4 再砍半,并发 8 时已经降到 94.6ms。这说明调度逻辑没有引入额外的串行瓶颈——耗时完全由「IO 总时长 ÷ 并发数」决定,直到 IO 不再是瓶颈为止。
需要注意的是,这个模型里 IO 是「纯等待」;真实网络 IO 还会受带宽上限约束,并发超过某个点后吞吐不再上涨(带宽打满),甚至因为连接数过多而下降。所以并发上限不是越大越好,实践中会配合「同源连接数上限」和一个略保守的默认值(本卷用 4)。
测试:四组行为锁住调度
L1 代码必须真跑。async-loader.test.ts 用可控的假 IO 覆盖四组行为:并发 1 比并发 4 慢(证明真的有并发)、并发 1 下高优先级先出队、同一 GUID 重复入队只加载一次、进度按字节单调递增且最终为 1。下面是后两组的片段:
// snippet: 去重与进度,摘自 src/subsys/resource/async-loader.test.ts
// 同一 guid 重复入队只加载一次,保留高优先级
loader.enqueue([req('dup.png', 1)]);
loader.enqueue([req('dup.png', 9)]);
expect(loader.pendingCount).toBe(1);
const res = await loader.run();
expect(res).toHaveLength(1);
// 进度按字节单调递增,最终 ratio 为 1
loader.enqueue([req('a.png', 0, 100), req('b.png', 0, 300), req('c.png', 0, 600)]);
await loader.run();
expect(snaps[0]!.ratio).toBe(0);
expect(snaps.at(-1)!.ratio).toBe(1);
expect(snaps.at(-1)!.bytesDone).toBe(1000);
这些用例在本机 npx vitest run 全绿。AssetLoader 只依赖注入的 fetchBytes,因此它既是 1.1 依赖图的消费者,也会被第 2 章的流式加载复用——届时 fetchBytes 换成按 chunk 分片的网络请求,调度逻辑一行不改。
取消、节流与抢占:把加载器接到真实 UI 上
前面的加载器解决了「怎么把资源拿进来」,但一旦它和真实游戏循环接上,还会冒出三个问题。
取消。 玩家进入关卡时预加载了整张地图,两秒后点了返回。那批请求还在飞,如果放任不管,它们会继续占带宽、继续往进度条上写数字,甚至把已经销毁的场景资源加载回来。CancelToken 让整批加载可被整体叫停:每个请求开始前检查一次令牌,已取消就中断整条队列。要注意 IO 本身无法真正中断——已经在飞的 fetch 会跑完,令牌的作用是让它的结果被丢弃,不写进缓存、不触发回调。
进度节流。 加载 5000 个小资源时,如果每完成一个就调一次进度回调,前端的 setState 会被打爆,主线程全花在渲染进度条上,反而拖慢加载。ProgressCoalescer 把高频进度合并成「每 16ms 最多一次」(正好一帧),但保证 flush 时末值一定送达——否则进度条会永远停在 0.97。
抢占。 这才是 1.2 开头的优先级真正发力的地方:玩家点开背包时,几百字节的图标应该立刻插到正在下载的 8MB 地形纹理前面。优先级队列让「小但急」的请求抢在「大但不急」的前面,代价是后者可能被反复延后,所以优先级档位不宜过多——预加载 / 常规 / 交互三档就够了。
下面的片段是 ProgressCoalescer 的核心(完整的 CancelToken 在同一文件里,此处略去):
// snippet: ProgressCoalescer 核心,完整文件见 src/subsys/resource/load-control.ts
export class ProgressCoalescer<T> {
#intervalMs: number;
#emit: (v: T) => void;
#lastEmit = Number.NEGATIVE_INFINITY;
#pending: T | null = null;
#emits = 0;
constructor(intervalMs: number, emit: (v: T) => void) {
if (!(intervalMs >= 0)) throw new RangeError(`intervalMs 必须 >= 0,收到 ${intervalMs}`);
this.#intervalMs = intervalMs;
this.#emit = emit;
}
get emits(): number {
return this.#emits;
}
push(v: T, nowMs = performance.now()): void {
if (nowMs - this.#lastEmit >= this.#intervalMs) {
this.#emit(v);
this.#lastEmit = nowMs;
this.#pending = null;
this.#emits++;
} else {
this.#pending = v;
}
}
flush(nowMs = performance.now()): void {
if (this.#pending !== null) {
this.#emit(this.#pending);
this.#lastEmit = nowMs;
this.#pending = null;
this.#emits++;
}
}
}
用一段 demo 量化合并率并演示取消,本机实跑输出(连续三次一致):
进度事件 1000 次 → 实际发射 64 次(合并率 15.6x)
串行提交 8 个请求(每个 5ms),12ms 时取消 → 完成 3 个:[1,2,3]
1000 次进度事件被合并成 64 次发射,正好约等于 1000ms / 16ms——每帧一次,不多不少。取消那个例子更直观:8 个请求串行加载,12ms 时取消,前 3 个已完成(保留),第 4 个刚开始就被令牌拦下,剩下 4 个根本没启动。load-control.test.ts 用 4 条用例锁住这些行为:取消前后 throwIfCancelled 的差异、窗口内多次 push 只发一次且 flush 补发末值、窗口外每次 push 都立即发、1000 次密集 push 的发射数远小于 1000。
加载器把资源「拿进来了」,但拿进来之后谁拥有它、什么时候可以扔掉?下一节用引用计数回答资源的一生。
阅读导航:上一节:1.1 资源模型、GUID 与依赖图 · 下一节:1.3 引用计数、生命周期与释放 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。