Skip to content

100 个请求同时发送咋办

100 个请求控制并发

一、 方案一:asyncPool(经典面试标准答案,最稳健)

核心思想:维护一个 executing(正在执行的任务池)。利用 Promise.race 监听池子里最快完成的任务,完成后立即从池子里移除,并塞入下一个新任务。

javascript
/**
 * 控制并发数的异步任务池
 * @param {number} limit - 最大并发数(建议浏览器中设为 5 或 6)
 * @param {Array} array - 要处理的数据列表(比如 100 个 URL 或 ID)
 * @param {Function} iteratorFn - 针对每个数据项执行的异步函数(返回 Promise)
 * @returns {Promise} - 所有任务完成后的结果数组(顺序与原数组一致)
 */
async function asyncPool(limit, array, iteratorFn) {
  // 1. 存放所有任务的 Promise(用于保持输出顺序)
  const ret = [];
  // 2. 存放当前正在执行的 Promise 列表(用于做并发控制)
  const executing = [];

  for (const [index, item] of array.entries()) {
    // 3. 核心:构造当前项的任务 Promise
    // 注意:这里用 Promise.resolve().then() 是为了确保 iteratorFn 返回的是一个 Promise
    const p = Promise.resolve().then(() => iteratorFn(item, index, array));
    ret.push(p);

    // 4. 并发控制逻辑(只有当数组长度大于并发限制时才触发)
    if (limit <= array.length) {
      // 当任务 p 完成后,从 executing 队列中移除自己
      const e = p.then(() => executing.splice(executing.indexOf(e), 1));
      executing.push(e);

      // 如果当前正在执行的任务数 >= 限制,就使用 race 等待任意一个完成
      if (executing.length >= limit) {
        await Promise.race(executing);
      }
    }
  }

  // 5. 返回所有结果(按顺序)
  return Promise.all(ret);
}
javascript
// 场景:进入会话时,需要拉取 100 条历史消息,但浏览器同域名并发限制为 6
const messageIds = Array.from({ length: 100 }, (_, i) => i);

asyncPool(5, messageIds, async (id) => {
  // 模拟拉取单条消息(控制并发为 5,服务器压力更小)
  const res = await fetch(`/api/message/${id}`);
  return res.json();
}).then((results) => {
  // 结果集顺序依然是 [msg0, msg1, msg2, ...]
  dispatch(updateMessages(results));
});

二、 方案二:pLimit 精简版(源码风格,面试极大加分项)

如果你遇到的是高级 TL,他可能不满足于 asyncPool,而是想看你如何抽象出一个通用的“任务调度器”。这时候使用闭包 + 队列pLimit 风格会让你直接封神。

javascript
/**
 * 创建一个并发控制函数(类似 p-limit 库的核心实现)
 * @param {number} concurrency - 最大并发数
 * @returns {Function} - 接收一个异步函数并执行,返回该异步函数的 Promise 结果
 */
function pLimit(concurrency) {
  // 1. 任务队列(存的是一个个“任务执行函数”)
  const queue = [];
  // 2. 当前正在运行的任务数
  let activeCount = 0;

  // 3. 任务执行器:从队列头部取出一个任务执行
  const next = () => {
    activeCount--;
    if (queue.length > 0) {
      // 取出队头并执行
      queue.shift()();
    }
  };

  // 4. 运行单个任务
  const run = async (fn, resolve, args) => {
    activeCount++;
    // 执行用户传入的异步函数,并拿到结果
    const result = (async () => fn(...args))();
    // 将结果回传给外层的 Promise
    resolve(result);
    // 无论成功还是失败,执行完毕后调用 next 取出下一个任务
    try {
      await result;
    } catch (_) {}
    next();
  };

  // 5. 暴露给外部的函数(接收异步函数和参数)
  const enqueue = (fn, ...args) => {
    return new Promise((resolve) => {
      // 如果并发数未满,立即执行;否则存入队列等待
      if (activeCount < concurrency) {
        run(fn, resolve, args);
      } else {
        queue.push(() => run(fn, resolve, args));
      }
    });
  };

  return enqueue;
}
javascript
// 创建并发限制为 5 的调度器
const limit = pLimit(5);

const tasks = Array.from({ length: 100 }).map((_, i) =>
  // 每个任务都通过 limit 包装,自动进入队列排队
  limit(async () => {
    const res = await fetch(`/api/message/${i}`);
    return res.json();
  })
);

// 等待所有任务完成(但底层同时最多只跑 5 个请求)
const results = await Promise.all(tasks);

三、 面试官一定会追问的“灵魂三问” & 你的满分回应

追问 1:“asyncPool 中,为什么 p 外面要包一层 Promise.resolve().then()?”

你的回答:“这样做的目的是确保 iteratorFn 返回的结果一定是一个 Promise,即使有人不小心传入了一个同步函数或普通值,它也能被正常转换为 Promise 进行异步控制,这能极大增强代码的健壮性。”

追问 2:“如果你不做并发控制,直接 Promise.all(100 个请求),除了浏览器限制,还有什么坏处?”

你的回答(结合 WeLink):“第一,浏览器同域名并发限制是 6 个,多发只会排队,不会变快。第二,服务器压力陡增,瞬间 100 条数据库查询可能会把接口打挂。第三,客户端内存和网络负载,在 WeLink 这种桌面端应用上,瞬间发起过多请求会造成 CPU 飙升和界面卡顿。所以控制并发本质是为了保护客户端和服务器的双重稳定。”

追问 3:“如果某个请求失败了(rejected),你的 asyncPool 怎么处理?”

你的回答:“如果是标准实现,Promise.race 会因为 reject 抛出错误而打断整个流程。在真实业务中(比如上报日志或拉取非关键资源),我会在 iteratorFn 内部做 catch 兜底(比如返回 null 或重试),确保单个任务失败不影响整体流程。这也是我在 WeLink 做消息拉取时的保底策略。”

在二面中,如果面试官看到你写 pLimit,他大概率会追问一句:“你之前写的那个 asyncPool 和这个 pLimit 有什么区别?为什么不用那个?

一、 核心区别(一句话说清)

  • asyncPool 是“一次性批量处理工具”:你给它一堆数据,它帮你跑完就结束了。
  • pLimit 是“可复用的任务调度器”:它只负责管“并发数”,你可以随时往里塞任务,它一直在待命。

二、 深入对比(面试官想要的“工程视角”)

对比维度asyncPoolpLimit
调用方式asyncPool(5, array, fetchFn)const limit = pLimit(5);limit(fetchFn)
任务来源静态:必须提前准备好所有数据(数组)动态:可以随时、任意时刻塞入新任务(比如滚动加载)
可复用性一次性的:只处理传入的这一批数据全局复用的:创建后整个应用生命周期都可使用
对外暴露暴露结果(Promise.all 返回的数组)暴露“执行器”(调度函数)
内部机制直接 for 循环启动 + race 占位使用 queue(队列)+ next(递归消费)

三、 面试官追问 & 你的满分回应

面试官追问:“在你真实的 WeLink 消息拉取场景里,你更倾向用哪个?”

你的满分回答(结合业务场景)

“面试官,这两个我在项目中都实践过,它们有非常明确的分工:

1. 对于asyncPool(批处理场景): 比如当用户进入一个会话窗口时,我需要一次性拉取首屏 20 条历史消息。这时候数据是确定的、有限的,我用 asyncPool(5, [id1...id20], fetchDetail) 直接批量处理就足够了,代码简单清晰。

2. 对于pLimit(动态调度场景): 但是,当用户向上滚动加载更多历史消息时,任务是动态、不可预知的。用户可能在 10 分钟内滚动加载 5 次,也可能不滚动。这时候 asyncPool 就不适用了。

我会在组件初始化时创建一个 const limit = pLimit(5);,这个调度器是常驻的。每当用户滚动触发加载,我就直接调用 limit(fetchMoreData)这样一来,不仅控制了全局并发,还实现了请求去重——如果用户瞬间触发了多次滚动加载,由于并发池满了,后续请求自然会进入队列排队,不会重复发起。

总结:asyncPool 适合‘批处理’;pLimit 适合‘流式处理’。 如果让我做通用库,我会优先封装 pLimit,因为它更底层、更灵活。”

四、 补充加分点

面试官如果点头认可,你可以再补一句关于“错误处理”的差异:

“还有一个容易被忽略的差异:asyncPool 是所有任务结束后一起返回结果(Promise.all),如果其中某一个失败且没做 catch,会直接导致整体失败(快速失败)。而 pLimit 每个任务都是独立执行、独立 resolve/reject 的,配合 allSettled 使用,能让非关键资源(比如头像加载)失败不影响正文渲染,健壮性更高。”

背景及解题思路

最近有个同学在面试中遇到了一个问题:“假设有 100 个请求需要发送,你会如何设计一个算法用 Promise 来控制并发请求(最大并发数为 10),以完成全部 100 个请求呢?” 想要回答这个问题,那么首先,我们可以先模拟 100 个请求出来:

js
const requestList = [];

for (let i = 1; i <= 100; i++) {     
	requestList.push(
    	() =>
        	new Promise(resolve => {           
				setTimeout(() => {             
					console.log('done', i);             
					resolve(i);           
				}, Math.random() * 1000);         
			}),     
	);

在上面的代码中,我们创建一个数组 requestList ,然后利用循环放进去了 100 个 promise 用来模拟请求。

每个 promise 中都通过 Math.random() * 1000 创建了一个随机的延迟时间,用来模拟网络延迟

那么在这样的一个场景下有两种常见方式:

  1. Promise.all()

  2. Promise.allSettled()

  3. 维护线程池

01:Promise.all()

Promise.all() 静态方法接受一个 Promise 可迭代对象作为输入,并返回一个 Promise。当所有输入的 Promise 都被兑现时,返回的 Promise 也将被兑现(即使传入的是一个空的可迭代对象),并返回一个包含所有兑现值的数组。如果输入的任何 Promise 被拒绝,则返回的 Promise 将被拒绝,并带有第一个被拒绝的原因。

通过 Promise.all() 我们可以通过以下代码进行实现

js
// 定义一个异步函数,用于并发运行请求
const parallelRun = async (max) => {
  const requestSliceList = [];
  // 用于存储分片后的请求列表
  // 将请求列表按max大小分片
  for (let i = 0; i < requestList.length; i += max) {
    requestSliceList.push(requestList.slice(i, i + max));
    // 每max个请求为一组
  }
  // 按顺序执行每个请求分片
  for (let i = 0; i < requestSliceList.length; i++) {
    const group = requestSliceList[i];
    // 当前分片组
    try {
      // 并行执行当前分片组的所有请求
      const res = await Promise.all(group.map((fn) => fn()));
      console.log("Response:", res);
      // 输出当前分片组的响应结果
    } catch (error) {
      // 处理请求失败的情况
      console.error(error);
      // 输出错误信息
    }
  }
};
// 调用parallelRun函数,并行执行请求,最大并发数为max   parallelRun(10)
// 例如最大并发数为10

每次并发 10 个请求,等这 10 个请求完成后,再发送接下来的 10 个请求,完美满足需求。

但是,这样去做会有一些问题。如果在大厂面试中,那么一般会被追问:如果其中一个请求失败会发生什么?

因为,对于 promise.all() 来说,一个请求失败,那么 整个请求会全部失败。所以说,如果组中的一个请求失败,则无法检索该组中其他成员的返回值。这在实际业务中,可能就会出现问题。

那么怎么办呢?

02:Promise.allSettled()

Promise.allSettled() 静态方法将一个 Promise 可迭代对象作为输入,并返回一个单独的 Promise。当所有输入的 Promise 都 已敲定(不是必须全部成功) 时(包括传入空的可迭代对象时),返回的 Promise 将被兑现,并带有描述每个 Promise 结果的对象数组。

即:即使某个 Promise 被拒绝,Promise.allSettled 也会等待所有 Promise 完成,并且不会因为某个 Promise 被拒绝而中断。

我们可以直接使用 Promise.allSettled 代替 Promise.all,并增加失败的概率:

js
// 往请求列表中添加100个请求函数
for (let i = 1; i <= 100; i++) {
  requestList.push(
    () =>
      new Promise((resolve, reject) => {
        setTimeout(() => {
          console.log("done", i);
          // 被认定为失败
          if (Math.random() * 100 > 90) {
            reject(new Error("请求失败了~~"));
          } else {
            resolve(i);
          }
        }, Math.random() * 1000);
      })
  );
}

这样我们就可以解决 单个 promise 被拒绝后导致一组会全部失败的问题了

但是,这样的解决方式依然不够完美。因为使用 Promise.all() 或者 Promise.allSettled() 来并发处理 10 个请求,确实可以满足并发需求,但是效率较低,如果存在一个或多个慢接口,就会出现两个问题:

  1. 接口较慢的并发组的返回会非常慢;一个较慢的接口会延迟其他九个,这是适得其反的。

  2. 虽然我们有能力处理 10 个并发请求,但是一个接口慢就导致该组中其他 9 个并发槽被浪费,从而残忍地延长了这 100 个接口的并发时间。慢接口组后面的后续并发组都被阻塞,使其变得更慢。

因此,就需要使用第三种方式

03:维护线程池

我们可以维护一个运行池和一个等待队列,运行池中始终保持 10 个请求并发。

当运行池中一个请求完成后,就从等待队列中取出一个新请求放入运行池中运行,保证运行池始终满负荷运转,即使出现慢接口,也不会阻塞后续接口入池。

js
// 运行池,用于存储当前正在执行的请求
const pool = new Set();
// 等待队列,用于存储等待执行的请求
const waitQueue = [];
/**
@description: 限制并发请求的数量
@param {*} reqFn: 请求方法(返回一个 Promise 的函数)
@param {*} max: 最大并发数
@returns {Promise} 返回一个 Promise,当请求完成时 resolve 或 reject
 */
const request = (reqFn, max) => {
  return new Promise((resolve, reject) => {
    // 检查运行池是否已满
    const isFull = pool.size >= max;
    // 包装新的请求方法
    const newReqFn = () => {
      reqFn()
        .then((res) => {
          resolve(res);
          // 请求成功时 resolve
        })
        .catch((err) => {
          reject(err);
          // 请求失败时 reject
        })
        .finally(() => {
          // 请求完成后,将其从运行池中移除
          pool.delete(newReqFn);
          // 从等待队列中取出新的请求并放入运行池中执行
          const next = waitQueue.shift();
          if (next) {
            pool.add(next);
            next();
          }
        });
    };
    if (isFull) {
      // 如果运行池已满,将新的请求放入等待队列
      waitQueue.push(newReqFn);
    } else {
      // 如果运行池未满,将新的请求加入运行池并执行
      pool.add(newReqFn);
      newReqFn();
    }
  });
};
// 遍历 requestList,并发执行每个请求,限制最大并发数为 10
requestList.forEach(async (item) => {
  const res = await request(item, 10);
  // 调用 request 函数,并发执行请求
  console.log(res);
  // 输出每个请求的结果
});
最近更新