《游戏引擎实现原理》15.3 与主循环、渲染线程的同步

任务被派发到别的线程之后,帧边界就成了同步点:用 Atomics 屏障让所有线程在每帧末尾对齐,用双缓冲让逻辑线程与渲染线程各写各的、只在帧边界交换一次引用。本节用 worker_threads + Atomics 跑一个最小同步 demo,并标出命令行不可复现的部分。

15.3 与主循环、渲染线程的同步

15.2 把每一帧的任务派发到了工作线程上。一旦有第二个线程在跑,问题就从「算得对不对」变成「什么时候能读」:逻辑线程刚写完的变换矩阵,渲染线程凭什么保证读到的是完整的、而不是写了一半的?这一节处理帧边界上的三件事:屏障(让所有线程在每帧末尾对齐)、双缓冲(让读写不打架)、以及哪些同步只能靠手动观察。

帧边界是一道屏障

最朴素的跨线程约定是:每帧结束,所有参与者都到齐了,才一起进入下一帧。 实现它的是一个计数屏障(barrier):每个线程到达时把共享计数器加一,最后一个到达者把「代次」翻转,唤醒所有在等的线程。

helio 的 Barrier 建在 SharedArrayBuffer 上,用 Atomics 操作两个字:word0 是已到达计数,word1 是代次。src/core/frame-sync.ts 里同时给了屏障和双缓冲两个原语,层级 L1。

// file: src/core/frame-sync.ts
// tier: L1
// verify: npx vitest run src/core/frame-sync.test.ts
//
// helio 内核:跨线程同步原语。
// Barrier 让若干线程在每个帧边界对齐;DoubleBuffer 让逻辑线程与渲染线程
// 各写各的、只在帧边界交换一次引用,避免一帧内读到写了一半的数据。

/** 屏障状态占两个字:word0 = 已到达计数,word1 = 代次。 */
export const BARRIER_WORDS = 2;

/** 分配一块可被多个 worker 共享的屏障缓冲区。 */
export function createBarrierBuffer(): SharedArrayBuffer {
  return new SharedArrayBuffer(BARRIER_WORDS * Int32Array.BYTES_PER_ELEMENT);
}

/**
 * 计数屏障(sense-reversing)。parties 个线程各自调用 await();
 * 最后一个到达者把代次加一,其余线程在 word1 上等待代次变化后一起放行。
 */
export class Barrier {
  readonly #state: Int32Array;
  readonly #parties: number;

  constructor(buffer: SharedArrayBuffer, parties: number) {
    if (parties < 1) throw new RangeError(`parties 必须 >= 1,收到 ${parties}`);
    this.#state = new Int32Array(buffer);
    this.#parties = parties;
  }

  /** 当前代次:已完整通过的轮数。 */
  get generation(): number {
    return Atomics.load(this.#state, 1);
  }

  /** 登记一次到达;返回「我是不是最后一个到的」。不阻塞。 */
  arrive(): boolean {
    const arrived = Atomics.add(this.#state, 0, 1) + 1;
    if (arrived === this.#parties) {
      Atomics.store(this.#state, 0, 0); // 重置计数,供下一轮使用
      Atomics.add(this.#state, 1, 1); // 翻转代次,放行等待者
      Atomics.notify(this.#state, 1); // 唤醒所有等待者
      return true;
    }
    return false;
  }

  /** 阻塞直到本轮所有参与者都到达。 */
  await(): void {
    const gen = Atomics.load(this.#state, 1);
    if (this.arrive()) return;
    // 只在「代次仍等于我读到的值」时阻塞,被唤醒或已翻代则退出。
    while (Atomics.load(this.#state, 1) === gen) {
      Atomics.wait(this.#state, 1, gen);
    }
  }
}

/**
 * 双缓冲。逻辑线程用 write 写 back 槽,渲染线程读 front 槽;
 * 帧边界调用 swap 一次,把写完的 back 发布成 front。
 * 约定:write 必须整体替换槽值,不要就地改已经发布的对象。
 */
export class DoubleBuffer<T> {
  readonly #slots: [T, T];
  #front = 0;
  #back = 1;

  constructor(initial: T) {
    this.#slots = [initial, initial];
  }

  /** 渲染线程读到的、上一帧发布出来的快照。 */
  get front(): T {
    return this.#slots[this.#front];
  }

  /** 逻辑线程正在写的槽(尚未发布)。 */
  get back(): T {
    return this.#slots[this.#back];
  }

  write(value: T): void {
    this.#slots[this.#back] = value;
  }

  /** 帧边界发布:交换 front / back 的角色。 */
  swap(): void {
    const t = this.#front;
    this.#front = this.#back;
    this.#back = t;
  }
}

await() 的阻塞条件写得很讲究:只在「代次仍等于我读到的值」时才 Atomics.wait。 如果在我读到代次之后、调用 wait 之前,最后一个线程已经把代次翻转了,那 Atomics.wait 会因为「值已不等于预期」立刻返回,不会白等一轮。这就是 sense-reversing 屏障不需要额外锁的原因。

双缓冲:逻辑与渲染各写各的

屏障解决「什么时候一起走」,双缓冲解决「读的时候写没写完」。逻辑线程每帧往 back 槽写新的帧状态,写完后 swap() 一次;渲染线程永远只读 front。两个线程碰的是不同的槽,同一帧内不存在「一个人写、另一个人读同一块内存」——数据竞争被结构性地消掉了。

关键约定写在注释里:write 必须整体替换槽值。如果逻辑线程拿到 front 里的对象就地改字段,双缓冲就白做了——渲染线程读的还是同一个对象。所以要么每帧写一个全新对象,要么让 T 是一个明确区分前后帧的快照类型。

可运行产物:worker_threads + Atomics 的最小同步

下面这个 demo 起两个 worker,和主线程一起在同一道屏障上对齐 5 帧。主线程(逻辑线程)每帧把 [frame, frame*7] 写进共享槽再发布,worker 在屏障之后读取,并断言读到的是完整帧(第二字恒等于第一字的 7 倍)。层级 L1,node 直跑。

// file: src/apps/frame-sync-demo.ts
// tier: L1
// verify: node src/apps/frame-sync-demo.ts
//
// 用 worker_threads + Atomics 跑一个最小跨线程同步 demo:
// 主线程是「逻辑线程」,若干 worker 是「消费者」;每帧结束在屏障上对齐,
// 逻辑线程把该帧快照发布到共享槽,消费者在屏障之后读取,永远读到完整帧。
//
// 说明:本工程未装 @types/node,类型由 src/core/thread-shims.d.ts 提供;
// 该 shim 只声明了 Worker,其余运行时成员(isMainThread 等)在这里按需取用。

import { Worker } from 'node:worker_threads';
import * as nodeWorkerThreads from 'node:worker_threads';
import { Barrier, createBarrierBuffer } from '../core/frame-sync.ts';

interface RuntimeMembers {
  isMainThread: boolean;
  parentPort: { postMessage(value: unknown): void } | null;
  workerData: unknown;
}
const { isMainThread, parentPort, workerData } = nodeWorkerThreads as unknown as RuntimeMembers;

const FRAMES = 5;
const WORKERS = 2;
const PARTIES = WORKERS + 1; // 主线程 + 每个 worker 都在同一道屏障上

/** 共享帧槽布局:FRAMES 个槽,每槽两字 [frame, frame*7]。 */
const SLOT_WORDS = 2;

interface SyncPayload {
  barrierBuffer: SharedArrayBuffer;
  stateBuffer: SharedArrayBuffer;
  published: SharedArrayBuffer;
}

function runWorker(payload: SyncPayload): void {
  const barrier = new Barrier(payload.barrierBuffer, PARTIES);
  const state = new Int32Array(payload.stateBuffer);
  const published = new Int32Array(payload.published);
  let consistent = 0;
  for (let f = 0; f < FRAMES; f++) {
    barrier.await(); // 等到逻辑线程发布完本帧
    const pub = Atomics.load(published, 0); // 已发布帧数
    const slot = pub - 1; // 读最近一帧
    const a = Atomics.load(state, slot * SLOT_WORDS);
    const b = Atomics.load(state, slot * SLOT_WORDS + 1);
    if (b === a * 7) consistent++;
  }
  parentPort!.postMessage({ consistent });
}

async function main(): Promise<void> {
  const payload: SyncPayload = {
    barrierBuffer: createBarrierBuffer(),
    stateBuffer: new SharedArrayBuffer(FRAMES * SLOT_WORDS * Int32Array.BYTES_PER_ELEMENT),
    published: new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT),
  };
  const barrier = new Barrier(payload.barrierBuffer, PARTIES);
  const state = new Int32Array(payload.stateBuffer);
  const published = new Int32Array(payload.published);

  const results: Promise<number>[] = [];
  const workers: Worker[] = [];
  for (let i = 0; i < WORKERS; i++) {
    const w = new Worker(new URL(import.meta.url), { workerData: payload });
    workers.push(w);
    results.push(
      new Promise<number>((resolve) => {
        w.on('message', (m) => resolve((m as { consistent: number }).consistent));
      }),
    );
  }

  // 逻辑线程:写 back 槽 -> 发布(published+1)-> 屏障对齐
  for (let f = 0; f < FRAMES; f++) {
    Atomics.store(state, f * SLOT_WORDS, f);
    Atomics.store(state, f * SLOT_WORDS + 1, f * 7);
    Atomics.store(published, 0, f + 1);
    barrier.await();
  }

  const perWorker = await Promise.all(results);
  await Promise.all(workers.map((w) => w.terminate()));
  console.log(`frames=${FRAMES} workers=${WORKERS} parties=${PARTIES}`);
  console.log(`barrier generation=${barrier.generation}(应等于帧数 ${FRAMES})`);
  console.log(`每个 worker 读到的一致帧数=${perWorker.join(', ')}(应为 ${FRAMES})`);
  const ok = barrier.generation === FRAMES && perWorker.every((c) => c === FRAMES);
  console.log(ok ? 'SYNC-OK 所有消费者在每帧边界对齐且读到完整帧' : 'SYNC-FAIL');
}

if (isMainThread) {
  await main();
} else {
  runWorker(workerData as SyncPayload);
}

本机实测:node src/apps/frame-sync-demo.ts 输出

frames=5 workers=2 parties=3
barrier generation=5(应等于帧数 5)
每个 worker 读到的一致帧数=5, 5(应为 5)
SYNC-OK 所有消费者在每帧边界对齐且读到完整帧

屏障的代次恰好等于帧数 5,两个 worker 各读到 5 个完整帧——说明「每帧边界对齐」和「读到的永远是发布完的帧」都成立。Barrier 与 DoubleBuffer 另有 6 个 vitest 用例(npx vitest run src/core/frame-sync.test.ts,全部通过)覆盖到达计数、跨轮复用、以及「未 swap 前 front 不变」的发布语义。

哪些部分只能手动验证(L4)

上面那个 demo 是 L1,命令行可复现。但同步问题里有几块只能在真实环境里手动观察,本节显式标注为 L4:

  • 真实的调度抖动。两个 worker 谁先到屏障、各自等了多少微秒,取决于操作系统调度,命令行里每次都不一样。demo 只断言「结果正确」,不断言「耗时多少」。
  • 真实的渲染线程交接。helio 的渲染在卷二才出现;「逻辑线程写完、渲染线程提交给 GPU」的真实交接点、以及 GPU 侧何时完成,需要真实的图形 API 才能观察。
  • 浏览器上的 Atomics.wait 限制。Atomics.wait 在浏览器主线程上被禁止(会抛错),必须换 Atomics.waitAsync 或把等待移到 worker 里。这是平台差异,只有在真实浏览器里才会撞到。

这几条不写进自动断言,是因为它们不可复现——同一段代码跑两遍,数字就不一样。把它们诚实标出来,比编一个「稳定」的数字更有用。

常见坑

  • 忘了 Atomics.notify。等待者永远醒不过来,整帧卡死。notify 必须在翻转代次之后调用。
  • 把屏障的计数和代次混用同一个字。两者必须分开:计数会被重置,代次只增不减;混用会导致「上一轮的残留计数」把这一轮提前放行。
  • 在浏览器主线程上 Atomics.wait。会直接抛错,得改用 waitAsync。
  • 双缓冲里就地改对象。逻辑线程改了 front 里的对象字段,双缓冲失效——write 必须整体替换。
  • 用普通变量做跨线程标志。没有 Atomics 的读写可能被编译器或 CPU 重排,看到的是旧值。跨线程的每一个共享标量都得走 Atomics。
  • 把 demo 的「结果正确」当成「时序也确定」。同步只保证数据的完整性,不保证谁快谁慢。

到这里,第 15 章的三件事就齐了:15.1 把任务依赖表达成 DAG,15.2 每帧重建图并算出执行序与并行批次,15.3 在帧边界上用屏障与双缓冲把这些并行任务的结果安全地交给下一个阶段。下一步是让引擎能「看见自己」——第 16 章从「引擎为什么需要反射」讲起。

阅读导航:上一节:15.2 任务图构建与帧内调度 · 下一节:16.1 引擎为什么需要反射 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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