[转] 封装并发任务方法

作者:谢杰

该文章是并发异步操作系列文章第五篇。

有了前面几篇文章所介绍的知识铺垫后,本系列最终篇,我们来封装一个能够指定并发上限的方法。

需求

先来过一下需求,封装一个异步方法 runWithConcurrency,如下:

async function runWithConcurrency(items, worker, maxConcurrency) {}

该方法接收 3 个参数:

  • items:要处理的任务列表
  • worker:处理单个任务的异步函数
  • maxConcurrency:最大并发量

先假设这个方法我们已经写好了,那么外部在使用的时候,大概是这么用的:

// 示例 1:模拟任务
const items = Array.from({ length: 8 }, (_, i) => i + 1);

const worker = async (n) => {
  // 模拟耗时 200~800ms 的异步任务
  await new Promise((r) => setTimeout(r, 200 + Math.random() * 600));
  console.log(`处理完成:task=${n}`);
  return n * n; // 返回结果
};

const results = await runWithConcurrency(items, worker, 3);
console.log('结果(顺序与输入一致):', results);
// 示例 2:真实网络请求(批量拉取)
const urls = [
  '/api/user/1',
  '/api/user/2',
  '/api/user/3',
  '/api/user/4',
  '/api/user/5',
];

const fetchUser = async (url) => {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`请求失败:${res.status}`);
  const data = await res.json();
  console.log('已获取:', url);
  return data;
};

const users = await runWithConcurrency(urls, fetchUser, 2);
console.log('所有用户:', users);

分析与实现

明确了需求后,接下来我们来逐步实现。假设外界调用的时候,是这么调用的:

await runWithConcurrency(urls, fetchUser, 2);

那么这里传递的 2 是什么?

没错,是并发的上限,我们可以将其想象成有两个工人,如下图

┌──────────────────────────────────────────────────┐
│ [任务五]  [任务四]  [任务三]  [任务二]  [任务一] │  ← i(下一个)
└──────────────────────────────────────────────────┘
                                │         │
                                │         │
                                ▼         ▼
                              工人 2     工人 1

工人1 先领取了任务一,然后开始执行任务一

工人2 领取任务二,然后开始执行任务二

那么任务三交给谁呢?究竟由工人1 领取还是工人2 领取呢?

那得看哪一个工人的工作先完成,假设工人1 先完成了任务一,那么这个工人就去领取任务三;反之,如果是工人2 先完成了任务二,则是由工人2 去领取任务三。依此类推,后面的任务四、任务五也是这样,哪个工人手上没活了,就去领取下一个任务。这和我们现实生活中的场景,也是一致的。

这种模式在代码里对应的是:固定数量的工人 + 一个共享的“下一个任务”指针

接下来落地到具体的代码。首先,我们来确定工人的人数:

async function runWithConcurrency(items, worker, maxConcurrency) {
  const n = Math.max(1, Math.min(maxConcurrency, items.length));
}

这里的 n 是实际工人的人数。有的同学会觉得很奇怪,为什么不直接用 maxConcurrency?因为:

  • 工人数不能超过任务总数(否则有些工人一上来就没活干)
  • 至少要有 1 个工人

接下来这 n 个工人开始干活:

const workers = [];
for (let k = 0; k < n; k++) workers.push(spawn());

这里的 workers 用来收集每个工人(spawn())返回的 Promise。一旦调用 spawn(),工人就会立刻去领取任务并开始执行;数组里收集的是“工人何时收工”的 Promise,而不是每个任务的 Promise。

紧接着是“工人如何领活儿”。该方法需要不断地把任务分配给工人,直到任务列表为空:

let i = 0; // 共享任务指针:指向“下一个要处理的下标”

async function spawn() {
  // 每个工人都需要不断地从任务队列中领取任务
  while (i < items.length) {
    const idx = i++;                 // 领取当前要处理的任务下标
    await worker(items[idx], idx);   // 处理单个任务(工人内部串行)
    // 处理完继续回到 while,再领下一单;直到任务列表为空
  }
  // 跳出 while:说明任务已经被领完,这个工人可以正常收工
}

这里要强调两点:

  • 每个工人内部是串行的:比如工人 1 领了任务一,必须等这单完成(await 返回)后才能去领下一单。并发来自“有 n 个工人同时在干”,而不是让单个工人内部再并发。
  • 共享指针是安全的const idx = i++ 这一步在同一次调用栈里是同步完成的(读取→使用→自增),不会被别的工人中途打断,因此不会出现两个工人领到同一单。真正“让出执行权”的地方发生在 await worker(...) 之后。

最后,等待所有工人完工,这里使用 Promise.allSettled

await Promise.allSettled(workers);

这一步不会启动任务,只是“在门口等所有工人跑完自己手上的任务”。

allSettled 的好处是:即便某个工人因为某一单失败而变成 rejected,也不会短路,其他工人仍会把剩下的活干完,更符合“批量任务尽量跑完”的诉求。

最终完整的代码如下:

async function runWithConcurrency(items, worker, maxConcurrency) {
  if (!items?.length) return;
  
  // i 是“下一个要被领取的任务索引”。多个工人共享这个变量。
  // 在 JS 的单线程事件循环中,“读取 i -> 使用 i -> i++”这一小段代码在一次
  // 宏/微任务中是原子的(不会被其他 JS 执行栈打断),因此可作为简单的任务分发指针。
  let i = 0;

  // workers 用来收集每个“工人”的 Promise,后面用 allSettled 等待他们全部结束。
  const workers = [];

  // spawn = 启动一个工人:不停从任务池里领取下一个索引并处理,直到没有任务可领
  async function spawn() {
    // 当还有未处理的任务(i < items.length)就继续循环
    while (i < items.length) {
      // 领取当前要处理的任务索引,然后自增以留给下一个任务
      const idx = i++;
      // 执行该任务:必须 await,保证这个工人在“串行处理自己的任务队列”
      // 如果不 await,就会在单个工人内部产生更高的并发,超出 maxConcurrency 的约束
      await worker(items[idx], idx);
      // 若 worker 抛错(reject),该 spawn() 的 Promise 会变为 rejected;
      // 但我们外层会用 Promise.allSettled,所以不会影响其它工人继续工作。
    }
    // 退出 while:说明任务已经被领完,这个工人可以正常收工(resolve)
  }

  // 计算实际需要启动的工人数:
  // - 不能超过任务总数(否则有些工人一上来就没活干)
  // - 至少 1 个
  const n = Math.max(1, Math.min(maxConcurrency, items.length));

  // 启动 n 个工人,每个工人都是一个独立的异步执行体(Promise)
  for (let k = 0; k < n; k++) workers.push(spawn());

  // 等待所有工人“收工”。使用 allSettled:
  // - 不会因为某个工人失败而中断等待(all 会短路,allSettled 不会)
  // - 适合“多个任务相互独立,允许部分失败也要跑完”的场景
  await Promise.allSettled(workers);
  // 函数到这里 resolve,表示全部任务都已尝试完成(成功或失败),所有工人均结束。
}

至此,带并发上限的并发执行方法就写完了。

写在最后

这一次我们实现了通用的 runWithConcurrency(items, worker, maxConcurrency),用固定数量的工人配合共享游标分发任务,工人内部串行、整体受控并发;配套示例也证明它能稳妥跑完批量请求和本地批处理场景。

使用时有几处要点值得牢牢记住:

  1. 并发从调用 spawn() 的那一刻就开始了,Promise.allSettled(workers)只是在门口把所有工人等到收工,并不会启动任务、更不会决定顺序;
  2. 共享指针用的是 const idx = i++,在单线程调用栈里是同步完成的,不会出现两个人领到同一单;
  3. 工人内部必须 await worker(...),否则单个工人会把并发继续堆高,超出你的 maxConcurrency
  4. 另外,workers 装的是“工人的promise”而不是“每个任务的promise”,若需要拿回结果,可以在工人里按下标写回一个 out[idx] 再返回。空列表可以直接早退,maxConcurrency 也最好做下限与取整的校验。

落到真实项目,还方法可以继续打磨:

  1. 给它包上超时与取消,必要时做重试与指数退避;
  2. 按需暴露进度回调与观测埋点,用数据调参;
  3. 在高压场景里引入优先级、背压和动态并发,保护上下游。

掌握并灵活运用这套基建,小到脚本批处理,大到服务侧限流,你的异步就能既稳又快。


-EOF-

posted @ 2025-10-30 14:43  Zhentiw  阅读(4)  评论(0)    收藏  举报