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

问题场景

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

  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 浪费、卸载报警,一次性全解决。

相关推荐
AlienZHOU6 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
Captaincc9 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师10 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen11 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒11 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
前端snow11 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林81812 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang8007812 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明12 小时前
第 6 章 组件进阶
前端·vue.js
江华森12 小时前
HTTP请求的完整过程详解:从DNS解析到TCP挥手的微秒级实战分析
前端