14.1 为什么 JS 也需要 Job System
前 13 章写的所有代码,都跑在一个线程上。一帧里成百上千个实体的位置更新、动画采样、碰撞检测,全部排着队从一个核里挤过去。现代机器动辄 8 核、10 核,剩下的 9 个核在游戏运行时基本闲着。这一章要做的,就是把「多核跑满」这件事在 JS 里真正搭出来。
《微光岛》的岛屿是程序化生成的:一块 256×256 的地形网格,每帧要对上千个地块重算高度与法线。第 2 章算过,60 fps 下一帧的预算是 16.7 ms,而这段地形更新在单线程里就要吃掉 8~10 ms——留给渲染、物理、音频的只剩一半。10 个核里只用 1 个,这不是算法问题,是执行模型问题。
难点在于:JS 不是 Go。Go 里你写下 go f(),运行时自动把 goroutine 调度到不同的 P 上,work stealing 是运行时内部的事,你什么都不用管。JS 里没有这种东西——async / await 只是单线程上的协作式切换,setTimeout 换不来第二个核。要真正并行,只有一条路:worker_threads。
本章与站内既有内容的分工
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/posts/golang/ | Go 运行时的 goroutine 调度:G/M/P 三件套、work stealing、抢占,全由运行时自动完成 | 在 JS 里手搭同一件事:worker_threads 起线程、SharedArrayBuffer 共享内存、Atomics 同步,调度逻辑全部自己写 |
/game-engine-architecture-ecs/ | 一篇文章讲清 ECS 引擎骨架 | 本节不碰 ECS,只搭「把计算分到多个核」的底座;系统之间怎么并行调度留到第 15 章 |
/defold-ecs-architecture/ | 轻量引擎的子系统视角 | 不讲子系统分层,只讲线程与任务这一层 |
一句话概括分工:Go 那套讲的是「运行时替你调度」,本节讲的是「没有运行时时你怎么自己调度」。 从 14.2 起,我们会从零写出一个线程池、一个共享任务队列、一个 work stealing 双端队列——这些在 Go 里都是 runtime 包内部几十行你永远看不见的代码。
本章三节的分工是这样切的:14.1(本节)讲清「为什么」与「有什么原语可用」,并把串行与并行的差距先量出来;14.2 搭一个能跑的最小线程池,用共享环形队列把任务分下去;14.3 把静态分片换成 work stealing,用真实的不均衡负载量出窃取的收益。到第 15 章,这套线程池会接上依赖 DAG,成为每帧调度 ECS 系统的执行底座。
三种「并发」不是一回事
JS 里能写出「看起来并发」的代码有好几种,但它们解决的完全不是同一个问题:
| 机制 | 底层 | 能不能用第二个核 | 适合什么 |
|---|---|---|---|
setTimeout / setInterval | 同一个事件循环 | 不能 | 延后执行、定时轮询 |
async / await / Promise | 同一个事件循环 | 不能 | 等 I/O 时让出控制权 |
worker_threads / Worker | 另起一个 OS 线程 | 能 | CPU 密集计算 |
前两种是并发(concurrency):任务在时间上交错,但任何时刻只有一个在跑。第三种才是并行(parallelism):真的有两个核在同时算。游戏里要的是后者——地形采样、粒子积分这些活儿没有 I/O 可以等,把它们塞进 async 只是换了个写法,总时间一点没少。
这也是为什么 async 满天飞的代码,一旦遇到真正的 CPU 密集任务还是会卡住主线程:await 让出的只是「等 I/O」的那段时间,纯计算的那段谁都替不了它。
主线程之外:worker_threads 与浏览器 Worker
Node 的 worker_threads 和浏览器的 Worker 是同一层概念——都在主线程之外再起一个真正跑在另一个 OS 线程上的 JS 执行环境——但接口和约束差别不小:
| 维度 | Node worker_threads | 浏览器 Worker |
|---|---|---|
| 创建 | new Worker(文件路径, { workerData }) | new Worker(脚本 URL) |
| 初始数据 | workerData 直接传(结构化克隆) | 只能 postMessage 传 |
| 通信 | parentPort.postMessage / parentPort.on('message') | self.postMessage / self.onmessage |
| 共享内存 | SharedArrayBuffer 开箱即用 | 需要 COOP/COEP 跨源隔离头 |
| 主线程阻塞 | 允许 Atomics.wait(本机实测返回 not-equal) | 主线程禁止 Atomics.wait,只能用 Atomics.waitAsync |
关键差异在最后两行。浏览器出于 Spectre 一类的侧信道顾虑,默认关掉了 SharedArrayBuffer,要打开必须给页面加两个响应头:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
这就是所谓的「跨源隔离」(cross-origin isolation)。Node 没有这个限制,SharedArrayBuffer 开箱即用——这也是本卷全部并发代码能在命令行里跑通的原因。浏览器部分本节无法命令行复现,需要你在带上这两个响应头的页面里手动验证。
浏览器侧的写法与 Node 不同,值得对照着看一眼(L4:需在带隔离头的页面里手动验证):
// snippet: 浏览器 Worker 的写法与 Node 不同
// 主线程
const worker = new Worker('/workers/terrain.js'); // 没有 workerData
worker.postMessage({ start: 0, end: 128 }); // 初始参数只能靠消息传
worker.onmessage = (e) => console.log(e.data);
// worker 内(terrain.js)
self.onmessage = (e) => {
const { start, end } = e.data;
self.postMessage(compute(start, end));
};
注意浏览器里主线程不能用 Atomics.wait 阻塞——主线程一旦阻塞,页面就彻底冻住。浏览器提供了 Atomics.waitAsync,返回一个 Promise,只能在主线程用异步形式等待。Node 里没有这个顾虑,主线程直接 Atomics.wait 即可(本机实测返回 not-equal,未阻塞)。
共享内存:SharedArrayBuffer 与 Atomics
worker_threads 默认的通信方式是 postMessage:发出去的对象会被结构化克隆(structured clone)——深拷贝一份,两边各拿各的。传一个 100 万元素的 Float32Array,就是老老实实复制 4 MB。想避免拷贝,就得让两个线程指向同一块内存,这就是 SharedArrayBuffer。
共享内存一旦有了,就必然有竞争。JS 用 Atomics 提供原子操作,本机实测全部可用:
| 原语 | 作用 |
|---|---|
Atomics.add / Atomics.sub | 原子加减,返回旧值(计数、取号) |
Atomics.load / Atomics.store | 原子读写(发布 / 观察一个字段) |
Atomics.compareExchange | CAS:值没变才写入(锁、无锁队列) |
Atomics.wait / Atomics.notify | 阻塞等待与唤醒(空闲 worker 挂起) |
Atomics 只作用于 Int32Array / BigInt64Array 这类整数视图。游戏里常见的 Float32Array 位置数据也能放进 SharedArrayBuffer,但对它做 Atomics.load 是不允许的——要原子地更新浮点,得靠 CAS 绕,或者干脆约定「谁写哪一段」来避免竞争。这条约束会在 15.2 的任务图里反复出现。
用一个最小的例子感受一下共享内存:下面这段代码建了一块 4 字节的 SharedArrayBuffer,主线程 Atomics.add 一次,读回来就是共享后的值。把它换成普通对象传给 worker,两边就各拿各的副本了——差别只在这一个类型上。
// snippet: 共享内存的最小例子(本机实测 SAB 初值 0,add 后 8)
const sab = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
const counter = new Int32Array(sab);
Atomics.add(counter, 0, 8);
console.log(counter[0]); // 8——同一块内存,任何线程都看得见
可运行产物:并发能力探针
先写一个最小的计算 worker:它接收 [start, end),对每个整数做 8 轮 xorshift 哈希链并累加,纯整数运算、零分配,用来把「CPU 时间」和「调度开销」分开观察。这是 src/core/compute-worker.mjs,层级 L1。
// file: src/core/compute-worker.mjs
// tier: L1
// verify: node src/core/thread-probe.ts
//
// 计算 worker:对 [start, end) 的每个整数做 8 轮 xorshift 哈希链并累加。
// 纯整数运算、无分配,用来把「CPU 时间」和「调度开销」分开观察。
import { parentPort, workerData } from 'node:worker_threads';
const { start, end } = workerData;
let acc = 0 >>> 0;
for (let i = start; i < end; i++) {
let x = i >>> 0;
for (let k = 0; k < 8; k++) {
x ^= x << 13; x >>>= 0;
x ^= x >>> 17;
x ^= x << 5; x >>>= 0;
}
acc = (acc + x) >>> 0;
}
parentPort.postMessage(acc);
thread-probe.ts 里 import { Worker } from 'node:worker_threads' 需要一个类型声明。本工程没装 @types/node(tsconfig 的 types 只列了 @webgpu/types),沿用 3.2 的做法补一个只声明 worker_threads 的垫片 src/core/thread-shims.d.ts(层级 L3,仅类型):
// file: src/core/thread-shims.d.ts
// tier: L3
//
// 本工程未安装 @types/node,这里只声明 JobSystem 用到的 worker_threads API。
// 真实工程 `npm i -D @types/node` 后本文件可删。
declare module 'node:worker_threads' {
export interface WorkerOptions {
workerData?: unknown;
}
export class Worker {
constructor(filename: string | URL, options?: WorkerOptions);
on(event: 'message', listener: (value: unknown) => void): this;
on(event: 'error', listener: (err: Error) => void): this;
on(event: 'exit', listener: (code: number) => void): this;
postMessage(value: unknown): void;
terminate(): Promise<number>;
}
}
主线程起 4 个这样的 worker,把 [0, 40000000) 切成 4 段并行跑,再和主线程串行跑同一区间对比。这是 src/core/thread-probe.ts:
// file: src/core/thread-probe.ts
// tier: L1
// verify: node src/core/thread-probe.ts
//
// helio 内核:并发能力探针。确认 Node 里 worker_threads / SharedArrayBuffer /
// Atomics 三件套是否可用,并用一个 CPU 密集任务对比「主线程串行」与
// 「4 个 worker 并行」的墙钟时间。
import { Worker } from 'node:worker_threads';
import { availableParallelism } from 'node:os';
import { performance } from 'node:perf_hooks';
// 1) 能力探针
console.log('worker_threads :', typeof Worker);
console.log('SharedArrayBuffer :', typeof SharedArrayBuffer);
console.log('Atomics.wait/notify :', typeof Atomics.wait, typeof Atomics.notify);
console.log('Atomics.add/CAS :', typeof Atomics.add, typeof Atomics.compareExchange);
console.log('availableParallelism :', availableParallelism());
// 2) 共享内存:跨线程自增一个计数器
const N = 8;
const sab = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
const counter = new Int32Array(sab);
console.log('SAB 初值 :', counter[0]);
Atomics.add(counter, 0, N);
console.log('Atomics.add 之后 :', counter[0]);
// 3) CPU 密集任务:串行 vs 4 worker 并行
const TOTAL = 40_000_000;
const WORKERS = 4;
function hashChain(start: number, end: number): number {
let acc = 0 >>> 0;
for (let i = start; i < end; i++) {
let x = i >>> 0;
for (let k = 0; k < 8; k++) {
x ^= x << 13; x >>>= 0;
x ^= x >>> 17;
x ^= x << 5; x >>>= 0;
}
acc = (acc + x) >>> 0;
}
return acc;
}
function runWorker(start: number, end: number): Promise<number> {
return new Promise((resolve, reject) => {
const w = new Worker(new URL('./compute-worker.mjs', import.meta.url), {
workerData: { start, end },
});
w.on('message', (m) => { void w.terminate(); resolve(m as number); });
w.on('error', reject);
});
}
const t0 = performance.now();
const serial = hashChain(0, TOTAL);
const serialMs = performance.now() - t0;
const t1 = performance.now();
const chunk = TOTAL / WORKERS;
const parts = await Promise.all(
Array.from({ length: WORKERS }, (_, k) =>
runWorker(Math.floor(k * chunk), Math.floor((k + 1) * chunk)),
),
);
const parallelMs = performance.now() - t1;
const parallelSum = parts.reduce((a, b) => (a + b) >>> 0, 0);
console.log('serial :', serialMs.toFixed(1), 'ms sum', serial);
console.log('parallel :', parallelMs.toFixed(1), 'ms sum', parallelSum);
console.log('speedup :', (serialMs / parallelMs).toFixed(2), 'x');
本机实测(Node 26.5.0,10 逻辑核):
worker_threads : function
SharedArrayBuffer : function
Atomics.wait/notify : function function
Atomics.add/CAS : function function
availableParallelism : 10
SAB 初值 : 0
Atomics.add 之后 : 8
serial : 289.9 ms sum 4274967296
parallel : 107.9 ms sum 4274967296
speedup : 2.69 x
两个和的低 32 位完全一致,说明并行没有算错。串行 290 ms、4 worker 并行 108 ms,约 2.7 倍——没到 4 倍,是因为 worker 的冷启动(本机单测约 22 ms)和结果回传都算在并行时间里,而且这段计算本身是纯 CPU、没有内存带宽瓶颈。数字的意义在方向:同一段代码,多核确实能吃下大头。
代价:不是所有负载都适合并行
把代码拆到多个线程不是免费的,三笔账要提前算清楚:
- 数据要么克隆,要么共享。
postMessage传普通对象走结构化克隆,传多大的对象就复制多大;只有SharedArrayBuffer是零拷贝,但随之而来的是竞争与Atomics的复杂度。 postMessage有序列化开销。 即便走结构化克隆,把一个 100 万点的Float32Array发过去也要复制 4 MB,一来一回就是 8 MB 的内存流量——这往往比你想省下的那点计算还贵。- 任务太碎时,调度开销大于收益。 这一点在 14.2 会用真实数字演示:把每个任务缩到几百微秒,4 个 worker 反而比单线程慢近 10 倍,因为抢锁、唤醒、回传的固定开销压过了并行省下的时间。
一条经验:并行只对「单个任务足够粗、且任务之间几乎没有共享写」的负载有效。 游戏里符合这个描述的典型是位置积分、粒子更新、程序化地形的分块生成、动画曲线的批量采样;不符合的是几百个实体的碰撞两两配对(共享写太多)、每帧只更新几个对象的逻辑(太碎)。
判断一个负载该不该并行,可以先过一遍这张清单:
- 单个任务的计算时间是不是远大于一次
postMessage与一次线程唤醒的固定开销?太碎就别拆。 - 任务之间要不要写同一块内存?要写就得靠
Atomics或分片约定,复杂度立刻上一个台阶。 - 结果是不是可以「先各算各的、最后合并」?可以合并的(求和、求最大值、分块写入)最好并行;必须严格串行依赖的只能排队。
- 数据是复制过去还是共享过去?大块只读数据用
SharedArrayBuffer零拷贝,小块一次性数据用postMessage更省心。
这四条里只要有一条踩雷,并行的收益就可能被抵消甚至变成负数。第 14.2 节的实测会给出一组反例:把任务切到几百微秒一粒,4 个 worker 比单线程还慢——因为上面第一条不满足。
还有一个容易被忽略的事实:postMessage 本身是异步的。发出去的消息要排进对方的事件循环,不会立即执行;想同步拿到结果,就得自己在主线程上等一个 Promise,而这个等待期间主线程是自由的——这既是好处(主线程可以继续渲染),也是坏处(你没地方插一个「阻塞式 join」)。14.2 的线程池会用 Atomics.wait 把「等所有任务跑完」这件事做得比轮询 postMessage 干净得多。
数字是怎么测的
本节的数字都来自本机实测(Node 26.5.0,10 逻辑核),测量方法固定:
- 计时用
node:perf_hooks的performance.now(),单位毫秒,取 3 次运行的中位数。 - 并行前先让所有 worker 就绪,避免把冷启动算进「并行时间」——但 14.1 的探针故意没有加这道闸门,所以它算出来的 2.7 倍里已经包含了冷启动,是更保守的数。
- 串行与并行跑的是同一段代码、同一批输入,两个结果的低 32 位必须相等,否则说明并行算错了。
请记住卷首的诚实声明:这些是同机相对量级,绝对数值不可移植。V8 的 JIT、GC 时机、核数都会让数字浮动,重点是方向——多核能吃下大头,但吃不下全部。
14.2 就把这套东西真正搭起来:一个固定大小的线程池、一个所有 worker 共享的任务队列,以及用 Atomics 写出来的同步原语。
阅读导航:上一节:目录 · 下一节:14.2 线程池、任务队列与 Atomics 同步 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。