100 个请求同时发送咋办
100 个请求控制并发
一、 方案一:asyncPool(经典面试标准答案,最稳健)
核心思想:维护一个 executing(正在执行的任务池)。利用 Promise.race 监听池子里最快完成的任务,完成后立即从池子里移除,并塞入下一个新任务。
/**
* 控制并发数的异步任务池
* @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);
}结合你的 WeLink 场景使用:
// 场景:进入会话时,需要拉取 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 风格会让你直接封神。
/**
* 创建一个并发控制函数(类似 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;
}使用演示(WeLink 批量拉取未读消息):
// 创建并发限制为 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是“可复用的任务调度器”:它只负责管“并发数”,你可以随时往里塞任务,它一直在待命。
二、 深入对比(面试官想要的“工程视角”)
| 对比维度 | asyncPool | pLimit |
|---|---|---|
| 调用方式 | 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 个请求出来:
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 创建了一个随机的延迟时间,用来模拟网络延迟
那么在这样的一个场景下有两种常见方式:
Promise.all()Promise.allSettled()维护线程池
01:Promise.all()
Promise.all()静态方法接受一个 Promise 可迭代对象作为输入,并返回一个 Promise。当所有输入的 Promise 都被兑现时,返回的 Promise 也将被兑现(即使传入的是一个空的可迭代对象),并返回一个包含所有兑现值的数组。如果输入的任何 Promise 被拒绝,则返回的 Promise 将被拒绝,并带有第一个被拒绝的原因。
通过 Promise.all() 我们可以通过以下代码进行实现
// 定义一个异步函数,用于并发运行请求
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,并增加失败的概率:
// 往请求列表中添加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 个请求,确实可以满足并发需求,但是效率较低,如果存在一个或多个慢接口,就会出现两个问题:
接口较慢的并发组的返回会非常慢;一个较慢的接口会延迟其他九个,这是适得其反的。
虽然我们有能力处理 10 个并发请求,但是一个接口慢就导致该组中其他 9 个并发槽被浪费,从而残忍地延长了这 100 个接口的并发时间。慢接口组后面的后续并发组都被阻塞,使其变得更慢。
因此,就需要使用第三种方式
03:维护线程池
我们可以维护一个运行池和一个等待队列,运行池中始终保持 10 个请求并发。
当运行池中一个请求完成后,就从等待队列中取出一个新请求放入运行池中运行,保证运行池始终满负荷运转,即使出现慢接口,也不会阻塞后续接口入池。
// 运行池,用于存储当前正在执行的请求
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);
// 输出每个请求的结果
});