搞定异步任务依赖:Promise.all 与拓扑排序的妙用

在日常开发中,你是否遇到过这样的场景:多个异步任务存在复杂依赖关系(比如 A 执行完才能执行 B/C,B/C 都完了才能执行 D),用普通async/await嵌套会写成 "回调金字塔",用Promise.then链又难以维护?今天就带大家用拓扑排序 +Promise.all打造一个通用的异步任务调度器,彻底解决依赖管理问题,代码可直接复用!

一、先明确需求:我们要解决什么问题?

先看一个典型的异步任务场景,用数组tasks定义 4 个任务:

  • A 任务:无依赖,需 5 秒执行完
  • B 任务:依赖 A,立即执行
  • C 任务:依赖 A,立即执行
  • D 任务:依赖 B 和 C,立即执行

如果用原生async/await写,会是这样:

javascript 复制代码
async function manuallyRun() {
  const aRes = await taskA.run(); // 等5秒
  const bRes = await taskB.run(); // 等B(无耗时)
  const cRes = await taskC.run(); // 等C(无耗时)------ 这里B和C本可并行,却串行执行了!
  const dRes = await taskD.run(); // 等D
  return {aRes, bRes, cRes, dRes};
}

问题很明显:B 和 C 本可并行执行,却被强制串行,浪费了时间。如果任务更多,依赖更复杂,手动维护顺序会变得无比繁琐。

这时候就需要一个能自动处理依赖、并行执行无依赖任务的调度器 ------ 这正是我们今天要实现的runTasks函数。

二、核心思想:用拓扑排序解决依赖问题

异步任务的依赖关系本质是一个有向无环图(DAG) :每个任务是 "节点",依赖关系是 "有向边"(A→B 表示 A 是 B 的依赖)。

拓扑排序 正是处理 DAG 的经典算法,它能保证:所有依赖项执行完后,才执行当前任务。其核心逻辑就 3 步:

  1. 给每个节点标记 "入度"(即依赖的节点数量,比如 B 的入度是 1,依赖 A);
  2. 找出所有入度为 0 的节点(无依赖,可立即执行),加入 "就绪队列";
  3. 执行就绪队列的节点,执行完后减少其 "下游节点"(依赖它的节点)的入度;
  4. 重复步骤 2-3,直到所有节点执行完。

再结合Promise.all并行执行就绪队列的节点,就能兼顾 "依赖正确性" 和 "执行效率"。

三、逐行拆解runTasks核心代码

javascript 复制代码
async function runTasks(tasks) {
  // 1. 构建任务映射表:id -> 任务(O(1)查找)
  const taskMap = new Map(tasks.map(t => [t.id, t]));
  
  // 2. 初始化每个任务的入度(依赖数量)
  const indegress = new Map(tasks.map(t => [t.id, t.deps.length]));
  
  // 3. 存储任务执行结果
  const result = {};
  
  // 4. 就绪队列:初始存入所有无依赖(入度0)的任务id
  let ready = tasks.filter(t => t.deps.length === 0).map(t => t.id);

  // 5. 单个任务执行逻辑:执行后更新下游依赖
  async function run(id) {
    // 5.1 执行当前任务,获取结果
    const task = taskMap.get(id);
    const output = await task.run();
    result[id] = output;

    // 5.2 遍历所有任务,找到依赖当前任务的"下游任务"
    for (const [tid, t] of taskMap) {
      if (t.deps.includes(id)) {
        // 5.3 下游任务入度-1(少了一个依赖)
        indegress.set(tid, indegress.get(tid) - 1);
        // 5.4 若入度变为0,加入就绪队列
        if (indegress.get(tid) === 0) {
          ready.push(tid);
        }
      }
    }
  }

  // 6. 主循环:批量执行就绪队列任务
  while (ready.length > 0) {
    // 6.1 浅拷贝就绪队列:避免执行中动态修改队列导致重复执行
    const currentBatch = [...ready];
    // 6.2 清空就绪队列:等待下一批任务加入
    ready = [];
    // 6.3 并行执行当前批次任务(关键!提升效率)
    await Promise.all(currentBatch.map(run));
  }

  // 7. 返回所有任务结果
  return result;
}

// 测试任务列表
const tasks = [
  { id: 'A', run: () => new Promise(res => setTimeout(() => res('A done'), 5000)), deps: [] },
  { id: 'B', run: () => Promise.resolve('B done'), deps: ['A'] },
  { id: 'C', run: () => Promise.resolve('C done'), deps: ['A'] }, 
  { id: 'D', run: () => Promise.resolve('D done'), deps: ['B', 'C'] },
];

// 执行调度器
runTasks(tasks).then(res => console.log('最终结果:', res));

关键逻辑 1:3 个核心数据结构

这 3 个结构是调度器的 "骨架",决定了效率和正确性:

  • taskMap(Map) :key 是任务 id,value 是任务对象。为什么不用数组遍历?因为数组查找需要O(n),而 Map 是O(1),任务多的时候效率差距会很明显。
  • indegress(Map) :key 是任务 id,value 是当前入度(依赖未完成的数量)。比如初始时 B 的入度是 1(依赖 A),D 的入度是 2(依赖 B 和 C)。
  • ready(数组) :存储当前可执行的任务 id(入度为 0)。初始时只有 A(deps 为空),后续会动态加入 B、C、D。

关键逻辑 2:run函数 ------ 执行任务 + 更新依赖

run函数是单个任务的执行单元,核心是执行完当前任务后,通知下游任务 "依赖已完成"

  1. 先通过taskMap找到任务,await执行其run方法,把结果存入result
  2. 遍历所有任务,找到依赖当前任务的下游任务(t.deps.includes(id));
  3. 下游任务的入度减 1(比如 A 执行完后,B 的入度从 1→0);
  4. 若下游任务入度变为 0,说明它的所有依赖都完成了,加入ready队列等待执行。

优化点 :当前遍历所有任务找下游(O(n)),可以提前构建 "反向依赖表"(比如reverseDeps: Map<string, string[]>,key 是任务 id,value 是依赖它的任务 id 数组),这样只需O(1)获取下游任务,代码如下:

javascript 复制代码
// 优化:提前构建反向依赖表
const reverseDeps = new Map();
tasks.forEach(task => {
  task.deps.forEach(depId => {
    if (!reverseDeps.has(depId)) reverseDeps.set(depId, []);
    reverseDeps.get(depId).push(task.id);
  });
});

// 后续run函数中,无需遍历所有任务,直接取反向依赖
if (reverseDeps.has(id)) {
  for (const tid of reverseDeps.get(id)) {
    indegress.set(tid, indegress.get(tid) - 1);
    if (indegress.get(tid) === 0) ready.push(tid);
  }
}

关键逻辑 3:主循环 ------ 批量并行执行

主循环是提升效率的关键,核心是用 Promise.all 并行执行当前批次的所有就绪任务

  1. 先浅拷贝readycurrentBatch:因为ready会在run函数中动态添加新任务(比如 A 执行完会加 B 和 C),如果不拷贝,会导致同一批次执行了下一批的任务,逻辑混乱;
  2. 清空ready:为下一批任务腾出空间;
  3. await Promise.all(currentBatch.map(run)):并行执行当前批次的所有任务。比如 B 和 C 会同时执行,而不是串行,比手动执行节省了时间。

四、执行流程拆解:一步看懂调度过程

我们以测试任务为例,一步步看调度器如何工作:

Step 1:初始化

  • taskMap:{'A': 任务A, 'B': 任务B, 'C': 任务C, 'D': 任务D}
  • indegress:{'A':0, 'B':1, 'C':1, 'D':2}
  • ready:['A'](只有 A 入度为 0)
  • result:{}

Step 2:第一次循环(执行 A)

  • currentBatch = ['A'],ready 清空为[]
  • await Promise.all([run('A')]):
    1. 执行 A 的 run,等待 5 秒后得到 'A done',存入 result:{'A': 'A done'}
    2. 找依赖 A 的任务(B 和 C),它们的入度各减 1:B→0,C→0;
    3. 把 B、C 加入 ready:ready 变为['B','C']

Step 3:第二次循环(并行执行 B 和 C)

  • currentBatch = ['B','C'],ready 清空为[]

  • await Promise.all([run('B'), run('C')]):

    1. 执行 B:得到 'B done',存入 result;找依赖 B 的任务(D),D 的入度 2→1;
    2. 执行 C:得到 'C done',存入 result;找依赖 C 的任务(D),D 的入度 1→0;
    3. 把 D 加入 ready:ready 变为['D']

Step 4:第三次循环(执行 D)

  • currentBatch = ['D'],ready 清空为[]

  • await Promise.all([run('D')]):

    1. 执行 D:得到 'D done',存入 result;
    2. 无依赖 D 的任务,ready 保持空。

Step 5:循环结束,返回结果

最终 result:{'A':'A done', 'B':'B done', 'C':'C done', 'D':'D done'},总耗时约 5 秒(A 的 5 秒 + B/C/D 的 0 秒),比手动串行执行(5+0+0+0=5 秒,这里巧合,但如果 B/C 有耗时,并行优势会很明显)。

五、注意事项:避免踩坑

  1. 禁止循环依赖:如果任务存在环(比如 A 依赖 B,B 依赖 A),indegress 永远不会为 0,循环会无限卡住。需要加环检测:

    javascript 复制代码
    // 环检测:执行结束后若result长度 < 任务总数,说明有环
    if (Object.keys(result).length !== tasks.length) {
      throw new Error('任务存在循环依赖,无法完成调度');
    }
  2. 错误处理 :任务的run函数可能抛出错误,需要在run中加 try/catch,避免整个调度失败:

    javascript 复制代码
    async function run(id) {
      try {
        const task = taskMap.get(id);
        const output = await task.run();
        result[id] = { success: true, data: output };
      } catch (err) {
        result[id] = { success: false, error: err.message };
        // 可选:依赖当前任务的下游任务直接标记失败,或跳过
      }
      // ...后续更新依赖逻辑
    }
  3. 任务优先级 :如果需要给就绪任务加优先级(比如 C 比 B 先执行),可以把ready改成优先级队列(比如最小堆),按优先级排序后再执行。

六、应用场景:不止于异步任务

这个调度器的适用场景远超普通异步任务,比如:

  • 前端构建:webpack 插件执行顺序(依赖插件先执行);
  • 后端任务调度:数据同步(先同步用户表,再同步订单表);
  • 工作流引擎:审批流程(部门经理审批完,才到 CEO 审批);
  • CI/CD 流水线:构建→测试→部署,测试依赖构建,部署依赖测试。

总结

今天我们用 "拓扑排序 + Promise.all" 实现了一个通用的异步任务调度器,核心是:

  1. 用拓扑排序保证依赖正确性,解决 "先执行谁" 的问题;
  2. 用 Promise.all 并行执行无依赖任务,解决 "如何执行更快" 的问题;
  3. 用 Map 优化查找效率,解决 "大规模任务性能" 的问题。

代码可以直接复制到项目中使用,也可以根据需求扩展(比如优先级、错误重试、环检测)。如果你的项目中有复杂的异步依赖场景,不妨试试这个方案!

相关推荐
Jay Kay2 小时前
GVPO:Group Variance Policy Optimization
人工智能·算法·机器学习
Epiphany.5562 小时前
蓝桥杯备赛题目-----爆破
算法·职场和发展·蓝桥杯
Ticnix2 小时前
ECharts初始化、销毁、resize 适配组件封装(含完整封装代码)
前端·echarts
纯爱掌门人2 小时前
终焉轮回里,藏着 AI 与人类的答案
前端·人工智能·aigc
twl2 小时前
OpenClaw 深度技术解析
前端
崔庆才丨静觅2 小时前
比官方便宜一半以上!Grok API 申请及使用
前端
星光不问赶路人2 小时前
vue3使用jsx语法详解
前端·vue.js
YuTaoShao2 小时前
【LeetCode 每日一题】1653. 使字符串平衡的最少删除次数——(解法三)DP 空间优化
算法·leetcode·职场和发展
天蓝色的鱼鱼2 小时前
shadcn/ui,给你一个真正可控的UI组件库
前端
茉莉玫瑰花茶3 小时前
C++ 17 详细特性解析(5)
开发语言·c++·算法