搞定异步任务依赖: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 优化查找效率,解决 "大规模任务性能" 的问题。

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

相关推荐
Focusbe2 小时前
为什么 “大前端” 需要 “微前端”?
前端·后端·架构
usagisah2 小时前
为 CSS-IN-JS 正个名,被潮流抛弃并不代表无用,与原子类相比仍有一战之力
前端·javascript·css
阿笑带你学前端2 小时前
Flutter应用自动更新系统:生产环境的挑战与解决方案
前端·flutter
不一样的少年_2 小时前
老板催:官网打不开!我用这套流程 6 分钟搞定
前端·程序员·浏览器
徐小夕2 小时前
支持1000+用户同时在线的AI多人协同文档JitWord,深度剖析
前端·vue.js·算法
fox_2 小时前
JS:手搓一份防抖和节流函数
javascript
小公主3 小时前
面试必问:跨域问题的原理与解决方案
前端
Cache技术分享3 小时前
194. Java 异常 - Java 异常处理之多重捕获
前端·后端
新酱爱学习3 小时前
🚀 Web 图片优化实践:通过 AVIF/WebP 将 12MB 图片降至 4MB
前端·性能优化·图片资源