Skip to content

有界并发与调度:快不等于一次启动全部 ​

对十个 Promise 使用 all 通常很方便,对十万个任务直接 map 后 all 就可能耗尽连接、内存或服务配额。Promise 组合器管理结果,不管理任务启动数量。限制并发的关键是保存“尚未调用的函数”或尚未处理的输入。

一个有限并发的映射器 ​

js
async function mapLimit(items, limit, mapper) {
  if (!Number.isInteger(limit) || limit < 1) throw new RangeError('limit');
  const output = new Array(items.length);
  let next = 0;
  let failed = false;
  async function worker() {
    while (!failed) {
      const index = next++;
      if (index >= items.length) return;
      try { output[index] = await mapper(items[index], index); }
      catch (error) { failed = true; throw error; }
    }
  }
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
  return output;
}
mapLimit([3, 1, 2], 2, async n => n * 10).then(console.log);
// [30, 10, 20]

最多创建 limit 个 worker。每个 worker 在取下一项之前等待当前项,所以并发上限由活跃 worker 数量控制。next++ 在 await 之前同步完成,同一 agent 中不会被另一个 worker 从中间打断。结果放回原索引,完成顺序不改变返回顺序。

失败标志防止观察到失败后继续启动新工作,已启动的 mapper 仍会运行。函数返回的 Promise 在首次失败时拒绝,不等待那些工作真正结束。若调用者要求“返回之前所有副作用已经收尾”,应等待所有 worker 落定后再报告错误;若要求终止,mapper 必须接收并响应取消信号。这个边界比多写一个 catch 更重要。

先失败返回,还是全部收尾再返回 ​

用两个可控 Promise 把这个区别看清楚:

js
let finishSlow;
const slow = new Promise(resolve => { finishSlow = resolve; });
const failed = Promise.reject(new Error('先失败'));
const all = Promise.all([failed, slow]);
try { await all; }
catch (error) { console.log(error.message); } // 先失败
console.log('此时慢任务还没完成');
finishSlow('慢任务结束');
console.log(await slow); // 慢任务结束

mapLimit 的 worker 也是这样:all 的失败报告时刻与所有 worker 退出时刻不同。需要“函数退出时所有活跃任务已结束”的接口,可以保存 worker Promise,等待 allSettled,再决定抛出哪一个错误。这会等待不响应取消的任务,甚至可能一直等待,因此不能只把 all 替换成 allSettled 就声称同时实现了及时失败和完整收尾。

另一个细节是“第一个失败”:按输入索引第一个失败的任务、时间上最早观察到的失败,不一定是同一个。若需要保留时间上最早的错误,应该在 worker catch 时记录一次,而不是最后从 allSettled 数组中寻找第一项 rejected。错误选择也是接口语义,不该偶然取决于数组顺序。

并发限制与背压还差一步 ​

上面的 items 已经全在内存里。如果生产者持续产生数据,把所有输入提前存进数组,仍可能无限增长。背压让消费速度反馈到生产端:消费者处理不完时,生产者减少或暂停生产。下一章的异步迭代采用按需 next,Streams 则加入队列容量与上下游传播。

并发数量也不等于 CPU 并行数量。十项网络等待可以交错进行,十个长同步循环仍会阻塞同一线程。Worker 才提供独立执行环境,但通信、数据复制与任务拆分都有成本。

重试的前提是允许重复 ​

一次超时不能证明服务端没有完成。创建订单的请求可能已成功,只是响应丢失;盲目重试会重复创建。是否重试由操作语义决定:读请求通常较容易重试,写请求需要服务端支持幂等键或其他去重约定。

重试应有最大次数、总时间预算、指数退避和随机抖动,并区分临时网络故障、限流、永久参数错误与用户取消。退避期间也应响应取消;服务端提供 Retry-After 时应按协议处理。最后一次失败要保留上下文,不要只抛一句“重试失败”抹掉原因。

用一个小重试器看清尝试次数和重试条件 ​

js
async function retry(run, { attempts, canRetry, wait, signal }) {
  if (!Number.isInteger(attempts) || attempts < 1) throw new RangeError('attempts');
  for (let attempt = 1; ; attempt++) {
    signal?.throwIfAborted();
    try { return await run(attempt, signal); }
    catch (error) {
      signal?.throwIfAborted();
      if (attempt >= attempts || !canRetry(error)) throw error;
      await wait(attempt, signal);
    }
  }
}
const trace = [];
const result = await retry(async attempt => {
  trace.push(`尝试${attempt}`);
  if (attempt < 3) throw Object.assign(new Error('稍后可再试'), { transient: true });
  return '成功';
}, {
  attempts: 3,
  canRetry: error => error.transient === true,
  wait: async attempt => { trace.push(`退避${attempt}`); }
});
console.log(result); // 成功
console.log(trace.join(',')); // 尝试1,退避1,尝试2,退避2,尝试3

attempts 是包括首次执行在内的总尝试上限。只有明确标记的临时故障才重试,最后一次失败不再退避;run 的同步抛错与异步拒绝都在 await 处进入同一个处理路径。wait 被作为函数传入,让示例无需真实等几秒,实际环境可实现带抖动的延迟并响应 signal。

这个实现不判断操作是否幂等,那是调用方对 run 的约定;也不自动设定总时间预算。若 run 已成功,随后才有取消,不会倒转成功结果。若等待期间取消,wait 必须自己响应信号,或者至少在等待结束后由下一轮检查拒绝。每一层都要准确说明能取消的阶段。

微任务让得出调用栈,不一定让得出页面 ​

循环中 await Promise.resolve() 会安排微任务恢复,但微任务检查点持续清空队列,浏览器仍可能无法进入渲染或其他任务。递归 queueMicrotask 同样可能造成饥饿。要给交互机会,可以按时间预算分片并通过宿主任务调度让出执行;单纯增加更多 await 不等于更流畅。

requestAnimationFrame 适合在下一次绘制前协调轻量视觉更新,不适合塞入大量计算。后台页面可能暂停或降低回调频率。调度策略应服务于响应时间,而不是只追求吞吐量。

下面用有限三轮观察“微任务里继续加微任务”,不运行无限循环:

js
const events = [];
setTimeout(() => console.log(events.join(',')), 0);
let remaining = 3;
function continueWork() {
  events.push(`微任务${remaining}`);
  if (--remaining > 0) queueMicrotask(continueWork);
}
queueMicrotask(continueWork);
// 计时器最后输出 微任务3,微任务2,微任务1

计时器已经安排,但当前微任务检查点会把新加入的微任务继续处理。把 remaining 去掉,就可能不断占住这个阶段。若把工作分片以后又只用微任务连接各片,虽然函数调用栈每次清空,宿主任务仍未获得机会;因此“没有长栈”与“界面能响应”是两件事。

Node 不能照搬浏览器排序口诀 ​

Node 具有 timers、poll、check 等宿主阶段,setImmediate 与 I/O、计时器的关系受调用位置影响。Node 20 起 libuv 计时器阶段变化也会影响边界行为。不要在顶层代码中断言 setTimeout(0) 与 setImmediate 永远固定先后。

process.nextTick 也不是普通 Promise 微任务。在通常的 CommonJS 同步入口,nextTick 会先于 Promise 微任务处理;ES module 顶层本身在异步模块执行路径中,示例顺序可能不同。研究输出时应同时记录 Node 版本、.cjs/.mjs、入口位置及是否位于 I/O 回调。语言保证的 Promise 链因果关系与宿主阶段安排必须分开。

练习 ​

  1. 把已创建的 Promise 数组传给 mapLimit,能限制它们的启动吗?

  2. mapper 抛错之后,这个实现保证什么、不保证什么?如果要求函数退出前所有活跃任务都已收尾,需要改变什么承诺?

  3. 为什么反复 await 已兑现 Promise,页面仍可能卡住?

  4. 沿用本节 mapLimit,写 mapLimitResults(items, limit, mapper):保留并发上限和输入顺序,每项成功记录 {status: "fulfilled", value},失败记录 {status: "rejected", reason},一项失败后仍处理剩余项。用 [1,2,3] 验证第 2 项失败、第 3 项仍成功,并记录最大活跃数不超过 2。

查看答案

用心记录每一步成长