文章

并发请求控制实现深度解析:从手写并发池到自适应节流

同时发起大量请求会卡死浏览器、打爆服务端,并发控制用队列加最大并发数加 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 游标 + 完成后自动取下一个,三个状态就搞定了所有复杂场景。

本文由作者按照 CC BY 4.0 进行授权