《游戏引擎实现原理》13.3 系统依赖声明与并行调度

每个系统声明读哪些、写哪些数据槽,两个系统存在写-读或写-写重叠即冲突。用最长路径分层把系统排进互不冲突的组:组内可并行、组间同步。本节给出 helio 内核可编译的调度器,跑出《微光岛》十个系统的真实分组结果,并对照粗粒度与细粒度数据槽对并行度的影响。

13.3 系统依赖声明与并行调度

13.2 用「遍历顺序」解决了单棵树内部的先后问题。到了系统这一层,问题变成了「几十个系统谁先谁后、哪些能同时跑」。手写执行顺序会随着系统增多迅速失控,而且一旦顺序写错,多线程一开就是数据竞争。这一节的做法是:不写顺序,只声明读写依赖,让调度器自己排出「哪些系统可以并行」。

声明读写依赖,重叠即冲突

一个系统不用说自己排第几,只需要说清楚它碰哪些数据:

  • reads:只读的数据槽。多个系统同时读同一个槽没问题。
  • writes:会写的数据槽。写与任何人的读或写都不能同时发生。

「数据槽」是抽象的命名空间,可以是组件类型名、全局资源名,或任何你愿意切成的最小单位。两条规则就够判定冲突:

系统 A系统 B是否冲突原因
写 transform读 transform冲突写-读,B 可能读到半成品
写 transform写 transform冲突写-写,结果取决于顺序
读 transform读 transform不冲突只读共享
写 particle写 audio不冲突互不相干

冲突的判定是对称的:A 与 B 冲突,当且仅当 B 与 A 冲突。于是可以把系统看成一个图,边是冲突关系,目标是把图染成尽量少的「层」,每层内部没有边——层内可并行,层间必须同步。这就是一个图着色 / 分层问题。

最长路径分层:既分组又保序

一个直觉做法是「首次适配」:按声明顺序,把每个系统塞进第一个与它不冲突的组。它简单,但会出错。考虑 a 写 t、b 读 t 写 u、c 读 u 写 v 这条链:c 与 a 没有槽重叠,首次适配会把 c 塞回 a 所在的组,于是 c 排在 b 前面——c 读到的 u 还是上一帧的。冲突图只告诉我们「谁不能和谁同层」,却丢了「谁必须在谁之后」的方向。

正确的做法是最长路径分层:每个系统的组号 = 所有「与它冲突、或它显式声明要后置」的已处理系统的组号 + 1 的最大值。因为按声明顺序处理(声明顺序即逻辑执行顺序),一条数据流链 a→b→c 自然得到 0、1、2 三层。可以证明:同层的系统必不冲突——若两者冲突且一个声明在前,后者的组号必然 ≥ 前者 + 1,不可能同层。

系统还可以用可选的 after 字段声明「无槽冲突但必须后置」的顺序(比如 UI 要最后画)。下面就是完整实现,层级 L1,Node 直跑。

// file: src/core/system-scheduler.ts
// tier: L1
// verify: npx vitest run src/core/system-scheduler.test.ts
//
// 系统依赖声明与并行分组。每个系统声明读哪些、写哪些「数据槽」,
// 另可用 after 声明「必须在某系统之后」的显式顺序依赖。
// 两个系统存在「写-读」或「写-写」重叠即冲突,不能同组。
// 分组用最长路径分层:组号 = 与之冲突/需后置的系统的最大组号 + 1。

export interface SystemDecl {
  name: string;
  reads: readonly string[];
  writes: readonly string[];
  /** 显式顺序依赖:这些系统必须先于本系统(须声明在更前面)。 */
  after?: readonly string[];
}

export interface SchedulePlan {
  /** 组内系统可并行,组间必须按顺序(一次同步屏障)。 */
  groups: SystemDecl[][];
  /** 每个系统被分到的组号。 */
  level: Map<string, number>;
  /** 冲突边总数(无向图,每个冲突对记一次)。 */
  conflictEdges: number;
}

function overlaps(a: readonly string[], b: readonly string[]): boolean {
  for (const x of a) if (b.includes(x)) return true;
  return false;
}

/** 两个系统是否冲突:一方写、另一方读或写同一数据槽。 */
export function conflicts(a: SystemDecl, b: SystemDecl): boolean {
  return (
    overlaps(a.writes, b.reads) ||
    overlaps(a.writes, b.writes) ||
    overlaps(b.writes, a.reads)
  );
}

/**
 * 最长路径分层:按声明顺序,每个系统的组号 = 所有「与它冲突或它要后置」
 * 的已处理系统的组号 + 1 的最大值。这样同组内必不冲突,且顺序依赖被满足。
 */
export function planSchedule(systems: readonly SystemDecl[]): SchedulePlan {
  const level = new Map<string, number>();
  const groups: SystemDecl[][] = [];
  for (let i = 0; i < systems.length; i++) {
    const s = systems[i]!;
    let lv = 0;
    for (let j = 0; j < i; j++) {
      const o = systems[j]!;
      const mustAfter = s.after?.includes(o.name) ?? false;
      if (mustAfter || conflicts(s, o)) {
        lv = Math.max(lv, (level.get(o.name) ?? 0) + 1);
      }
    }
    level.set(s.name, lv);
    (groups[lv] ??= []).push(s);
  }
  let edges = 0;
  for (let i = 0; i < systems.length; i++) {
    for (let j = i + 1; j < systems.length; j++) {
      if (conflicts(systems[i]!, systems[j]!)) edges++;
    }
  }
  return { groups, level, conflictEdges: edges };
}

/** 校验:任一组的内部都不存在冲突。 */
export function validatePlan(plan: SchedulePlan): boolean {
  for (const g of plan.groups) {
    for (let i = 0; i < g.length; i++) {
      for (let j = i + 1; j < g.length; j++) {
        if (conflicts(g[i]!, g[j]!)) return false;
      }
    }
  }
  return true;
}

/** 把分组渲染成可读文本,一行一组。 */
export function formatPlan(plan: SchedulePlan): string {
  return plan.groups
    .map((g, i) => `G${i}: ${g.map((s) => s.name).join(', ')}`)
    .join('\n');
}

groups[lv] 就是第 lv 层。执行时按 lv 从 0 递增,每一层内的系统可以并行派发到工作线程,层与层之间插一道同步屏障——这正是第 14、15 章 Job System 与任务图的输入。

把分层和「全串行」对照一下就更清楚了:全串行时,十个系统十个屏障,帧内可并行的机会是 0;分层后屏障数等于层数,每层内的系统数就是这一刻能同时干活的工作线程数。所以层数是同步开销,层宽是并行收益,调度器要做的就是在两者之间找平衡。planSchedule 是最长路径分层,它给出的是「满足依赖前提下的最小层数」——因为任何一条冲突链的长度都下界了层数,而它恰好取到这个下界。

可运行产物:跑出并行分组

用《微光岛》的十个系统声明读写依赖,跑两次:一次用粗粒度槽(所有变换统一叫 transform),一次用细粒度槽(把 transform 拆成 position / rotation / velocity)。

// file: bench/schedule-demo.ts
// tier: L1
// verify: node bench/schedule-demo.ts
//
// 用《微光岛》的系统声明读写依赖,跑出并行分组。
// 对比「粗粒度槽(transform 整体)」与「细粒度槽(position/rotation 分开)」。

import { formatPlan, planSchedule, validatePlan, type SystemDecl } from '../src/core/system-scheduler.ts';

// 粗粒度:所有变换统一叫 transform,凡是碰变换的系统都互相冲突。
const coarse: SystemDecl[] = [
  { name: 'input', reads: ['device'], writes: ['intent'] },
  { name: 'movement', reads: ['transform', 'intent'], writes: ['transform', 'velocity'] },
  { name: 'physics', reads: ['transform', 'velocity'], writes: ['transform', 'velocity'] },
  { name: 'collision', reads: ['transform'], writes: ['contacts'] },
  { name: 'animation', reads: ['animation'], writes: ['transform'] },
  { name: 'particles', reads: ['particle'], writes: ['particle'] },
  { name: 'audio', reads: ['audio'], writes: ['audio'] },
  { name: 'culling', reads: ['transform'], writes: ['visible'] },
  { name: 'render', reads: ['transform', 'visible'], writes: ['drawlist'] },
  { name: 'ui', reads: ['ui'], writes: ['ui'], after: ['render'] },
];

// 细粒度:把 transform 拆成 position / rotation / velocity,冲突面收窄。
const fine: SystemDecl[] = [
  { name: 'input', reads: ['device'], writes: ['intent'] },
  { name: 'movement', reads: ['position', 'intent'], writes: ['position', 'velocity'] },
  { name: 'physics', reads: ['position', 'velocity'], writes: ['position', 'velocity'] },
  { name: 'collision', reads: ['position'], writes: ['contacts'] },
  { name: 'animation', reads: ['animation'], writes: ['rotation'] },
  { name: 'particles', reads: ['particle'], writes: ['particle'] },
  { name: 'audio', reads: ['audio'], writes: ['audio'] },
  { name: 'culling', reads: ['position', 'rotation'], writes: ['visible'] },
  { name: 'render', reads: ['position', 'rotation', 'visible'], writes: ['drawlist'] },
  { name: 'ui', reads: ['ui'], writes: ['ui'], after: ['render'] },
];

function report(tag: string, systems: SystemDecl[]): void {
  const plan = planSchedule(systems);
  const maxParallel = Math.max(...plan.groups.map((g) => g.length));
  console.log(`\n== ${tag} ==`);
  console.log(`systems=${systems.length} groups=${plan.groups.length} conflictEdges=${plan.conflictEdges} maxParallel=${maxParallel}`);
  console.log(formatPlan(plan));
  console.log(`valid=${validatePlan(plan)}`);
}

report('coarse: transform', coarse);
report('fine: position/rotation', fine);

本机(Node 26.5.0)实跑 node bench/schedule-demo.ts,输出:

== coarse: transform ==
systems=10 groups=8 conflictEdges=14 maxParallel=3
G0: input, particles, audio
G1: movement
G2: physics
G3: collision
G4: animation
G5: culling
G6: render
G7: ui
valid=true

== fine: position/rotation ==
systems=10 groups=6 conflictEdges=11 maxParallel=4
G0: input, animation, particles, audio
G1: movement
G2: physics
G3: collision, culling
G4: render
G5: ui
valid=true

两条结论都写在数据里:

第一,声明粒度直接决定并行度。粗粒度把 movement、physics、animation 全串起来(它们都写 transform),排成 8 组、最宽一组 3 个系统;细粒度把 animation 拆出去写 rotation,于是它和 input、particles、audio 挤进同一层,组数降到 6、最宽一组 4 个系统。冲突边从 14 条降到 11 条。想并行,先别把数据槽声明得过粗。

第二,valid=true 不是装饰。它是 validatePlan 逐组两两复查「组内无冲突」的结果——分组算法本身可能写错(比如前面说的首次适配),这条断言是调度正确性的守门人。ui 落在最后一组(G7 / G5),是因为它显式声明了 after: ['render'],即使它和 render 没有槽冲突。

测试:冲突判定与分组

// snippet: src/core/system-scheduler.test.ts(节选)
it('写-读链形成逐级分组(c 必须在 b 之后)', () => {
  const p = planSchedule([
    sys('a', [], ['t']),
    sys('b', ['t'], ['u']),
    sys('c', ['u'], ['v']),
  ]);
  expect(p.groups.length).toBe(3);
  expect(p.level.get('c')).toBe(2);
  expect(p.conflictEdges).toBe(2);
});

it('after 显式顺序:无槽冲突也强制后置', () => {
  const p = planSchedule([
    sys('render', [], ['drawlist']),
    sys('ui', [], ['ui'], ['render']),
  ]);
  expect(p.level.get('ui')).toBe(1);
  expect(p.groups.length).toBe(2);
});

npx vitest run src/core/system-scheduler.test.ts 全部通过(10 个用例)。第一个用例就是首次适配会栽的那个链——最长路径分层给出 0、1、2 三层,把「顺序」真正保住了。

常见坑

  • 数据槽声明过粗。把所有东西都叫 world 或 transform,会让本来无关的系统互相冲突,并行度归零。粒度要贴着真实的数据边界切。
  • 漏声明写。只声明 reads 不声明 writes,冲突检测就漏掉写-写竞争——多线程下表现为随机的数据错乱,极难复现。
  • 用首次适配当调度。它忽略数据流方向,会把读者排到写者前面。要保序就用最长路径分层。
  • after 指向后面声明的系统。本节按声明顺序处理,after 只能引用已声明过的系统;反向引用会被静默忽略。
  • 组数当成帧数。层是「并行机会」,不等于帧数;一层里可以塞很多系统同时跑,真正的瓶颈是最宽那层的系统数(maxParallel)。
  • 以为分组结果恒定。它依赖声明顺序与粒度;改动声明顺序可能改变分组。把 planSchedule 的输出纳入回归测试,别靠记忆。

第 14 章要回答的问题是:这些「可并行的组」交给谁去跑?答案是一个基于 worker_threads 的 Job System——先讲清楚为什么连 JS 也需要它。

阅读导航:上一节:13.2 迭代顺序、变更检测与脏标记 · 下一节:14.1 为什么 JS 也需要 Job System 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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