8.3 多线程与 Worker
8.1 和 8.2 讲的是「跨语言边界」——把能力接到 JS 够不着的地方。这一节解决另一个方向的瓶颈:能力都在 JS 里,但主线程不够用了。
卷二的性能基线定在这里:60 FPS → 每帧 16.6 ms,中低端机 30 FPS → 33.3 ms。第 7 章教你怎么定位是谁吃掉了预算;这一节讲一种结构性的解法——把不需要每帧完成的重计算,挪到另一条线程上去。核心工具是 Worker。
8.3.1 主线程到底在忙什么
先看清敌人。在 Cocos 里,一帧的主线程要串行做完这些事:
| 阶段 | 内容 | 是否可省 |
|---|---|---|
| 脚本逻辑 | 你的 update、动画求值、状态机 | 不能省 |
| 物理步进 | 碰撞检测与求解(见第 5 章) | 不能省 |
| 渲染提交 | 场景遍历、合批、命令编码(见第 2 章) | 不能省 |
| 渲染执行 | 提交给 GPU | 可异步 |
关键点是:这四件事在同一个线程上排队。如果某一帧里你顺手做了一次「关卡加载时算一条 2000 格的寻路」,这 20 ms 的计算就会直接插进这一帧的队列——物理、渲染提交全部往后推,表现为一次明显的卡顿。这不是「平均帧率」的问题,而是**长帧(jank)**问题:60 帧里 59 帧都 5 ms,只有 1 帧 25 ms,玩家照样能感觉到那一下顿挫。
第 7 章的采样能告诉你「有长帧」,但它不告诉你「怎么消灭长帧」。对可并行、非每帧必需的重计算,答案就是 Worker。
8.3.2 什么能挪,什么不能挪
Worker 不是万能药。它能碰的只有纯数据——不能碰 DOM,不能碰引擎对象。这条边界决定了「什么能挪」:
| 活 | 能不能挪 | 原因 |
|---|---|---|
| 网格寻路 / 流场计算 | 能 | 纯数据进、纯数据出 |
| 大数组排序 / 统计 | 能 | 同上 |
| JSON 解析、存档序列化 | 能 | 纯字符串处理 |
| 地图数据预处理 | 能 | 一次性、非每帧 |
逐帧的组件 update | 不能 | 要读写 Node / Component |
| 物理与渲染 | 不能 | 引擎状态在主线程,未开放跨线程访问 |
| 音视频解码 | 视平台 | 需引擎支持,不能自己起 |
一句话原则:能把输入抽象成「数组进、数组出」的,才适合挪。 如果你的任务需要读一个 Node 的位置再写回去,它就不适合——先把数据拷出来是另一回事,那笔拷贝成本要单独算(见 8.3.3)。
8.3.3 Worker 的数据边界:结构化克隆
Worker 与主线程不共享内存。你 postMessage(data) 时,默认行为是结构化克隆(structured clone):把 data 深拷贝一份给另一条线程。这意味着两笔隐藏成本:
- 复制成本:数据越大、结构越深,复制越慢。传一个 5 万条实体的对象数组,可能一次就是十几毫秒——把计算挪出去了,却在拷贝上花了同样多。
- 内存翻倍:同一份数据在两条线程各存一份,峰值内存上涨。
有一类数据例外:可转移对象(Transferable),最典型的是 ArrayBuffer(及其视图 Int32Array / Float64Array 等)。用转移的方式传递时,所有权从发送方移交接收方,零拷贝——不复制,只是换个线程持有。代价是发送方那边这份 buffer 会被「掏空」(byteLength 变 0),不能再读。
这个差异可以用一段与引擎无关、可直接在 Node 里跑的代码量化。structuredClone 是 Node 17+ 的全局函数,语义与 postMessage 的默认克隆一致:
// logic/clone-cost.ts —— 与引擎无关,可直接单测
interface Entity {
id: number;
x: number;
y: number;
hp: number;
}
function buildEntities(n: number): Entity[] {
const out: Entity[] = [];
for (let i = 0; i < n; i += 1) out.push({ id: i, x: i * 0.5, y: i * 0.25, hp: 100 });
return out;
}
function time(label: string, fn: () => void): number {
const t0 = performance.now();
fn();
const ms = performance.now() - t0;
console.log(`${label}: ${ms.toFixed(2)}ms`);
return ms;
}
const N = 50000;
// 方案 A:对象数组,只能结构化克隆(深拷贝)
const entities = buildEntities(N);
time('structuredClone(objects)', () => {
structuredClone(entities);
});
// 方案 B:等量的 TypedArray,可转移(这里用 slice 模拟「复制」上限)
const xs = new Float64Array(N);
const ys = new Float64Array(N);
for (let i = 0; i < N; i += 1) { xs[i] = i * 0.5; ys[i] = i * 0.25; }
time('Float64Array copy', () => {
xs.slice();
ys.slice();
});
跑一下会看到量级差异:对象数组的结构化克隆明显慢于等量 TypedArray 的复制,而真正的 Transferable 转移连复制都不用。 具体数字随机器与数据规模变化,这里不给精确值——你应当在目标机型上复测。结论是方向性的:能用 TypedArray 表示的数据,就不要用对象数组传;能转移的,就不要克隆。
8.3.4 cc.d.ts 里没有 Worker
现在到了最容易踩坑的地方。很多人以为 Cocos 会像导出 Node、sys 那样导出一个 Worker——没有。核对 3.8.8 的 cc.d.ts:搜 Worker,零命中。cc 不导出任何 Worker 类。
那怎么办?用平台自己的 Worker:
- H5 平台:用浏览器原生的
Worker(标准 Web Worker API)。 - 原生平台:能不能用,取决于引擎构建时是否包含了 worker 支持。未包含时,全局
Worker根本不存在。 - 小游戏平台:各平台对 Worker 的支持程度不同,同样必须先检测再使用。
所以第一原则是特性检测,而不是硬写 new Worker(...):
import { sys } from 'cc';
export function supportsWorker(): boolean {
// 标准 Web Worker 的存在性检测(typeof 对未声明的全局是安全的)
const hasCtor = typeof Worker !== 'undefined';
// 原生平台还要求引擎构建包含 worker 模块;这里保守地只在浏览器侧启用
return hasCtor && !sys.isNative;
}
这段代码里 sys.isNative 是 cc.d.ts 里确证存在的成员(见 8.1.4)。注意这里刻意用了 typeof Worker !== 'undefined' 而不是 if (Worker)——对未声明的全局变量,typeof 不会抛异常,而直接引用会抛 ReferenceError。这是特性检测的通用写法,在原生/小游戏这种「全局对象不齐全」的环境里尤其重要。
8.3.5 一个适合并行的分片任务
抽象的讨论不如一个具体任务。选《方块冲刺》里真实会遇到的一个:在网格地图上算一条从起点到终点的路径。这是典型的「数组进、数组出」——正好适合挪进 Worker。
下面是一段纯 TS、可单测的网格 BFS,它不依赖任何引擎对象,输入是一维 Uint8Array 地形、输出是路径索引数组:
// logic/grid-path.ts —— 与引擎无关,可直接单测
// 地形用一维数组表示:0 = 可走,1 = 障碍
export interface GridSpec {
cols: number;
rows: number;
blocked: Uint8Array;
}
export function findPath(spec: GridSpec, start: number, goal: number): number[] {
const { cols, rows, blocked } = spec;
const total = cols * rows;
const prev = new Int32Array(total).fill(-1);
const seen = new Uint8Array(total);
const queue: number[] = [start];
seen[start] = 1;
const dirs = [-1, 1, -cols, cols]; // 左右上下(一维索引的邻居偏移)
while (queue.length > 0) {
const cur = queue.shift() as number;
if (cur === goal) break;
const col = cur % cols;
for (const d of dirs) {
const next = cur + d;
if (next < 0 || next >= total) continue;
if (blocked[next] === 1 || seen[next] === 1) continue;
// 左右移动不能跨行
if ((d === -1 || d === 1) && Math.floor(next / cols) !== Math.floor(cur / cols)) continue;
seen[next] = 1;
prev[next] = cur;
queue.push(next);
}
}
if (seen[goal] !== 1) return [];
const path: number[] = [];
for (let at = goal; at !== -1; at = prev[at]) path.push(at);
return path.reverse();
}
GridSpec 里的 blocked 是 Uint8Array——这正是关键:它可以直接作为可转移对象跨线程传递,零拷贝。start / goal 是数字,路径结果是 Int32Array 或普通数组,体积都很小。这套数据结构天生适合 Worker 边界:大块地形用 TypedArray 转移,小结果用克隆传回。
8.3.6 常驻 worker pool 的最小骨架
每帧新建 Worker 是灾难(下一小节会讲原因),所以真正落地时用的是一个常驻的任务池:启动时建好固定数量的 Worker,任务来了投给空闲的那个,算完再把 Worker 交还。
// assets/scripts/core/worker-pool.ts
export class WorkerPool {
private readonly idle: Worker[] = [];
private readonly busy = new Map<Worker, (r: unknown) => void>();
constructor(size: number, scriptUrl: string) {
for (let i = 0; i < size; i += 1) {
const w = new Worker(scriptUrl);
w.onmessage = (ev: MessageEvent) => {
const done = this.busy.get(w);
if (!done) return;
this.busy.delete(w);
this.idle.push(w);
done(ev.data);
};
this.idle.push(w);
}
}
run(payload: unknown): Promise<unknown> {
const w = this.idle.pop();
if (!w) return Promise.reject(new Error('worker pool exhausted'));
return new Promise((resolve) => {
this.busy.set(w, resolve);
w.postMessage(payload);
});
}
}
池的大小不是越大越好:每个 Worker 都有自己的 JS 运行时与内存,开太多反而抢 CPU 与内存。经验做法是取「硬件并发数减一」,并把它做成可配置项,在真机上按下一小节的清单复测。这段代码用到了全局 Worker——所以它必须在 supportsWorker() 为真的分支里才实例化(见 8.3.4)。
8.3.7 Worker 的坑与限制
把 Worker 用起来之后,下面这些坑几乎都会遇到:
- 启动开销。 创建一个 Worker 不是免费的——要加载并编译脚本、初始化运行时,首次启动可能几十毫秒。所以不要每帧建一个 Worker;正确做法是常驻一个(或一小组)Worker,反复投递任务(worker pool 模式)。
- 消息序列化有上限。 不能传函数、不能传类实例(会退化成普通对象,丢失原型)、不能传引擎对象。传
Node进去,得到的是一具「壳」,方法全丢。 - 内存翻倍。 克隆传递时两条线程各存一份;配合常驻 Worker,要把它计入内存预算(回到第 7 章)。
- SharedArrayBuffer 不是默认可用。 真正共享内存要靠
SharedArrayBuffer,但它要求页面处于「跨源隔离」状态(需要特定的 HTTP 响应头)。H5 上能否用,取决于你的部署与托管配置;小游戏/原生上支持情况各异。不要把它当作默认可用的能力。 - 调试更难。 Worker 里的错误栈、日志、断点都和主线程分离,排查成本更高。
- 不是所有平台都有。 回到 8.3.4——原生与小游戏上可能根本没有
Worker。所以代码必须能在「没有 Worker」时退化成主线程版本,而不是直接崩。
最后一条尤其重要,它决定了架构:
// assets/scripts/core/task-runner.ts
export interface TaskRunner {
run<TIn, TOut>(task: (input: TIn) => TOut, input: TIn): Promise<TOut>;
}
// 主线程兜底实现:同步执行,接口一致
export class InlineRunner implements TaskRunner {
run<TIn, TOut>(task: (input: TIn) => TOut, input: TIn): Promise<TOut> {
return Promise.resolve(task(input));
}
}
业务代码只依赖 TaskRunner 接口,有 Worker 就用 WorkerRunner,没有就退回 InlineRunner。这样降级是自动的,而不是在某台机器上白屏。
8.3.8 工程决策:什么时候该上 Worker
收尾回到判断。Worker 能消灭长帧,但它把「一次同步调用」变成「异步消息 + 数据搬运 + 生命周期管理」,复杂度是实打实的。所以只在下面几条同时成立时才上:
- 有一个可量化的重计算(第 7 章的采样能证明它是长帧主因,而不是猜的);
- 这个计算能抽象成数组进、数组出,且输入可以用 TypedArray 表达;
- 它是低频的(关卡加载、地图预处理、批量结算),不是每帧的;
- 你已经准备好了没有 Worker 时的降级路径。
反过来,如果瓶颈是渲染提交或物理步进——那些在主线程上无法搬走的阶段——Worker 帮不上忙,该做的是减少工作量(合批、剔除、降频、对象池),而不是换线程。先把单线程做对,再谈并行。 一条被 Worker 掩盖了算法问题的代码,只是把 20 ms 的卡顿变成了 20 ms 的延迟,还多付了一份拷贝与调试成本。
第 8 章到此结束:8.1 讲清了跨语言边界,8.2 讲清了怎么把三方能力接进来,8.3 讲清了怎么把重活挪出主线程。这三节合起来,是「当 TypeScript 和引擎的默认路径都不够用时」的三条出路。下一节进入第 9 章——如果连引擎本身的行为都要改,就得自己拉源码、编译一份引擎。9.1 从「拉取与编译引擎源码」开始。
阅读导航:上一节:8.2 原生插件与三方 SDK 接入 · 下一节:9.1 拉取与编译引擎源码 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。