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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。