异步并发控制:手写并发池、竞态防护与取消的完整方案

问题场景

前端处理批量异步任务时,几乎必经这几个痛点:

  1. 并发失控:一次性发 200 个 HTTP 请求,服务器直接 429 限流甚至被误判为攻击打爆。
  2. 竞态覆盖:搜索框快速输入,每次触发一个请求;"慢的旧请求"把"快的新结果"覆盖掉,显示错乱数据。
  3. 资源浪费 :用户取消/组件卸载后,之前的请求还在后台跑,白白消耗带宽,甚至 setState on unmounted 报警。
  4. AI 流式:用户点了"停止",但服务端还在生成,token 哗哗地烧。

这四类问题本质是异步作业的三大控制能力缺失:限流(并发控制)、防竞态、可取消 。本文给出完整的实战方案:手写并发池、两种竞态防护、AbortController 取消,以及 React 里的落地写法。

原理深入

1. 并发控制:为什么需要"池"而不是 Promise.all

Promise.all(urls.map(fetch))同时发起所有请求 ,没有并发上限。浏览器对同一域名的 TCP 连接数有限,但你的异步任务(HTTP/文件/批量处理)不受此限制------上千个并发瞬间铺开,服务器崩、内存爆。

并发池的核心思路 :维护一个"任务队列 + 进行中的计数",同时最多 N 个在跑,跑完一个补一个,像水池放水一样稳定:

text 复制代码
队列: [t1, t2, t3, ... tm]
进行中: N 个
当某一个结束 → 从队列补一个进来 → 保持进行中始终 ≤ N

2. 竞态防护:为什么"请求顺序"不等于"到达顺序"

竞态(race condition) :多次异步操作,它们的完成顺序发起顺序 可能不一致。慢请求先发后到,快请求后发先到,导致旧结果覆盖新结果

防护的两种主流思路:

  • 序号标识:每次发起递增 ID,回调时比对"我是不是最新",不是就丢弃。
  • 取消旧请求 :发起新请求前,abort 掉上一个请求。

3. 主动取消:AbortController 的威力

AbortController 是浏览器原生取消信号,核心是 signal

js 复制代码
const controller = new AbortController();
const { signal } = controller;
fetch(url, { signal });   // 传 signal
controller.abort();       // 触发取消 → fetch 抛 AbortError

它能取消:fetch、流式读取(body.getReader())、事件监听 { signal },还能配合超时。关键点 :所有异步 API 通过 signal 感知取消,取消时抛 AbortError,必须区分"主动取消"和"真错误"。

实战代码

方案 1:可复用的并发池(p-limit 思想)

js 复制代码
function createLimiter(limit) {
  const queue = [];      // 等待队列:存 {fn, resolve, reject}
  let active = 0;        // 进行中数量

  function next() {
    // 名额满了 或 队列空,就不动
    if (active >= limit || queue.length === 0) return;
    const { fn, resolve, reject } = queue.shift();
    active++;
    // 无论成功失败,finally 里补下一个 → 不会因为某个失败而卡死队列
    Promise.resolve()
      .then(fn)
      .then(resolve, reject)
      .finally(() => {
        active--;
        next();          // 结束一个,拉下一个
      });
  }

  return (fn) =>
    new Promise((resolve, reject) => {
      queue.push({ fn, resolve, reject });
      next();            // 尝试启动
    });
}

// 使用:限制同时最多 3 个
const limit = createLimiter(3);
const results = await Promise.all(
  urls.map((u) => limit(() => fetch(u).then((r) => r.json())))
);

这个实现的关键细节:

  • queue + active + next() 三要素,滚动执行。
  • finally 里补位,失败也不会停摆 (这是新手最容易漏的------用 then 忘了 catch/finally,一个失败整个队列卡死)。
  • 返回 Promise,可以和 Promise.all 组合。

方案 2:竞态防护------序号比对

js 复制代码
let seq = 0;
async function search(keyword) {
  const mySeq = ++seq;              // 本次的序号
  const data = await fetch(`/api/search?q=${keyword}`).then((r) => r.json());
  if (mySeq !== seq) return;        // 已被更新的请求顶掉 → 丢弃本次结果
  render(data);                     // 只有最新一次会渲染
}

优点 :简单可靠,不依赖 AbortController 兼容性。缺点:旧请求实际还在跑,浪费资源。

方案 3:竞态防护------取消旧请求(推荐,省资源)

js 复制代码
let currentController = null;
async function search(keyword) {
  if (currentController) currentController.abort();  // 取消上一个
  currentController = new AbortController();
  try {
    const res = await fetch(`/api/search?q=${keyword}`, { signal: currentController.signal });
    render(await res.json());
  } catch (e) {
    if (e.name !== 'AbortError') throw e;  // 非取消错误才抛出
    // 主动取消:静默忽略,不弹错
  }
}

优点 :旧请求真正被中断,省带宽。缺点:依赖 AbortController(现代浏览器都支持)。

方案 4:取消流式读取(AI/SSE,省 token 的关键)

js 复制代码
const controller = new AbortController();

async function chat(prompt) {
  const res = await fetch('/api/chat/stream', {
    method: 'POST',
    body: JSON.stringify({ prompt }),
    signal: controller.signal,      // 传入 signal
  });
  const reader = res.body.getReader();
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      append(new TextDecoder().decode(value));
    }
  } catch (e) {
    if (e.name === 'AbortError') console.log('用户停止生成');
  }
}

// "停止"按钮 → 前端中断 + 服务端感知断开 → 停止生成 → 省 token
stopBtn.onclick = () => controller.abort();

核心价值abort真正中断 流式读取,服务端收到连接中断也会停止生成。这比"前端假装忽略、让它在后台跑完"省钱得多(AI 按 token 计费)。

方案 5:超时自动取消

js 复制代码
function fetchWithTimeout(url, ms = 8000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);
  return fetch(url, { signal: controller.signal })
    .finally(() => clearTimeout(timer));
}

方案 6:React 组件里的完整落地(卸载取消 + 竞态)

jsx 复制代码
useEffect(() => {
  const controller = new AbortController();   // 本组件一个信号
  let cancelled = false;                       // 或配合序号

  async function load() {
    try {
      const data = await fetch('/api/data', { signal: controller.signal }).then((r) => r.json());
      if (!cancelled) setData(data);
    } catch (e) {
      if (e.name === 'AbortError') return;      // 卸载取消,忽略
      console.error(e);
    }
  }
  load();

  return () => {
    controller.abort();     // 卸载时取消请求 → 根治 "setState on unmounted"
    cancelled = true;
  };
}, []);

要点总结

  • 并发池queue + active + next(),结束一个补一个,控制并发数;失败也要 finally 补位。
  • 竞态防护:序号比对(简单)或 AbortController 取消旧请求(省资源),保证只渲染最终结果。
  • 取消AbortController 统一管 fetch/流式/事件;catch 判 AbortError,别把主动取消当错误弹给用户。
  • 流式中止能真正中断服务端 → 省 AI token、省带宽。
  • React 落地useEffect 里创建 controller + 卸载时 abort,根治 setState on unmounted。
  • 生产可借力:p-limit(并发)、useRequest/useSWR(竞态已内置)、原生 AbortController(取消)。
  • 组合拳:限流 + 防竞态 + 可取消三者一起用,批量异步就从"乱"变成"稳"。

一句话:异步并发控制 = 限流(并发池)+ 防竞态(序号/取消)+ 可取消(AbortController)。三件套配齐,海量请求、竞态闪屏、token 浪费、卸载报警,一次性全解决。

相关推荐
csuzhucong14 分钟前
二十一 - 四十阶魔方
javascript·css·html
专业抄代码选手20 分钟前
06|双缓冲与 Commit:让 Fiber 支持第二次渲染
前端·javascript·react.js
涛涛ing21 分钟前
4.8K Star!一套规则让 AI 帮你写出“像人写的”前端代码
前端
万物智能24 分钟前
OpenHarmony源码树解剖—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
前端·后端
天才熊猫君26 分钟前
通用虚拟滚动表格行拖拽排序:从思路到完整实现
前端·javascript
hh95028 分钟前
Agent Plan × DeepSeek Harness:角色 Prompt 驱动的 Agent 分工优化与协作质量实验
java·前端·人工智能·prompt·adg·agent plan·adg成都社区
xiaominlaopodaren30 分钟前
three.js最小地图运行时(九):TileKey 线框与 cursor 坐标探针
javascript·gis·three.js
CarIise31 分钟前
JavaScript进阶与轮播图实现 课堂笔记
开发语言·javascript·笔记
专业抄代码选手32 分钟前
05|把递归渲染拆成 Fiber:让一棵大树可以暂停
前端·javascript·react.js