人人都会AI编程

11.2 异步并发工具

更新时间:2026-07-10

在 Node.js 中,异步编程的核心是 Promise,而处理多个异步任务的并发执行则是实际开发中极其常见的需求。比如:同时查询多个数据库表、并发调用多个第三方 API、批量上传文件等。如果简单地用 for 循环配合 await 串行执行,总耗时将是所有任务耗时之和,并发优势完全无法体现。

还好,JavaScript 和 Node.js 提供了一系列工具方法来优雅地管理并发,同时社区也沉淀出了成熟的手动限流模式。本节我们就来梳理这些工具和方法,并给出可直接参考的实现。

11.2.1 Promise.all:全部成功才成功

Promise.all(iterable) 是最常用的并发工具。它接受一个 Promise 数组,返回一个新的 Promise。这个新 Promise 会在所有输入的 Promise 都成功时返回结果数组,只要有一个失败,就会立即拒绝,并以第一个拒绝的原因作为失败原因。

基本用法:

const [user, orders, settings] = await Promise.all([
  fetchUser(userId),
  fetchOrders(userId),
  fetchSettings(userId),
]);

在这个例子中,三个请求并发执行,总耗时约等于最慢的那个请求的时间,而非三者之和。

需要注意的坑:

  • 快速失败:如果一个请求先失败,Promise.all 立即拒绝,但其他请求并不会被自动取消,它们仍会在后台完成(或失败)。这可能导致资源浪费或状态不一致,需要根据场景使用 AbortController 或手动处理。
  • 结果顺序:返回的结果数组严格与输入 Promise 的顺序对应,即使某个 Promise 先完成,它在结果中的位置仍然不变。
  • 传入非 Promise 值:如果数组中有普通值,会被自动包装成 Promise.resolve(value)
// 即使 fetchSettings 先完成,结果顺序依然是 [user, orders, settings]
const results = await Promise.all([
  fetchUser(userId),    // 位置 0
  fetchOrders(userId),  // 位置 1
  fetchSettings(userId),// 位置 2
]);

适用场景:多个独立 I/O 操作,互相之间没有依赖,且要求全部成功才能继续后续逻辑。

11.2.2 Promise.allSettled:等待全部完成,不论成功或失败

Promise.allSettled(iterable) 是在 ES2020 中引入的。它与 Promise.all 的最大不同在于:永远不会拒绝。它会等待所有 Promise 都有了结果(成功或失败)后,返回一个对象数组,每个对象描述了对应 Promise 的最终状态。

返回结果结构:

  • 成功:{ status: 'fulfilled', value: 结果值 }
  • 失败:{ status: 'rejected', reason: 错误对象 }
const results = await Promise.allSettled([
  fetchUser(userId),
  fetchOrders(userId),
  fetchSettings(userId),
]);

results.forEach((result, index) => {
  if (result.status === 'fulfilled') {
    console.log(`第 ${index} 个请求成功:`, result.value);
  } else {
    console.error(`第 ${index} 个请求失败:`, result.reason);
  }
});

典型场景

  • 批量操作,希望收集成功和失败信息,而不是中途整体失败。例如:给一批用户发送通知,记录哪些成功哪些失败。
  • 面板信息聚合:即使某个服务挂了,其他面板数据依然要展示,但需要标记对应模块异常。

与 Promise.all 对比all 适用于“一个失败全盘皆输”的严格一致性场景;allSettled 适合“尽力而为,收集结果”的柔性场景。

11.2.3 Promise.race:竞速,取第一个完成的结果

Promise.race(iterable) 返回的 Promise 会与输入数组中最先完成的那个 Promise 状态和结果保持一致。如果第一个完成的是成功,则 race 成功;如果是失败,则 race 失败。

const result = await Promise.race([
  fetchFromServerA(),
  fetchFromServerB(),
  timeout(2000),  // 超时控制
]);

实用技巧:超时控制

可以使用 Promise.race 给一个耗时操作添加超时:

function withTimeout(promise, ms) {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error('请求超时')), ms)
  );
  return Promise.race([promise, timeout]);
}

当请求在指定时间内未完成,timeout 会先 reject,从而中断等待并抛出超时错误。

注意事项

  • all 一样,race 不会取消其他 Promise。超时后,原请求仍可能继续执行,只是结果被忽略。
  • race 在竞争多个相同资源(如从多个镜像下载)时有用,但必须确保拿到第一个成功的值即可,不关心其他。

11.2.4 Promise.any:取第一个成功的,全部失败才失败

Promise.any(iterable)(ES2021)会等待直到有一个 Promise 成功,然后返回该成功值。如果所有 Promise 都失败了,则返回一个 AggregateError 对象聚合所有错误。

try {
  const firstResult = await Promise.any([
    fetchFromCDN1(),
    fetchFromCDN2(),
    fetchFromCDN3(),
  ]);
  console.log('从最快的 CDN 获得数据:', firstResult);
} catch (e) {
  console.error('所有 CDN 都失败了:', e.errors);
}

这与 Promise.race 的区别在于:race 只看速度,不管成败;any 看速度,但更偏好成功。这对于容错场景非常有用——你有多个副本或镜像,只需一个成功即可,失败的就重试下一个。

注意:如果传入空数组或全部失败,any 会 reject。

11.2.5 并发限流:避免“打爆”下游

在真实系统中,你不能无限制地同时发起成百上千个请求,否则可能瞬间耗尽文件描述符、压垮下游服务或触发频率限制。所以需要控制并发上限,让一定数量的任务并行执行,同时保持整体速度。

手动实现一个并发队列

下面是一个典型的生产级“任务池”模式,利用一个小型队列和 runner 来控制并发数:

async function asyncPool(limit, tasks, taskFn) {
  const results = [];
  const executing = new Set();

  for (const task of tasks) {
    const p = Promise.resolve().then(() => taskFn(task));
    results.push(p);
    executing.add(p);

    const clean = () => executing.delete(p);
    p.then(clean, clean); // 无论成功失败都移除

    if (executing.size >= limit) {
      await Promise.race(executing); // 等待至少一个完成
    }
  }

  return Promise.all(results);
}

使用示例

const userIds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const results = await asyncPool(3, userIds, (id) => fetchUser(id));

这个函数保证同时只有最多 3 个 fetchUser 请求在执行,一个完成就会从队列中取出下一个任务开始执行。最终返回的结果数组仍然按原始顺序排列。

第三方库方案

  • p-limit:一个极简的并发限制库,只关注限制并发数。
  const pLimit = require('p-limit');
  const limit = pLimit(3);
  const tasks = urls.map(url => limit(() => fetch(url)));
  const results = await Promise.all(tasks);
  
  • bottleneck:功能完善,支持速率限制、重试、集群共享等。
  • async 库的 queuecargo:适用于更复杂的流控场景。

限流的真实考量

  • 配合超时使用:即使限流,也可能因为慢任务长时间占用槽位,导致后续任务饿死。可以通过 Promise.race 给任务设置最大执行时间。
  • 错误处理及重试:限流中某个任务失败,通常不应中断整个队列,应像 allSettled 一样收集结果并决定是否重试。
  • 断路器模式:当下游大量返回 5xx,限流还不够,可能需熔断保护。

11.2.6 并发的实用最佳实践

  1. 分清串行与并行:如果任务间有依赖,必须先 A 后 B,那就只能 await 串行。若相互独立,优先用 Promise.all 并发。
  2. 使用 allSettled 做批量处理:尤其在不希望单个失败影响整体的场景,比如数据同步、通知推送。
  3. 善用 race + any 提升鲁棒性race 用于超时控制,any 用于多副本择优。
  4. 生产环境一定要限流:即便认为下游可以承受,也要设置合理的并发上限,保护自身资源,避免打开过多连接。
  5. 结合 AbortController 进行请求取消:Node.js 15+ 内置 AbortController,可以在超时或任务取消时终止 HTTP 请求,避免资源泄露。
   const controller = new AbortController();
   const timeout = setTimeout(() => controller.abort(), 5000);
   try {
     const res = await fetch(url, { signal: controller.signal });
     clearTimeout(timeout);
     // ...
   } catch (err) {
     if (err.name === 'AbortError') {
       console.log('请求已取消');
     }
   }
   
  1. 监控并发行为的性能:利用 async_hooks 或性能探测工具,观察并发任务对事件循环的占用,避免挤压定时器和其他回调。

掌握这些异步并发工具,你就能在 Node.js 中灵活地控制任务执行节奏,从最简单的全并发到精细化的流量控制,都有对应的解决方案。下一节我们将进入定时器机制,深入理解 setTimeoutsetIntervalsetImmediate 等如何在事件循环中调度,并进一步巩固对这些异步工具的理解。