并发请求控制:前端网络调度的艺术

场景题: “假设有一个需求,要求你一次性向后端发起 100 张图片的瞎子啊请求。但浏览器对同域名下的 TCP 连接数有限制(通常 Chrome 是 6 个),如果 100 个请求同时发出去,大量的请求会被挂起,甚至导致整体超时宕机。 请你手写一个并发请求控制器,要求最大并发数 max 为 3,尽可能快地完成这 100 个请求。”

原理解析

并发控制的核心思想类似**“线程池”或“排队叫号机制”**:

  1. 我们始终维持一个“正在执行的请求队列”或一个计数器,其上限必定为 max
  2. 一开始,我们拉起 max 个任务同时起跑。
  3. 关键点:只要有一个任务跑完了(Resolved 或 Rejected),就立刻把它从执行队列里踢出去,并从剩下的待执行任务里抓一个新的补上去。
  4. 如此循环往复,直到所有任务全部执行完毕。

经典手写实现(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 放行函数。