并发请求控制:前端网络调度的艺术
场景题:
“假设有一个需求,要求你一次性向后端发起 100 张图片的瞎子啊请求。但浏览器对同域名下的 TCP 连接数有限制(通常 Chrome 是 6 个),如果 100 个请求同时发出去,大量的请求会被挂起,甚至导致整体超时宕机。
请你手写一个并发请求控制器,要求最大并发数 max 为 3,尽可能快地完成这 100 个请求。”
原理解析
并发控制的核心思想类似**“线程池”或“排队叫号机制”**:
- 我们始终维持一个“正在执行的请求队列”或一个计数器,其上限必定为
max。
- 一开始,我们拉起
max 个任务同时起跑。
- 关键点:只要有一个任务跑完了(Resolved 或 Rejected),就立刻把它从执行队列里踢出去,并从剩下的待执行任务里抓一个新的补上去。
- 如此循环往复,直到所有任务全部执行完毕。
经典手写实现(Promise 控制)
面试中,最优雅的解法是利用 Promise 和异步递归来实现:
/**
* 并发控制函数
* @param {Array} urls - 需要请求的 URL 数组或任务参数
* @param {Number} max - 最大并发数
* @param {Function} fetcher - 具体的请求层函数,接受 url 返回 Promise
* @returns {Promise} 所有请求完成后的结果数组
*/
function concurrentRequest(urls, max, fetcher) {
return new Promise((resolve) => {
// 处理空边界
if (urls.length === 0) {
resolve([]);
return;
}
const results = [];
let index = 0; // 当前执行到了哪个 url 的索引
let activeCount = 0; // 当前正在路上的请求数量
let finishedCount = 0; // 已经彻底归来的请求数量
// 这是一条流水线的工人,他干完一个活,就会自动去领下一个活
async function requestWorker() {
// 退出条件:所有任务都被领光了
if (index >= urls.length) return;
// 领任务,同时主动把索引推给下一个工人
const currentUrlIndex = index++;
const currentUrl = urls[currentUrlIndex];
activeCount++;
try {
const res = await fetcher(currentUrl);
results[currentUrlIndex] = { status: 'fulfilled', value: res };
} catch (err) {
// 请求失败同样算是归槽,不要卡爆流水线
results[currentUrlIndex] = { status: 'rejected', reason: err };
} finally {
activeCount--;
finishedCount++;
// 如果全部打完收工了,向外部 resolve
if (finishedCount === urls.length) {
resolve(results);
} else {
// 如果还没干完,这个闲下来的流水线工人去领下一个任务!
requestWorker();
}
}
}
// 引擎启动:由于有 max 限制,我们就雇佣 max 个流水线工人同时开工
const poolSize = Math.min(urls.length, max);
for (let i = 0; i < poolSize; i++) {
requestWorker();
}
});
}
面试官追问
Q1:如何优雅地控制大文件上传时的并发?
答:和上述原理完全一致。在大文件切片上传中,经常会切出几百个 5MB 的 Chunk,我们必须用并发控制套在 Axios 外部。如果有其中一个切片通过上述的 Catch 捕获到了失败,通常我们会引入“重试机制(Retry)”,重试 3 次仍失败则触发外部的 AbortController 掐死所有剩下的并发请求,提示上传失败。
Q2:如果用 ES9 的异步迭代器(for await...of)怎么写?
答:可以使用业界著名的被广泛使用的三方库 p-limit 的精简原理来实现:
本质是维护一个队列 queue,一旦达到最大并发生,新的任务进不来会一直处于 await new Promise(...) 挂起状态。直到前面有人完成并在 finally 中调用了 next 放行函数。