文章

并发请求控制实现深度解析:从任务队列到自适应节流

并发请求控制实现深度解析:从任务队列到自适应节流

一句话概括

并发请求控制通过精心设计的任务队列、最大并发限制、失败重试与动态节流机制,在有限资源下实现吞吐量与稳定性的最优平衡,是现代前端应用处理海量异步操作的核心基础设施。

背景与意义

并发之痛

前端应用在以下场景中极易遭遇并发失控:

  1. 批量数据加载:CMS 管理后台需要同时获取 1000 个商品详情,如果一次性发出 1000 个请求,浏览器会瞬间创建大量 TCP 连接,消耗系统资源的同时触发同域名并发限制(HTTP/1.1 下 Chrome 限制同域名最多 6 个并发)。
  2. 文件批量上传:选择 50 张高清图片同时上传,无控制的并发会导致带宽争抢,每个请求速度急剧下降,甚至触发浏览器层级的 socket 池耗尽。
  3. WebSocket 消息风暴:IM 应用收到历史同步消息后,需要批量加载引用的资源,上千个资源请求同时发起会拖垮主线程。
  4. 第三方 API 限流:上游 API 返回 429 Too Many Requests,迫使我们必须在客户端做流控。

为什么不能靠浏览器自动解决

浏览器虽然对每个域名有默认的并发连接数限制(Chrome 6,Firefox 6),但这远远不够:

  • HTTP/2 多路复用虽然取消了这个限制,但应用层面的限流(如避免同时发起 1000 个请求处理大量数据)仍需自行实现
  • 浏览器限制是针对 TCP 连接数,而非请求数,在 HTTP/2 场景下单连接可以并发数十个请求,但这会给服务端带来同样大的压力
  • 并发控制不仅是”限流”,还包括任务优先级、取消、重试、超时等多种治理策略

概念与定义

核心概念

概念定义
任务队列 (Task Queue)存储待执行异步操作的 FIFO 队列
并发上限 (Max Concurrency)同时执行中的异步操作最大数量
活跃任务 (Active Tasks)已进入执行但尚未完成的任务集合
等待任务 (Pending Tasks)在队列中等待调度的任务
失败重试 (Retry)任务执行失败后自动重新排队再执行的机制
指数退避 (Exponential Backoff)重试间隔按固定倍数递增的策略
动态节流 (Dynamic Throttling)根据当前系统负载或错误率动态调整并发数的策略

控制流示意

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
┌─────────────────────────────────────────────┐
│                 客户端                        │
│                                              │
│   ┌───────────┐    ┌──────────────────┐      │
│   │  任务注入   │───>│   等待队列        │      │
│   └───────────┘    └────────┬─────────┘      │
│                             │                 │
│              ┌──────────────▼──────────────┐  │
│              │       调度引擎               │  │
│              │  (从队列弹出 → 执行 → 回调)  │  │
│              └──────┬─────────────┬────────┘  │
│                     │             │            │
│              ┌──────▼──┐   ┌──────▼─────┐      │
│              │ 任务(#1) │   │ 任务(#2)    │ ... │
│              │ (执行中)  │   │ (执行中)    │     │
│              └─────────┘   └────────────┘      │
│                     │             │            │
│              ┌──────▼─────────────▼────────┐   │
│              │       完成/失败处理          │    │
│              │  (成功→回调/失败→重试∣放弃) │    │
│              └────────────────────────────┘   │
└─────────────────────────────────────────────┘

最小示例

以下是最小但完整的并发控制实现,使用 Symbol 控制内部状态,支持动态注入任务。

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// concurrency-queue.js
class ConcurrencyQueue {
  #queue = [];        // 等待队列(私有字段)
  #activeCount = 0;   // 当前活跃任务数
  #concurrency;       // 最大并发数
  #results = [];      // 结果收集

  constructor(concurrency = 3) {
    this.#concurrency = concurrency;
  }

  // 添加任务(任务是一个返回 Promise 的函数)
  add(task) {
    return new Promise((resolve, reject) => {
      this.#queue.push({ task, resolve, reject });
      this.#schedule();
    });
  }

  // 调度核心
  #schedule() {
    while (this.#activeCount < this.#concurrency && this.#queue.length > 0) {
      const { task, resolve, reject } = this.#queue.shift();
      this.#activeCount++;

      Promise.resolve()
        .then(() => task())
        .then(
          (result) => {
            resolve(result);
            this.#results.push(result);
          },
          (error) => {
            reject(error);
          }
        )
        .finally(() => {
          this.#activeCount--;
          this.#schedule();
        });
    }
  }

  get pendingCount() { return this.#queue.length; }
  get activeCount() { return this.#activeCount; }
  get results() { return [...this.#results]; }
}

// 使用示例
const queue = new ConcurrencyQueue(2);

// 模拟异步请求
const request = (id, delay) => () =>
  new Promise((resolve) => {
    console.log(`请求 ${id} 开始`);
    setTimeout(() => {
      console.log(`请求 ${id} 完成`);
      resolve(`结果_${id}`);
    }, delay);
  });

queue.add(request('A', 1000));
queue.add(request('B', 500));
queue.add(request('C', 800));
queue.add(request('D', 300));

// 输出:
// 请求 A 开始 (并发 2,A 和 B 同时启动)
// 请求 B 开始
// 请求 B 完成 (500ms 后,B 完成,activeCount 降到 1)
// 请求 C 开始 (调度引擎立即从队列弹出 C)
// 请求 A 完成 (1000ms 后)
// 请求 D 开始 (调度引擎从队列弹出 D)
// 请求 C 完成 (800ms 后,实际在 1300ms 左右)
// 请求 D 完成 (300ms 后,实际在 1600ms 左右)

核心知识点拆解

1. 调度策略:推模式 vs 拉模式

推模式(Push-based) 是最常见的实现:每次任务完成后主动拉取下个任务(如上述最小示例的 finally → schedule())。优点是实现简单,缺陷是队列管理逻辑分散在各处。

拉模式(Pull-based) 使用 Generator 或 Async Iterator,调度引擎主动”吸取”任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 拉模式示意
async function* asyncQueue(tasks, concurrency) {
  let index = 0;
  const active = new Set();

  while (index < tasks.length || active.size > 0) {
    // 补充任务到活跃集合
    while (active.size < concurrency && index < tasks.length) {
      const taskIdx = index++;
      const promise = tasks[taskIdx]().then(r => [taskIdx, r]);
      active.add(promise);
      promise.finally(() => active.delete(promise));
    }

    // 等待任意一个完成
    const [completedIdx, result] = await Promise.race(active);
    yield { index: completedIdx, result };
  }
}

// 使用
for await (const { index, result } of asyncQueue(tasks, 3)) {
  console.log(`任务 ${index} 完成:`, result);
}

拉模式的优势在于调用方可以通过 for await...of 以同步风格的代码处理异步流,天然支持结果流式处理。

2. 失败重试与指数退避

重试不是简单的”失败了再试一次”,它需要考虑:

  • 可重试性判断:网络错误(TypeError: Failed to fetch)、5xx 可重试;4xx 不可重试(除非 429 且包含 Retry-After)
  • 幂等性保证:重试发起相同请求,服务端不会产生副作用
  • 退避策略
    • 固定间隔:每次失败后等待固定时间(简单但不利于快速恢复)
    • 指数退避:第 n 次等待 $baseDelay \times 2^{n-1}$,加上随机 jitter 避免惊群效应
    • 线性退避 + 上限:超过 5 次重试后固定为最大间隔
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 带指数退避的重试包装器
async function withRetry(fn, options = {}) {
  const {
    maxRetries = 3,
    baseDelay = 1000,
    maxDelay = 30000,
    jitter = true,
    onRetry = null,
  } = options;

  let lastError;

  for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
    try {
      return await fn(attempt);
    } catch (err) {
      lastError = err;

      if (attempt > maxRetries) break;

      const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
      const jitterMs = jitter ? delay * (0.5 + Math.random() * 0.5) : delay;

      onRetry?.({
        attempt,
        error: err,
        delayMs: Math.round(jitterMs),
      });

      await new Promise(r => setTimeout(r, jitterMs));
    }
  }

  throw lastError;
}

// 与并发队列集成
class ResilientConcurrencyQueue extends ConcurrencyQueue {
  constructor(concurrency, retryOptions = {}) {
    super(concurrency);
    this.retryOptions = retryOptions;
  }

  add(task) {
    // 自动包裹重试逻辑
    const wrappedTask = () => withRetry(task, this.retryOptions);
    return super.add(wrappedTask);
  }
}

3. 优先级调度

在实际业务中,任务往往有优先级差异。例如:

  • 列表页图片资源 > 详情页图片 > 预加载资源
  • 用户主动触发的操作 > 后台静默同步

支持优先级的最简方案是使用”桶队列”(Bucket Queue):

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class PriorityQueue {
  #buckets = {
    high: [],
    normal: [],
    low: [],
  };
  #activeCount = 0;
  #concurrency;

  constructor(concurrency = 3) {
    this.#concurrency = concurrency;
  }

  add(task, priority = 'normal') {
    return new Promise((resolve, reject) => {
      this.#buckets[priority].push({ task, resolve, reject });
      this.#schedule();
    });
  }

  #next() {
    // 从高到低选择
    for (const level of ['high', 'normal', 'low']) {
      if (this.#buckets[level].length > 0) {
        return this.#buckets[level].shift();
      }
    }
    return null;
  }

  #schedule() {
    while (this.#activeCount < this.#concurrency) {
      const item = this.#next();
      if (!item) break;

      this.#activeCount++;
      const { task, resolve, reject } = item;

      Promise.resolve()
        .then(() => task())
        .then(resolve, reject)
        .finally(() => {
          this.#activeCount--;
          this.#schedule();
        });
    }
  }
}

4. 任务取消(AbortController 集成)

生产级并发控制需要支持取消正在排队或执行中的任务。ES2020 引入的 AbortController 提供了标准的取消信号机制:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
class CancellableQueue {
  #queue = [];
  #active = new Map(); // taskId -> { promise, controller }
  #concurrency;
  #taskIdCounter = 0;

  constructor(concurrency = 3) {
    this.#concurrency = concurrency;
  }

  add(fn, { signal } = {}) {
    const taskId = ++this.#taskIdCounter;

    return new Promise((resolve, reject) => {
      // 如果外部 signal 已经触发,直接拒绝
      if (signal?.aborted) {
        reject(new DOMException('任务在加入队列前已被取消', 'AbortError'));
        return;
      }

      this.#queue.push({ taskId, fn, resolve, reject, signal });
      this.#schedule();
    });
  }

  cancel(taskId) {
    // 如果任务在队列中,移除
    this.#queue = this.#queue.filter(t => t.taskId !== taskId);
    // 如果任务在执行中,中止
    const entry = this.#active.get(taskId);
    if (entry) {
      entry.controller.abort();
      this.#active.delete(taskId);
    }
  }

  cancelAll() {
    for (const [taskId] of this.#active) {
      this.cancel(taskId);
    }
    this.#queue = [];
  }

  #schedule() {
    while (this.#active.size < this.#concurrency && this.#queue.length > 0) {
      const item = this.#queue.shift();
      const { taskId, fn, resolve, reject, signal } = item;

      const controller = new AbortController();
      const combinedSignal = signal
        ? anySignal([signal, controller.signal])
        : controller.signal;

      const promise = Promise.resolve()
        .then(() => fn({ signal: combinedSignal }))
        .then(resolve, reject)
        .finally(() => {
          this.#active.delete(taskId);
          this.#schedule();
        });

      this.#active.set(taskId, { promise, controller });
    }
  }
}

// 辅助:合并多个 AbortSignal
function anySignal(signals) {
  const controller = new AbortController();
  for (const signal of signals) {
    if (signal.aborted) {
      controller.abort(signal.reason);
      return controller.signal;
    }
    signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true });
  }
  return controller.signal;
}

实战案例:API Gateway SDK 中的自适应并发控制

假设我们正在开发一个内部 API 网关的 JavaScript SDK,上游服务对每个 AppKey 的限制为 100 QPS。我们需要实现一个客户端限流器,在不超过配额的前提下最大化吞吐。

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
// adaptive-throttler.ts
interface ThrottlerOptions {
  maxConcurrency: number;      // 最大并发数
  quotaPerSecond: number;      // 每秒配额上限
  windowMs: number;            // 滑动窗口大小(毫秒)
  adaptive: boolean;           // 是否自适应调整并发
  errorThreshold: number;      // 触发降速的错误率阈值 (0~1)
}

interface Metrics {
  totalRequests: number;
  successfulRequests: number;
  failedRequests: number;
  throttledRequests: number;
  averageLatencyMs: number;
  currentConcurrency: number;
}

class AdaptiveThrottler {
  private options: ThrottlerOptions;
  private queue: Array<{
    task: () => Promise<any>;
    resolve: (value: any) => void;
    reject: (reason: any) => void;
    addedAt: number;
  }> = [];
  private active = 0;
  private window: number[] = [];  // 滑动窗口时间戳
  private errors = 0;
  private successes = 0;
  private latencies: number[] = [];
  private adaptiveConcurrency: number;
  private pollingTimer: ReturnType<typeof setInterval> | null = null;

  constructor(options: Partial<ThrottlerOptions> = {}) {
    this.options = {
      maxConcurrency: 10,
      quotaPerSecond: 100,
      windowMs: 1000,
      adaptive: true,
      errorThreshold: 0.1,
      ...options,
    };
    this.adaptiveConcurrency = this.options.maxConcurrency;
    this.startMonitoring();
  }

  // 提交任务
  async execute<T>(task: () => Promise<T>): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      this.queue.push({
        task: task as () => Promise<any>,
        resolve,
        reject,
        addedAt: Date.now(),
      });
      this.drain();
    });
  }

  // 核心调度逻辑
  private drain() {
    while (this.queue.length > 0 && this.canProceed()) {
      const item = this.queue.shift()!;
      this.active++;
      const startTime = performance.now();

      Promise.resolve()
        .then(() => item.task())
        .then(
          (result) => {
            this.successes++;
            this.window.push(Date.now());
            item.resolve(result);
          },
          (error) => {
            this.errors++;

            // 判断是否因被限流导致的错误
            if (error?.status === 429) {
              this.adaptiveConcurrency = Math.max(
                1,
                Math.floor(this.adaptiveConcurrency * 0.8)
              );
              console.warn(`[限流] 收到 429,并发数降低到 ${this.adaptiveConcurrency}`);
            }

            item.reject(error);
          }
        )
        .finally(() => {
          this.active--;
          this.latencies.push(performance.now() - startTime);
          this.drain();
        });
    }
  }

  // 判断是否可以发起新请求
  private canProceed(): boolean {
    // 1. 并发限制
    if (this.active >= this.adaptiveConcurrency) return false;

    // 2. 滑动窗口配额检查
    const now = Date.now();
    const cutoff = now - this.options.windowMs;
    // 移除窗口外的记录
    while (this.window.length > 0 && this.window[0] < cutoff) {
      this.window.shift();
    }

    if (this.window.length >= this.options.quotaPerSecond) return false;

    return true;
  }

  // 自适应调整并发数
  private startMonitoring() {
    if (!this.options.adaptive) return;

    this.pollingTimer = setInterval(() => {
      const total = this.successes + this.errors;
      if (total === 0) return;

      const errorRate = this.errors / total;
      const avgLatency = this.latencies.length > 0
        ? this.latencies.reduce((a, b) => a + b, 0) / this.latencies.length
        : 0;

      if (errorRate > this.options.errorThreshold) {
        // 错误率过高,降速
        this.adaptiveConcurrency = Math.max(
          1,
          Math.floor(this.adaptiveConcurrency * 0.85)
        );
        console.warn(
          `[自适应] 错误率 ${(errorRate * 100).toFixed(1)}% 过高,并发降到 ${this.adaptiveConcurrency}`
        );
      } else if (errorRate < this.options.errorThreshold * 0.5 && avgLatency < 200) {
        // 错误率低且延迟较低,尝试加速
        this.adaptiveConcurrency = Math.min(
          this.options.maxConcurrency,
          this.adaptiveConcurrency + 1
        );
      }

      // 每 10 秒重置统计
      if (this.latencies.length > 100) {
        this.successes = 0;
        this.errors = 0;
        this.latencies = [];
      }
    }, 2000);
  }

  // 获取当前指标
  getMetrics(): Metrics {
    return {
      totalRequests: this.successes + this.errors,
      successfulRequests: this.successes,
      failedRequests: this.errors,
      throttledRequests: this.window.length,
      averageLatencyMs: this.latencies.length > 0
        ? Math.round(this.latencies.reduce((a, b) => a + b, 0) / this.latencies.length)
        : 0,
      currentConcurrency: this.active,
    };
  }

  destroy() {
    if (this.pollingTimer) clearInterval(this.pollingTimer);
  }
}

// 使用示例
async function demo() {
  const throttler = new AdaptiveThrottler({
    maxConcurrency: 8,
    quotaPerSecond: 50,
    adaptive: true,
  });

  // 批量发送 200 个请求
  const urls = Array.from({ length: 200 }, (_, i) =>
    `https://api.example.com/items/${i}`
  );

  const results = await Promise.allSettled(
    urls.map(url =>
      throttler.execute(async () => {
        const resp = await fetch(url);
        if (!resp.ok) throw resp;
        return resp.json();
      })
    )
  );

  const succeeded = results.filter(r => r.status === 'fulfilled').length;
  const failed = results.filter(r => r.status === 'rejected').length;

  console.log(`成功: ${succeeded}, 失败: ${failed}`);
  console.log('最终指标:', throttler.getMetrics());

  throttler.destroy();
}

底层原理

1. Event Loop 与微任务调度关系

并发队列的本质是微任务调度链。每次 .then().finally() 生成的任务并不是同步执行的,而是被放入 Promise 的微任务队列。这意味着:

1
2
3
4
5
6
7
queue.add(taskA); // 加入队列
queue.add(taskB); // 加入队列
queue.add(taskC); // 加入队列

// 此时事件循环的状态:
// 宏任务: [当前脚本]
// 微任务: [] (promise 的回调在 resolve 时才加入)

当第一个 #schedule 执行时,task() 返回的 Promise 将 resolve 逻辑注册为微任务。当前宏任务执行完毕、进入微任务阶段时,这 3 个任务的 Promise 链开始执行。

关键洞察:并发队列的执行不是”同时启动”3 个异步操作,而是在同一个事件循环 tick 内依次调用 3 个 task()。但如果 task() 内部是 fetch(网络请求)或 setTimeout,它们会各自进入各自的队列(网络请求由浏览器网络线程处理,setTimeout 由定时器线程管理),从 Event Loop 视角看它们确实是”并行”的。

2. V8 中 Promise 的实现与微任务队列

V8 中的 Promise 遵循 ECMAScript 2022 规范,通过 NewPromiseCapability 创建 Promise 对象,其核心数据结构是:

1
2
3
4
5
6
7
8
9
10
11
// V8 中 Promise 的简化内部结构
class Promise {
  // 状态: kPending, kFulfilled, kRejected
  PromiseState state_;
  // 结果值或错误
  Object result_;
  // 反应链(.then/.catch 注册的回调)
  Deferred* deferreds_;
  // 是否已创建微任务
  bool has_handler_;
};

当 Promise.resolve() 时,V8 调用 EnqueuePromiseReactionJob,将 .then() 注册的回调包装为一个 Microtask 推入微任务队列。每个微任务在执行时会消费 deferreds_ 链表中的所有 .then() 回调。

理解这一层对并发控制的优化至关重要:不要在同一次微任务执行中同时 resolve 多个 Promise,这会导致微任务队列膨胀,影响下一帧的渲染。最佳实践是在 finally 中通过 setTimeout(0)queueMicrotask 延迟下一个任务的启动。

3. 浏览器网络栈对并发的真正限制

从 Chrome 的网络栈(Chromium net/ 目录)源码来看,浏览器对同一 Host 的 HTTP 连接池管理如下:

1
2
3
4
5
// Chrome 源码 net/socket/client_socket_pool_manager.cc
// 默认值
int g_max_sockets_per_group = 6;      // HTTP/1.1 每组最大 6 个连接
int g_max_sockets_per_pool = 256;     // 每个池子最大 256 个连接
int g_max_sockets_per_proxy_server = 32;

ClientSocketPool 使用分层空闲检测:

  1. Idle Socket Reuse:如果连接池中有空闲连接且未过期,直接复用
  2. Connect Job:无空闲连接时创建新的连接建立任务,加入 ConnectJob 队列
  3. 等待队列:当连接数达到上限时,请求被放入 pending_request_queue_

因此,即便我们设置 concurrency=100,实际的浏览器网络并发数仍然受限于 max_sockets_per_group。这就是为什么 HTTP/2 场景下并发控制更重要:HTTP/2 多路复用在同一 TCP 连接上可以发出任意数量的请求,但服务端可能同时处理数百个请求,每一路都会消耗内存和数据库连接池。

高频面试题解析

面试题 1:手写一个带最大并发限制的 Promise 调度器,要求能动态添加任务并获取每个任务的结果和状态

考察点:Promise 基础、队列管理、状态追踪

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 完整实现
class Scheduler {
  constructor(max = 2) {
    this.max = max;
    this.queue = [];
    this.pending = 0;    // 执行中任务数
    this.results = [];   // 结果收集
    this.errors = [];    // 错误收集
  }

  add(task) {
    return new Promise((resolve, reject) => {
      this.queue.push({ task, resolve, reject });
      this._next();
    });
  }

  _next() {
    if (this.queue.length === 0 || this.pending >= this.max) return;

    const { task, resolve, reject } = this.queue.shift();
    this.pending++;

    Promise.resolve()
      .then(() => task())
      .then(
        (value) => {
          resolve(value);
          this.results.push(value);
        },
        (reason) => {
          reject(reason);
          this.errors.push(reason);
        }
      )
      .finally(() => {
        this.pending--;
        this._next();
      });
  }
}

// 面试官追问:如果要支持 Promise.all 风格的"等所有任务结束"
class BatchScheduler extends Scheduler {
  addAll(tasks) {
    return Promise.all(tasks.map(t => this.add(t)));
  }
}

面试题 2:大量请求(上千个)如何防止浏览器内存溢出?除了并发控制还需要什么?

解答思路

并发控制解决的是”同时发送请求数”的问题,但上千个请求本身就会占用大量内存。需要组合使用以下手段:

  1. 虚拟请求(Lazy Promise):不预先创建所有的 Promise 对象,而是只存储请求参数,需要发送时才创建
1
2
3
4
5
6
// 不要这样做:一次创建 1000 个 Promise
const promises = urls.map(url => fetch(url));

// 应该这样做:懒创建
const tasks = urls.map(url => () => fetch(url));
// 调度器内部需要时再执行 task()
  1. 流式处理(Streaming):使用 ReadableStream 处理大响应,而不是 response.json()(后者会在内存中缓冲整个响应体)

  2. 分页与虚拟滚动:如果请求结果用于列表渲染,使用虚拟列表只保留可见区域的 DOM 元素

  3. Response 泄露防护:对于不再需要的请求,使用 AbortController.abort() 释放连接资源,避免 socket 泄漏

面试题 3:如何设计一个支持”任务优先级 + 超时 + 取消 + 重试”的健壮并发队列?

解答思路

这是一个综合性很强的题目,考察系统设计能力。设计思路如下:

架构分层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌─────────────────────────────────────────┐
│           用户 API 层                     │
│  .add(task, { priority, timeout })       │
│  .cancel(taskId) / .pause() / .resume()  │
├─────────────────────────────────────────┤
│           调度策略层                      │
│  优先级桶排序 + 加权轮询                  │
├─────────────────────────────────────────┤
│           执行引擎层                      │
│  并发窗口 + 超时外壳 + 取消注入           │
├─────────────────────────────────────────┤
│           容错层                         │
│  重试策略 + 指数退避 + 错误分类           │
└─────────────────────────────────────────┘

关键设计决策:

  • 优先级使用”多级反馈队列”而非简单排序:高优先级任务一旦插入,应在当前活跃任务完成后立即执行,而不是等待队列所有低优先级任务完成
  • 超时使用 AbortSignal.timeout()(Chrome 103+):与 AbortSignal.abort() 一个信号链,避免引入额外的定时器管理
  • 重试只在特定错误类型上触发:通过 retryIf 函数判断
  • 内存安全:限制队列最大长度(如 10000),超过时触发背压(Backpressure),拒绝新任务或降级

总结与扩展

并发请求控制是前端工程化中的”基础设施”——它不是炫技的框架特性,而是每个中大型应用都该认真设计的系统组件。从最简单的固定并发数队列,到自适应节流、优先级调度、背压机制,每一步扩展都对应着真实业务场景的痛点。

值得深入的方向

  • Backpressure(背压):当队列长度超过阈值时,上游生产者应降低生产速率。在 RxJS 中对应 Observable 的背压策略(debounce/throttle/buffer
  • Circuit Breaker(熔断器):当错误率达到阈值时,直接拒绝所有请求(不执行),让服务端有时间恢复;进入半开状态后试探性放行请求
  • Bulkhead(隔板模式):将连接池按优先级划分为独立分舱,一个舱耗尽不影响其他舱——避免低优先级请求占满连接池,饿死高优先级请求
  • Web Workers 中的并发:主线程的并发队列是异步任务调度,Web Worker 中可以配合 SharedArrayBuffer 实现真正的并行计算

并发控制设计背后,是对资源有限性、网络不确定性、性能与可靠性权衡的深刻理解。掌握它,就掌握了处理大规模异步系统的基础方法论。

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