《游戏引擎实现原理》15.1 依赖 DAG 与拓扑执行

把帧内任务的先后关系表达成一张有向无环图,再用 Kahn 算法做分层拓扑排序,输出一个合法执行序与每层可并行的任务集合。本节给出 helio 内核可编译的 TaskGraph 完整实现与 vitest 用例,并用一个真实的环断言「循环依赖被检测到并报错」。

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 任务图构建与帧内调度 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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