创见博客
手撕:异步任务串行执行的调度器——从 for await 到递归和动态队列
七崽爱吃小饼干2026/09/11阅读 0

「让几个异步任务一个接一个执行」,听起来简单,但面试现场至少有三种写法能翻车,尤其是那个 forEach 版本——它看起来最像对的,结果全都并发跑了。

先看现象。三个任务,串行执行,正确输出应该是 start A → end A → start B → ...:

text
// 正确(串行)
start A
end   A
start B
end   B
start C
end   C

而下面这段"看起来没问题"的代码:

js
tasks.forEach(async (task) => {
  await task();
});

实际输出是:

text
start A
start B
start C
end   A
end   B
end   C

三个任务几乎同时启动,完全没串行。这篇文章把串行的几种正统写法和它们的坑一次讲清:

  1. for...of + await(最推荐)
  2. 索引 for 循环 + await
  3. 递归
  4. reduce 构建 Promise 链
  5. 支持动态追加任务的串行队列

一、什么叫"串行"

先定义清楚,避免和并发调度混淆:

  • 串行:任意时刻最多只有 1 个任务在执行,后一个必须等前一个 resolve 之后才开始。本质就是并发数 = 1。
  • 并发:允许多个任务同时跑,但有上限。

串行的核心要求只有一个:把 await 放在循环内部,让循环体暂停。 所有错误的写法,根源都是"循环没有等 await"。

二、错误示范:为什么 forEach 不生效

关键在 forEach 的实现:它接收回调,但无视回调的返回值。

js
tasks.forEach(async (task) => {
  await task(); // 这个 Promise 被 forEach 直接丢掉了
});

async 回调返回的是一个 Promise,但 forEach 不 await 它、也不 return 它。于是 forEach 同步地把所有回调都调用了一遍,每个回调各自进入异步流程,三个任务自然就并发了。

map 同样不行,除非你把返回的 Promise 数组再 await,但那样又变成了 Promise.all 的全量并发:

js
await Promise.all(tasks.map((task) => task())); // 这是并发,不是串行

一句话记住:forEach / map 是给同步遍历用的,想串行,必须先让循环"停得下来"。而能停下来的循环只有 for / for...of / while。

三、实现一:for...of + await

最干净、最推荐的写法:

js
async function runSerial(tasks) {
  const results = [];
  for (const task of tasks) {
    results.push(await task());
  }
  return results;
}

await 出现在循环体内,每轮循环都会等当前任务完成后,才进入下一轮。输出完全符合预期:

text
start A
end   A
start B
end   B
start C
end   C

注意格式:这里用的是 for...of + await,不是 for await...of。for await...of 是给异步迭代器(async iterable)用的,普通数组不需要,用了反而会报错。面试时把这两个说混,容易被追问。

四、实现二:索引 for 循环 + await

需要在循环里拿到下标,或者要支持 break / continue 控制流时,用索引写法:

js
async function runSerialIndexed(tasks) {
  const results = [];
  for (let i = 0; i < tasks.length; i++) {
    results[i] = await tasks[i]();
  }
  return results;
}

它和 for...of 在串行语义上完全等价。区别是:

  • for...of 更简洁,适合只需要元素本身。
  • 索引 for 能直观拿到 i,行为更像传统循环,面试官接受度也高。

两个都是"同步阻塞循环",语义没有分歧。

五、实现三:递归

递归的直觉是:执行第 i 个任务,等它完成后,再执行第 i + 1 个。

js
function runSerialRecursive(tasks, i = 0, acc = []) {
  if (i >= tasks.length) return Promise.resolve(acc);
  return tasks[i]().then((value) => {
    acc.push(value);
    return runSerialRecursive(tasks, i + 1, acc);
  });
}

用 async/await 写更直白:

js
async function runSerialRecursive(tasks, i = 0, acc = []) {
  if (i >= tasks.length) return acc;
  acc.push(await tasks[i]());
  return runSerialRecursive(tasks, i + 1, acc);
}

有人担心递归会栈溢出——同步递归确实会,但这里的递归被 await 切开了:每次递归调用都发生在当前 Promise resolve 之后的微任务里,调用栈早已展开,栈深度不会随任务数增长。所以异步递归执行上万个任务是安全的。

不过要注意,把 acc 作为参数一路透传,可读性一般;纯手写面试题用它展示对递归和 Promise 的理解没问题,工程里更推荐 for...of。

六、实现四:reduce 构建 Promise 链

思路是把任务数组折叠成一条 Promise 链:

js
const results = await tasks.reduce(
  (chain, task) =>
    chain.then((acc) => task().then((value) => [...acc, value])),
  Promise.resolve([])
);

它确实串行,但有两个代价:

  1. 一次性构建整条链:reduce 是同步执行的,会立刻把 N 层 .then 全部挂好。任务特别多时,链会很长。
  2. 每次 [...acc, value] 都在复制数组:时间复杂度是 O(n²)。

所以 reduce 写法适合面试展示"我懂 Promise 链式调用",生产环境里不如 for...of 直观高效。

七、错误处理:中断 vs 继续

串行调度里,一个任务失败后有两种策略,必须提前和面试官确认:

策略一:失败即中断(默认)

js
async function stopOnError(tasks) {
  const results = [];
  for (const task of tasks) {
    results.push(await task()); // 抛错就直接向上冒泡,后续任务不再执行
  }
  return results;
}

验证:第 2 个任务抛错,第 3 个不执行。

text
t1
t2
stopOnError threw: boom

策略二:失败也继续

js
async function continueOnError(tasks) {
  const results = [];
  for (const task of tasks) {
    try {
      results.push({ status: 'ok', value: await task() });
    } catch (err) {
      results.push({ status: 'err', error: err.message });
    }
  }
  return results;
}

输出:

text
[
  { status: 'ok', value: 1 },
  { status: 'err', error: 'boom' },
  { status: 'ok', value: 3 }
]

如果是批量导入、批量上报这类场景,通常选策略二,并且把每个任务的成功/失败都记录下来,而不是让整批因为一条脏数据全军覆没。

八、进阶:支持动态追加任务的串行队列

上面四种实现都有同一个前提:任务集合一开始就确定了。 但真实场景(上传队列、消息发送队列)往往是任务边跑边加。这时需要一个「串行队列」:

js
class SerialQueue {
  constructor() {
    this.queue = [];
    this.running = false;
  }

  add(task) {
    return new Promise((resolve, reject) => {
      this.queue.push({ task, resolve, reject });
      this._drain();
    });
  }

  async _drain() {
    if (this.running) return;   // 已有 drain 在跑,直接返回
    this.running = true;

    while (this.queue.length) {
      const { task, resolve, reject } = this.queue.shift();
      try {
        resolve(await task());
      } catch (err) {
        reject(err);
      }
    }

    this.running = false;
  }
}

用法:

js
const q = new SerialQueue();

q.add(async () => { console.log('1 start'); await sleep(80); console.log('1 end') });
q.add(async () => { console.log('2 start'); await sleep(20); console.log('2 end') });

// 运行过程中再追加
setTimeout(() => {
  q.add(async () => { console.log('3 start'); console.log('3 end') });
}, 30);

输出(严格串行,运行中追加的任务也会排队):

text
1 start
1 end
2 start
2 end
3 start
3 end

几个设计点:

  • running 标志位是关键:防止每次 add 都启动一个新的 _drain 循环,导致并发。已有循环在跑时,新任务只入队,由现有循环的 while 顺带消费。
  • 用 while 而不是 if:每次 _drain 会一直消费到队列为空,中途新加的任务也能被同一轮处理。
  • 每个任务自带 resolve / reject,调用方仍然能拿到自己那一个的结果。
  • 这个结构和上一篇「最大并发为 2 的调度器」其实是同一个模型,把 limit 固定成 1 就是串行队列。理解了并发调度器,串行队列就是它的特例。

九、几种实现对比

实现方式可读性串行保证支持动态追加适用场景
for...of + await高是否任务集合已知,首选
索引 for + await高是否需要下标或 break / continue
递归中是否展示对 Promise / 递归的理解
reduce 链中是否面试演示,链过长不推荐
SerialQueue中是是上传、发送等边生产边消费

十、总结

  1. forEach / map + async 不会串行,因为循环不等回调返回的 Promise;错误示范的输出是所有任务一起启动。
  2. 串行的本质是把 await 放进循环体内,让循环暂停;正确的循环形式是 for / for...of / while。
  3. 面试最稳的写法是 for...of + await;需要下标时换成索引 for;递归和 reduce 是加分项,不是首选。
  4. 异步递归不会栈溢出,因为 await 会在每次递归前把调用栈展开,栈深不随任务数增长。
  5. 失败处理要提前确认策略:默认失败即中断,批量场景用 try/catch 逐条记录后继续。
  6. 任务集合不确定时,用带 running 标志的 SerialQueue;它其实就是并发数固定为 1 的调度器。
评论
0/100