C04 并发控制与取消

2026-10-31 17:060 阅读约 14 分钟JavaScript

C04 并发控制与取消

摘要:批量请求全部 Promise.all 会瞬间打满连接数,逐条 await 又会让总耗时线性增长。可控做法是并发池:固定工作槽位,完成一个补一个。AbortController 提供取消信号,fetch、事件监听、定时器均可共享同一信号。本文给出并发池、超时封装、输入防抖与请求去重四组实现。
关键词:并发池,AbortController,取消信号,防抖,请求去重

所属:frontend/js / C-高级 | 上一篇:C03-内存管理与性能分析 | 下一篇:C05-JavaScript工程化

1 引言

一次导入五百行数据、搜索框连续输入、页面切换时取消未完成的请求,三处需求都指向同一问题:同时进行的异步任务需要上限与中止能力。

本文回答三个问题:并发数量如何限制、进行中的任务如何取消、重复请求如何避免。范围见表 1-1。

项 本文覆盖 本文不覆盖
并发 池化执行、结果保序、错误隔离 服务端限流
取消 AbortController、signal、超时合并 跨页面导航中断
交互 防抖、竞态丢弃、请求去重 缓存策略(D03)

2 预备知识

项 要求 核对方式
环境 Node.js 20+(内置 fetch 与 AbortSignal.timeout) 能运行 ESM 脚本
前置 B03 理解 Promise 组合
文件 js-practice/concurrency.mjs 编码 UTF-8

3 原理与实现

3.1 池化调度

并发池维护一个固定长度的工作槽位数组,每个槽位取走下一个任务,完成后继续取,直到队列为空,如图 3-1 所示。

flowchart TD
    A["任务队列 tasks"] --> B["启动 min(limit, 任务数) 个工作者"]
    B --> C["工作者取下一个任务"]
    C --> D{"队列是否为空"}
    D -->|是| E["工作者退出"]
    D -->|否| F["执行任务,结果写入对应下标"]
    F --> C
    E --> G["全部工作者退出后返回结果数组"]

图 3-1 固定槽位的工作者反复取任务

图上可见:任一时刻最多 limit 个任务在跑,结果按下标写回,因此返回顺序与入队顺序一致。

3.2 完整实现

// js-practice/concurrency.mjs;运行环境:Node.js 20+
// 1 并发池:结果保序,错误不中断其它任务
export async function pool(tasks, limit = 4) {
  const results = new Array(tasks.length);
  let cursor = 0;
  let active = 0;

  return new Promise((resolve) => {
    if (tasks.length === 0) return resolve(results);

    const launch = () => {
      while (active < limit && cursor < tasks.length) {
        const index = cursor;
        cursor += 1;
        active += 1;
        Promise.resolve()
          .then(() => tasks[index]())
          .then(
            (value) => { results[index] = { status: 'fulfilled', value }; },
            (reason) => { results[index] = { status: 'rejected', reason }; },
          )
          .finally(() => {
            active -= 1;
            if (cursor >= tasks.length && active === 0) resolve(results);
            else launch();
          });
      }
    };

    launch();
  });
}

// 2 超时与外部取消合并
export function withTimeout(signal, ms) {
  const timeout = AbortSignal.timeout(ms);
  return signal ? AbortSignal.any([signal, timeout]) : timeout;
}

async function getJson(url, { signal, timeout = 3000 } = {}) {
  const res = await fetch(url, { signal: withTimeout(signal, timeout) });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

// 3 防抖 + 竞态丢弃:只保留最后一次输入的结果
export function debounceAsync(fn, wait = 300) {
  let timer;
  let controller;
  return (...args) =>
    new Promise((resolve, reject) => {
      clearTimeout(timer);
      controller?.abort();                 // 取消上一次仍在进行的请求
      controller = new AbortController();
      timer = setTimeout(() => {
        fn(...args, controller.signal).then(resolve, reject);
      }, wait);
    });
}

// 4 去重:相同 key 的进行中请求共用同一个 Promise
const inflight = new Map();
export function dedupe(key, factory) {
  if (inflight.has(key)) return inflight.get(key);
  const promise = factory().finally(() => inflight.delete(key));
  inflight.set(key, promise);
  return promise;
}

// 验证
const sleep = (ms, value, fail = false) =>
  new Promise((resolve, reject) => setTimeout(() => (fail ? reject(new Error('boom')) : resolve(value)), ms));

const tasks = [1, 2, 3, 4, 5, 6, 7].map((n) => () => sleep(20 * n, n, n === 4));
const t0 = performance.now();
const out = await pool(tasks, 3);
console.log(out.map((r) => (r.status === 'fulfilled' ? r.value : 'ERR')));  // [1,2,3,'ERR',5,6,7]
console.log('elapsed', Math.round(performance.now() - t0));                  // 约 120

const search = debounceAsync(async (q, signal) => `result:${q}`, 50);
const p1 = search('a').catch((e) => e.name);
console.log(await search('ab'));       // result:ab
console.log(await p1);                 // AbortError,被取消

const first = dedupe('k', () => sleep(10, 'v1'));
const second = dedupe('k', () => sleep(10, 'v2'));
console.log(first === second, await second);   // true v1

并发策略对比见表 3-1。

策略 峰值并发 总耗时 适用场景
逐条 await 1 各任务之和 强依赖前一步结果
Promise.all 全部 最慢任务 少量请求、可承受峰值
并发池 固定 limit 约等于总耗时除以并发 批量导入、大批量刷新
allSettled 全部 最慢任务 需要逐项结果

取消能力对照见表 3-2。

API 是否支持 signal 取消后行为
fetch 是 拒绝,name 为 AbortError
addEventListener 是({ signal }) 自动移除监听器
setTimeout 否 需手动 clearTimeout
AbortSignal.timeout — 到点触发,不可重置
AbortSignal.any — 任一信号触发即中止

4 验证与讨论

  1. 执行脚本,确认并发为 3 时总耗时约 120 毫秒,且第 4 项为 ERR。
  2. 把 limit 改为 1,确认总耗时接近各任务之和。
  3. 连续调用 search 三次,确认前两次以 AbortError 失败,只有最后一次返回结果。
  4. 同时用不同参数调用 dedupe('k', ...),确认拿到同一个 Promise 实例。
检查项 预期结果 说明
并发上限 同时最多 limit 个 槽位控制
结果保序 输出顺序与入队一致 按写下标
错误隔离 单项失败不影响其余 逐项记录状态
竞态丢弃 旧请求被中止 abort() 上一次

讨论与局限。 中止只停止客户端的等待,服务端可能仍在处理,需要服务端配合幂等键。另,AbortSignal.any 在较旧运行时不支持,可退化为手动监听两个信号并共用同一个 controller。

5 常见问题

现象 原因 处理
批量请求打满连接 直接 Promise.all 改用并发池
取消后仍报未捕获拒绝 未处理 AbortError 在 catch 中判断 err.name
搜索结果闪回旧内容 旧请求晚于新请求返回 取消旧请求或按请求序号丢弃
重复提交生成两条记录 未去重 用 dedupe 共用进行中的 Promise
超时后请求仍在跑 只做了 race 把 signal 传给 fetch

6 结论

序号 结论 依据
1 批量任务用固定槽位并发池 图 3-1
2 池化结果按下标写回,天然保序 3.2 节
3 取消统一走 AbortController 表 3-2
4 相同请求共用进行中的 Promise 3.2 节

参考文献

[1] MDN Web Docs · AbortController,https://developer.mozilla.org/zh-CN/docs/Web/API/AbortController(访问于 2026-10-31)
[2] DOM Standard · Aborting ongoing activities,WHATWG,https://dom.spec.whatwg.org/#aborting-ongoing-activities(访问于 2026-10-31)

返回文章列表

评论 0

0/2000

还没有评论,快来抢沙发吧~