并发请求控制实现深度解析:从手写并发池到自适应节流
同时发起大量请求会卡死浏览器、打爆服务端,并发控制用队列加最大并发数加 Promise 包装精确限制同时运行的任务数。 面试手写并发池并讲清重试与自适应节流策略。
一句话概括
同时发 100 个请求,浏览器会卡死、服务端会被打爆——并发请求控制用一个队列 + 最大并发数 + Promise 包装,精确控制”同时最多跑 N 个任务”,在高吞吐和稳定性之间找到最优解。
核心知识点
1. 手写并发池:面试三分钟出答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 核心思路:维护一个"运行中"的计数器,每完成一个就取下一个
async function asyncPool(concurrency, tasks, taskFn) {
const results = new Array(tasks.length);
let running = 0; // 当前运行中的任务数
let cursor = 0; // 下一个待处理任务的索引
return new Promise((resolve, reject) => {
function next() {
// 所有任务都完成了
if (cursor >= tasks.length && running === 0) return resolve(results);
// 尽可能发起新任务(不超过并发上限)
while (running < concurrency && cursor < tasks.length) {
const index = cursor++;
running++;
taskFn(tasks[index], index)
.then(result => { results[index] = result; })
.catch(err => { results[index] = { error: err }; })
.finally(() => {
running--;
next(); // 空出一个位置,尝试取下一个
});
}
}
next();
});
}
// 使用:最多 3 个请求同时进行
const urls = Array.from({ length: 100 }, (_, i) => `/api/item/${i}`);
await asyncPool(3, urls, (url) => fetch(url).then(r => r.json()));
面试要点:这个 20 行的实现考察了 Promise/Promise.all 的替代方案、闭包(cursor 和 running 状态管理)、错误处理(单个失败不阻断全部)。
2. 加码:失败重试 + 指数退避
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
async function asyncPoolWithRetry(concurrency, tasks, taskFn, maxRetries = 3) {
const results = new Array(tasks.length);
let running = 0, cursor = 0;
async function execute(index) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
results[index] = await taskFn(tasks[index], index);
return;
} catch (err) {
if (attempt === maxRetries) {
results[index] = { error: err, retries: attempt };
return;
}
// 指数退避:1s → 2s → 4s
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000));
}
}
}
return new Promise((resolve) => {
function next() {
if (cursor >= tasks.length && running === 0) return resolve(results);
while (running < concurrency && cursor < tasks.length) {
const index = cursor++;
running++;
execute(index).finally(() => { running--; next(); });
}
}
next();
});
}
3. 动态并发:根据网络状况自适应
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 根据请求成功率动态调整并发数
function createAdaptivePool(initialConcurrency = 3) {
let concurrency = initialConcurrency;
const recentResults = []; // 最近 20 个请求的结果
function adjust() {
const recent = recentResults.slice(-20);
if (recent.length < 10) return; // 数据不够,不调整
const failRate = recent.filter(r => r.error).length / recent.length;
if (failRate > 0.2) concurrency = Math.max(1, concurrency - 1); // 失败率高 → 降并发
else if (failRate < 0.05) concurrency = Math.min(10, concurrency + 1); // 很稳定 → 提并发
}
return {
get concurrency() { return concurrency; },
async run(tasks, taskFn) {
// ... asyncPool(concurrency, tasks, taskFn) + adjust()
}
};
}
4. 浏览器/Node.js 的并发限制
1
2
3
4
5
6
7
8
// 浏览器:同域名 HTTP/1.1 最多 6 个并发(Chrome)
// HTTP/2 多路复用没有此限制,但应用层仍需控制(避免 1000 个请求同时解析)
// Node.js:无内置限制,但需要控制(避免打爆数据库连接池)
// 后端一般设 10-20 并发访问同一个下游服务
// 关键:并发控制 ≠ 限制连接数,而是限制"同时执行的任务数"
// 浏览器 6 连接限制只是连接层,应用层并发池保证的是"逻辑任务并发"
5. Promise.all 的误区
1
2
3
4
5
6
7
8
9
10
// ❌ 1000 个请求一起飞 → 内存爆炸 + TCP 连接耗尽
const results = await Promise.all(urls.map(url => fetch(url)));
// ✅ 用并发池控制
const results = await asyncPool(5, urls, url => fetch(url).then(r => r.json()));
// ✅ 或者用 p-limit(npm 最流行的并发库)
import pLimit from 'p-limit';
const limit = pLimit(5);
const results = await Promise.all(urls.map(url => limit(() => fetch(url))));
其实你每天都在用
- 浏览器下载管理器:你同时下载 5 个文件,浏览器把它们排队一一处理——内置并发池
- VS Code 扩展安装:一次安装 10 个插件,npm/pnpm 是串行还是并行的——pnpm 默认用并发池,同时最多下载 N 个包
- Webpack 的 thread-loader:把 JS 编译任务分给多个 Worker 线程,也是并发池模式
- 网盘批量下载:你选了 50 张照片下载,网盘不会一次发 50 个请求,而是 3-5 个一批
常见误解(FAQ)
❌ 误区:Promise.all 是真正的”并行”
JavaScript 是单线程的(除 Worker),Promise.all 只是并发发起异步操作(网络请求等 I/O 是浏览器/系统层执行的),但 JS 代码本身仍然在单线程上执行回调。Promise.all 不限制并发数——你传 1000 个 Promise 进去它会全部同时开始。
❌ 误区:HTTP/2 多路复用让并发控制不再必要
HTTP/2 解决了连接数限制,但没解决服务端压力。同时发 1000 个请求,即使都在一个连接上,服务端处理 1000 个并发查询的压力是一样的。而且前端解析 1000 个响应数据也会占用大量内存和 CPU。
❌ 误区:并发数设成 CPU 核心数就行
CPU 核心数适用于计算密集型(Worker),但请求并发是 I/O 密集型。I/O 并发数由”网络带宽 / 单请求速度 + 服务端限流”决定,通常 3-6 对浏览器最合适。
❌ 误区:async/await 和 Promise.all 语义一样
await 是串行:for (const url of urls) { await fetch(url); } —— 第二个请求等第一个完成才发。Promise.all 是并发:所有请求同时发出。需要顺序依赖用 await,不依赖顺序用并发池。
一句话总结
并发请求控制的核心就是一个计数的 Promise 包装器——running 计数 + cursor 游标 + 完成后自动取下一个,三个状态就搞定了所有复杂场景。