「让几个异步任务一个接一个执行」,听起来简单,但面试现场至少有三种写法能翻车,尤其是那个 forEach 版本——它看起来最像对的,结果全都并发跑了。
先看现象。三个任务,串行执行,正确输出应该是 start A → end A → start B → ...:
// 正确(串行)
start A
end A
start B
end B
start C
end C
而下面这段"看起来没问题"的代码:
tasks.forEach(async (task) => {
await task();
});
实际输出是:
start A
start B
start C
end A
end B
end C
三个任务几乎同时启动,完全没串行。这篇文章把串行的几种正统写法和它们的坑一次讲清:
for...of+await(最推荐)- 索引
for循环 +await - 递归
reduce构建 Promise 链- 支持动态追加任务的串行队列
一、什么叫"串行"
先定义清楚,避免和并发调度混淆:
- 串行:任意时刻最多只有 1 个任务在执行,后一个必须等前一个 resolve 之后才开始。本质就是并发数 = 1。
- 并发:允许多个任务同时跑,但有上限。
串行的核心要求只有一个:把 await 放在循环内部,让循环体暂停。 所有错误的写法,根源都是"循环没有等 await"。
二、错误示范:为什么 forEach 不生效
关键在 forEach 的实现:它接收回调,但无视回调的返回值。
tasks.forEach(async (task) => {
await task(); // 这个 Promise 被 forEach 直接丢掉了
});
async 回调返回的是一个 Promise,但 forEach 不 await 它、也不 return 它。于是 forEach 同步地把所有回调都调用了一遍,每个回调各自进入异步流程,三个任务自然就并发了。
map 同样不行,除非你把返回的 Promise 数组再 await,但那样又变成了 Promise.all 的全量并发:
await Promise.all(tasks.map((task) => task())); // 这是并发,不是串行
一句话记住:forEach / map 是给同步遍历用的,想串行,必须先让循环"停得下来"。而能停下来的循环只有 for / for...of / while。
三、实现一:for...of + await
最干净、最推荐的写法:
async function runSerial(tasks) {
const results = [];
for (const task of tasks) {
results.push(await task());
}
return results;
}
await 出现在循环体内,每轮循环都会等当前任务完成后,才进入下一轮。输出完全符合预期:
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 控制流时,用索引写法:
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 个。
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 写更直白:
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 链:
const results = await tasks.reduce(
(chain, task) =>
chain.then((acc) => task().then((value) => [...acc, value])),
Promise.resolve([])
);
它确实串行,但有两个代价:
- 一次性构建整条链:
reduce是同步执行的,会立刻把 N 层.then全部挂好。任务特别多时,链会很长。 - 每次
[...acc, value]都在复制数组:时间复杂度是 O(n²)。
所以 reduce 写法适合面试展示"我懂 Promise 链式调用",生产环境里不如 for...of 直观高效。
七、错误处理:中断 vs 继续
串行调度里,一个任务失败后有两种策略,必须提前和面试官确认:
策略一:失败即中断(默认)
async function stopOnError(tasks) {
const results = [];
for (const task of tasks) {
results.push(await task()); // 抛错就直接向上冒泡,后续任务不再执行
}
return results;
}
验证:第 2 个任务抛错,第 3 个不执行。
t1
t2
stopOnError threw: boom
策略二:失败也继续
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;
}
输出:
[
{ status: 'ok', value: 1 },
{ status: 'err', error: 'boom' },
{ status: 'ok', value: 3 }
]
如果是批量导入、批量上报这类场景,通常选策略二,并且把每个任务的成功/失败都记录下来,而不是让整批因为一条脏数据全军覆没。
八、进阶:支持动态追加任务的串行队列
上面四种实现都有同一个前提:任务集合一开始就确定了。 但真实场景(上传队列、消息发送队列)往往是任务边跑边加。这时需要一个「串行队列」:
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;
}
}
用法:
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);
输出(严格串行,运行中追加的任务也会排队):
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 | 中 | 是 | 是 | 上传、发送等边生产边消费 |
十、总结
forEach/map+async不会串行,因为循环不等回调返回的 Promise;错误示范的输出是所有任务一起启动。- 串行的本质是把
await放进循环体内,让循环暂停;正确的循环形式是for/for...of/while。 - 面试最稳的写法是
for...of+await;需要下标时换成索引for;递归和reduce是加分项,不是首选。 - 异步递归不会栈溢出,因为
await会在每次递归前把调用栈展开,栈深不随任务数增长。 - 失败处理要提前确认策略:默认失败即中断,批量场景用
try/catch逐条记录后继续。 - 任务集合不确定时,用带
running标志的SerialQueue;它其实就是并发数固定为 1 的调度器。