在日常开发中,你是否遇到过这样的场景:多个异步任务存在复杂依赖关系(比如 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 步:
- 给每个节点标记 "入度"(即依赖的节点数量,比如 B 的入度是 1,依赖 A);
- 找出所有入度为 0 的节点(无依赖,可立即执行),加入 "就绪队列";
- 执行就绪队列的节点,执行完后减少其 "下游节点"(依赖它的节点)的入度;
- 重复步骤 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
函数是单个任务的执行单元,核心是执行完当前任务后,通知下游任务 "依赖已完成" :
- 先通过
taskMap
找到任务,await
执行其run
方法,把结果存入result
; - 遍历所有任务,找到依赖当前任务的下游任务(
t.deps.includes(id)
); - 下游任务的入度减 1(比如 A 执行完后,B 的入度从 1→0);
- 若下游任务入度变为 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 并行执行当前批次的所有就绪任务:
- 先浅拷贝
ready
到currentBatch
:因为ready
会在run
函数中动态添加新任务(比如 A 执行完会加 B 和 C),如果不拷贝,会导致同一批次执行了下一批的任务,逻辑混乱; - 清空
ready
:为下一批任务腾出空间; 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')]):
- 执行 A 的 run,等待 5 秒后得到 'A done',存入 result:
{'A': 'A done'}
; - 找依赖 A 的任务(B 和 C),它们的入度各减 1:B→0,C→0;
- 把 B、C 加入 ready:ready 变为
['B','C']
。
- 执行 A 的 run,等待 5 秒后得到 'A done',存入 result:
Step 3:第二次循环(并行执行 B 和 C)
-
currentBatch =
['B','C']
,ready 清空为[]
-
await Promise.all([run('B'), run('C')]):
- 执行 B:得到 'B done',存入 result;找依赖 B 的任务(D),D 的入度 2→1;
- 执行 C:得到 'C done',存入 result;找依赖 C 的任务(D),D 的入度 1→0;
- 把 D 加入 ready:ready 变为
['D']
。
Step 4:第三次循环(执行 D)
-
currentBatch =
['D']
,ready 清空为[]
-
await Promise.all([run('D')]):
- 执行 D:得到 'D done',存入 result;
- 无依赖 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 有耗时,并行优势会很明显)。
五、注意事项:避免踩坑
-
禁止循环依赖:如果任务存在环(比如 A 依赖 B,B 依赖 A),indegress 永远不会为 0,循环会无限卡住。需要加环检测:
javascript// 环检测:执行结束后若result长度 < 任务总数,说明有环 if (Object.keys(result).length !== tasks.length) { throw new Error('任务存在循环依赖,无法完成调度'); }
-
错误处理 :任务的
run
函数可能抛出错误,需要在run
中加 try/catch,避免整个调度失败:javascriptasync 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 }; // 可选:依赖当前任务的下游任务直接标记失败,或跳过 } // ...后续更新依赖逻辑 }
-
任务优先级 :如果需要给就绪任务加优先级(比如 C 比 B 先执行),可以把
ready
改成优先级队列(比如最小堆),按优先级排序后再执行。
六、应用场景:不止于异步任务
这个调度器的适用场景远超普通异步任务,比如:
- 前端构建:webpack 插件执行顺序(依赖插件先执行);
- 后端任务调度:数据同步(先同步用户表,再同步订单表);
- 工作流引擎:审批流程(部门经理审批完,才到 CEO 审批);
- CI/CD 流水线:构建→测试→部署,测试依赖构建,部署依赖测试。
总结
今天我们用 "拓扑排序 + Promise.all" 实现了一个通用的异步任务调度器,核心是:
- 用拓扑排序保证依赖正确性,解决 "先执行谁" 的问题;
- 用 Promise.all 并行执行无依赖任务,解决 "如何执行更快" 的问题;
- 用 Map 优化查找效率,解决 "大规模任务性能" 的问题。
代码可以直接复制到项目中使用,也可以根据需求扩展(比如优先级、错误重试、环检测)。如果你的项目中有复杂的异步依赖场景,不妨试试这个方案!