《游戏引擎实现原理》14.1 为什么 JS 也需要 Job System

前 13 章的所有更新都挤在一个核上跑。本节先讲清楚为什么 JS 也需要一套 Job System:Node 的 worker_threads 与浏览器 Worker 差在哪、SharedArrayBuffer 与 Atomics 在 Node 里怎么用、在浏览器里为什么必须加 COOP/COEP 跨源隔离头,并用一段 CPU 密集任务实测主线程串行与 4 worker 并行的差距。

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.compareExchangeCAS:值没变才写入(锁、无锁队列)
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 同步 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径