15.1 依赖 DAG 与拓扑执行
第 14 章把「任务怎么跑到工作线程上」讲完了:线程池、任务队列、work stealing。但一个真实帧里的任务不是一堆互不相干的独立项——物理必须在动画之前、动画必须在构建渲染列表之前。这一节解决的是这些「先后关系」怎么被表达、怎么被算法排出一个合法执行序。
本章与站内既有内容的分工
| 既有内容 | 它负责什么 | 本节只做什么 |
|---|---|---|
| Go 并发原语专题 | Go 的 goroutine、channel、sync 包怎么表达并发 | 不讲语言原语,只讲 TypeScript 侧的任务依赖图与调度算法 |
| 14.1 为什么 JS 也需要 Job System 等第 14 章 | 任务怎么跑到多线程上:线程池、队列、窃取 | 任务之间的依赖关系怎么表达与调度 |
| 3.1 子系统注册、启动顺序与逆序关闭 | 启动期一次性的子系统拓扑排序 | 运行期每帧重建的任务图拓扑执行 |
一句话概括分工:第 14 章讲「怎么把任务跑在多线程上」,本章讲「任务之间的依赖关系怎么表达与调度」。 站内此前没有任务依赖图与拓扑调度的内容——这是本章要补的空白。
依赖图长什么样
把每个任务画成一个点,把「A 必须在 B 之前」画成一条从 A 指向 B 的有向边,整张图就是一个有向无环图(DAG)。之所以必须无环,是因为环意味着「A 等 B、B 又等 A」——这是一组永远无法开始的任务。
在 helio 里,任务不显式记「后继是谁」,而是记「我必须在哪些任务之后」:
| 字段 | 含义 |
|---|---|
id | 任务唯一标识,同层内按加入顺序稳定排序 |
after | 前置任务列表:这些任务全部完成后,本任务才可开始 |
run | 执行体 |
用 after 而不是「后继表」,是因为写代码的人天然知道自己依赖谁,让他反过来声明「谁依赖我」既别扭又容易漏。图的方向由算法在内部补全:对每个任务的每个 after,都加一条「前置 → 本任务」的边。
有了这张图,一次拓扑排序就能回答两个问题:合法执行序是什么(order),以及哪些任务可以同时跑(levels)。同一层内的任务互不依赖,是天然的并行批次。
可运行产物:Kahn 分层拓扑排序
下面这个 src/core/task-graph.ts 用 Kahn 算法(基于入度的 BFS 式排序)做分层。它的做法是:每轮取出「当前入度为 0」的全部任务作为一层,把它们的后继入度减一,减到 0 的进入下一轮。层级 L1,Node 直跑。
// file: src/core/task-graph.ts
// tier: L1
// verify: npx vitest run src/core/task-graph.test.ts
//
// helio 内核:任务依赖 DAG 与拓扑执行。
// 每个任务声明「必须在哪些任务之后」(after = 前置),
// TaskGraph 用 Kahn 算法分层拓扑排序:同一层内的任务互不依赖,可以并行跑。
export type TaskId = string;
/** 一个任务:id 唯一,after 列出必须先完成的前置任务,run 是执行体。 */
export interface TaskNode {
readonly id: TaskId;
readonly after: readonly TaskId[];
readonly run: () => void;
}
/** 拓扑计划:order 是合法的串行执行序,levels 是分层的并行批次。 */
export interface TopoPlan {
readonly order: readonly TaskId[];
readonly levels: readonly (readonly TaskId[])[];
readonly maxParallelism: number;
}
/** 检测到环时抛出,remaining 列出卡在环上的任务(入度始终不为 0)。 */
export class CyclicDependencyError extends Error {
readonly remaining: readonly TaskId[];
constructor(remaining: readonly TaskId[]) {
super(`cyclic task dependency among: ${remaining.join(', ')}`);
this.name = 'CyclicDependencyError';
this.remaining = remaining;
}
}
/** 任务依赖图。加入顺序决定同层内的稳定次序,便于复现。 */
export class TaskGraph {
readonly #nodes = new Map<TaskId, TaskNode>();
add(task: TaskNode): this {
if (this.#nodes.has(task.id)) {
throw new Error(`duplicate task: ${task.id}`);
}
this.#nodes.set(task.id, task);
return this;
}
get size(): number {
return this.#nodes.size;
}
/**
* Kahn 分层拓扑排序。每一轮取出「当前入度为 0」的全部任务作为一层,
* 再把它们指向的后继入度减一,减到 0 的进入下一层。
* 若处理完的节点数少于总数,说明剩下的节点互相卡住——那就是环。
*/
plan(): TopoPlan {
const indegree = new Map<TaskId, number>();
const dependents = new Map<TaskId, TaskId[]>();
for (const id of this.#nodes.keys()) {
indegree.set(id, 0);
dependents.set(id, []);
}
for (const task of this.#nodes.values()) {
for (const dep of task.after) {
if (!this.#nodes.has(dep)) {
throw new Error(`unknown task dependency: ${task.id} -> ${dep}`);
}
indegree.set(task.id, (indegree.get(task.id) ?? 0) + 1);
dependents.get(dep)!.push(task.id);
}
}
let frontier = [...this.#nodes.keys()].filter((id) => indegree.get(id) === 0);
const levels: TaskId[][] = [];
const order: TaskId[] = [];
let processed = 0;
while (frontier.length > 0) {
const level = [...frontier];
levels.push(level);
const next: TaskId[] = [];
for (const id of level) {
order.push(id);
processed++;
for (const succ of dependents.get(id)!) {
const d = (indegree.get(succ) ?? 0) - 1;
indegree.set(succ, d);
if (d === 0) next.push(succ);
}
}
frontier = next;
}
if (processed !== this.#nodes.size) {
const stuck = [...this.#nodes.keys()].filter((id) => (indegree.get(id) ?? 0) > 0);
throw new CyclicDependencyError(stuck);
}
const maxParallelism = levels.reduce((m, l) => Math.max(m, l.length), 0);
return { order, levels, maxParallelism };
}
/** 按拓扑序串行执行全部任务,返回实际执行顺序(便于断言)。 */
run(): TaskId[] {
const plan = this.plan();
for (const id of plan.order) {
this.#nodes.get(id)!.run();
}
return [...plan.order];
}
}
plan() 里那句 processed !== this.#nodes.size 是整段的核心判断:能排出去的节点数,就是能开始的节点数。只要有一个节点因为入度永远归不了零而排不出去,整张图就有环。
测试:合法执行序与环检测
下面这个用例构造 glimmer-isle 的一帧任务图,断言分层结果、执行序,以及一个真实的环。
// file: src/core/task-graph.test.ts
// tier: L1
// verify: npx vitest run src/core/task-graph.test.ts
import { describe, expect, it } from 'vitest';
import { CyclicDependencyError, TaskGraph } from './task-graph.ts';
/** 造一个「把执行顺序写进 log」的图,用于观察 run() 的真实次序。 */
function frameGraph(log: string[]): TaskGraph {
const g = new TaskGraph();
const add = (id: string, after: string[]): void => {
g.add({ id, after, run: () => log.push(id) });
};
add('input', []);
add('physics', ['input']);
add('ai', ['input']);
add('animation', ['physics']);
add('render-list', ['animation', 'ai']);
add('cull', ['render-list']);
add('submit', ['cull']);
return g;
}
describe('TaskGraph 分层拓扑排序', () => {
it('给出合法的分层计划与最大并行度', () => {
const g = frameGraph([]);
const plan = g.plan();
expect(plan.levels).toEqual([
['input'],
['physics', 'ai'],
['animation'],
['render-list'],
['cull'],
['submit'],
]);
expect(plan.maxParallelism).toBe(2);
expect(plan.order).toEqual(plan.levels.flat());
});
it('run() 的执行顺序满足所有依赖(前置一定先跑)', () => {
const log: string[] = [];
const g = frameGraph(log);
const order = g.run();
expect(log).toEqual(order);
const pos = new Map(order.map((id, i) => [id, i]));
for (const [succ, deps] of [
['physics', ['input']],
['animation', ['physics']],
['render-list', ['animation', 'ai']],
] as const) {
for (const dep of deps) {
expect(pos.get(dep)!).toBeLessThan(pos.get(succ)!);
}
}
});
it('重复加入同名任务报错', () => {
const g = new TaskGraph();
g.add({ id: 'a', after: [], run: () => {} });
expect(() => g.add({ id: 'a', after: [], run: () => {} })).toThrow(/duplicate task: a/);
});
it('依赖不存在的任务报错', () => {
const g = new TaskGraph();
g.add({ id: 'a', after: ['ghost'], run: () => {} });
expect(() => g.plan()).toThrow(/unknown task dependency: a -> ghost/);
});
});
describe('TaskGraph 环检测', () => {
it('两节点互相依赖时报错并列出环上的任务', () => {
const g = new TaskGraph();
g.add({ id: 'a', after: ['b'], run: () => {} });
g.add({ id: 'b', after: ['a'], run: () => {} });
let caught: unknown;
try {
g.plan();
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(CyclicDependencyError);
expect([...(caught as CyclicDependencyError).remaining].sort()).toEqual(['a', 'b']);
expect(() => g.plan()).toThrow(/cyclic task dependency/);
});
it('三节点长环也能检测到,未卡住的任务不出现在 remaining 里', () => {
const g = new TaskGraph();
g.add({ id: 'root', after: [], run: () => {} });
g.add({ id: 'a', after: ['root', 'c'], run: () => {} });
g.add({ id: 'b', after: ['a'], run: () => {} });
g.add({ id: 'c', after: ['b'], run: () => {} });
let caught: CyclicDependencyError | undefined;
try {
g.plan();
} catch (e) {
caught = e as CyclicDependencyError;
}
expect(caught).toBeInstanceOf(CyclicDependencyError);
expect([...caught!.remaining].sort()).toEqual(['a', 'b', 'c']);
});
});
本机实测:npx vitest run src/core/task-graph.test.ts 全部通过,共 6 个用例。第二个环用例的报错原文是 cyclic task dependency among: a, b——环上的两个任务都被列了出来。第四个用例里 root 没有依赖、能正常开始,所以它不会出现在 remaining 里;卡住的恰好是 a -> b -> c -> a 这三个。
环:为什么必须早发现
一个环如果在运行期才暴露,表现是「这一帧的任务永远排不出来」——要么整帧卡死,要么某些任务被静默跳过。这两种后果都很难查:卡死没有栈,跳过没有报错。
所以 helio 的选择是:在 plan() 里就把环变成一次显式抛错,并把环上的任务名带出来。上面的用例证明,两节点环和三节点长环都能被精确抓到,而且能区分「卡在环上的任务」和「已经能开始的正常任务」。越早把不可调度这件事喊出来,越不用在深夜对着一个不动的画面猜。
常见坑
- 用「后继表」而不是「前置表」声明依赖。写代码的人知道自己依赖谁;反着写既别扭,又容易漏掉一条边。
- 把
after写成集合却当成有序。after只表达「必须先于」,不表达这些前置之间的顺序——它们之间由算法判定,不该有隐含次序。 - 忘记检测环。只要有环,
order就会静默地少掉一部分任务;不抛错的话,调用方会以为「排完了」。 - 依赖一个不存在的任务名。拼错前置名等于凭空多一个永远无法满足的依赖,行为上和环一样致命,所以
plan()也把它当错误抛出。 - 把同层的任务当成有序。同层内谁先谁后没有保证,调度器可以任意顺序、甚至并行地跑它们。
拿到合法的 order 和可并行的 levels 之后,下一步是把它们接到真实的帧循环里——每帧重建图、按层派发。15.2 就来跑这个每帧重建的循环,并把每帧的并行度真实打印出来。
阅读导航:上一节:目录 · 下一节:15.2 任务图构建与帧内调度 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。