← 中文资源

简体中文版本

异步竞态条件 — 响应乱序

诊断并修复并发异步操作中的竞态条件:后发出的请求先返回,导致陈旧数据覆盖新结果。包含 AbortController、请求令牌、框架方案与验证方法。

阅读英文原文

什么是异步竞态条件

当多个异步操作并发执行,而完成顺序与启动顺序不一致时,就会出现竞态条件。晚到的结果会覆盖正确的、更新的结果。

典型场景:用户先输入 abc(触发请求 1),随后输入 abcd(触发请求 2)。如果请求 2 的网络延迟恰好低于请求 1,那么 abcd 的响应先到达,随后请求 1 的 abc 响应到达并覆盖正确状态。

症状

  • 搜索框结果先短暂显示正确内容,随后回退到旧结果
  • 页面展示的数据与最新输入不一致
  • React 控制台出现 “Can’t perform state update on unmounted component” 警告
  • 发起新查询后,数据表仍显示上一次查询的行

诊断步骤

1. 在 Network 面板观察请求与响应时序

打开 Chrome DevTools → Network → 按 Waterfall 列排序。如果后发出的请求 B 在响应时间线上早于先发出的请求 A,就存在竞态。注意响应体大小:小响应常常先于大响应到达,即使发送顺序更晚。

2. 为请求添加 ID 以追踪状态更新

为每次状态更新附带时间戳或递增计数器,并在 setState 旁记录请求 ID。如果看到 setState(5) 之后又出现 setState(3),说明旧响应覆盖了新响应。

3. 限速网络以放大竞态

在 Network 面板选择 “Slow 3G”,或在服务端加入人为延迟。慢网络更容易复现竞态;如果问题只在慢连接出现,它一直存在,只是被本地快速网络掩盖了。

4. 检查是否缺少 AbortController

审查所有 fetch 调用是否传入 signal。没有 AbortController 时,触发条件(搜索词、页码)改变后旧请求仍会继续,并可能用旧数据更新状态。

修复 1:AbortController(推荐)

useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/search?q=${query}`, { signal: controller.signal })
    .then(r => r.json())
    .then(data => setResults(data))
    .catch(err => {
      if (err.name !== 'AbortError') throw err; // 忽略主动取消
    });

  return () => controller.abort(); // 新查询或卸载时取消
}, [query]);

AbortController.abort() 会让 fetch Promise 以 AbortError 拒绝。必须把它与其他错误分开处理,避免错误监控被取消噪声污染。

修复 2:请求 ID 令牌

useEffect(() => {
  const requestId = ++requestIdRef.current;

  fetch(`/api/search?q=${query}`)
    .then(r => r.json())
    .then(data => {
      // 只有仍然是最新请求时才应用结果
      if (requestId === requestIdRef.current) {
        setResults(data);
      }
    });
}, [query]);

修复 3:SWR / TanStack Query(生产环境)

import { useQuery } from '@tanstack/react-query';

function SearchResults({ query }) {
  const { data } = useQuery({
    queryKey: ['search', query],
    queryFn: () => fetch(`/api/search?q=${query}`).then(r => r.json()),
    // 内置去重、取消陈旧请求并忽略旧响应
  });
  return <ResultsList data={data} />;
}

常见陷阱

未捕获 AbortError

// ❌ AbortError 污染错误追踪
fetch(url, { signal }).then(setData).catch(setError);

// ✅ 过滤取消错误
fetch(url, { signal }).then(setData).catch(err => {
  if (err.name !== 'AbortError') setError(err);
});

共享可变引用

// ❌ 共享数组引用——并发 push 会破坏状态
const shared = [];
await Promise.all(urls.map(url =>
  fetch(url).then(r => r.json()).then(d => shared.push(...d))
));

测试竞态条件

// 用人为延迟模拟竞态
const mockFetch = (delay, data) =>
  new Promise(resolve => setTimeout(() => resolve(data), delay));

// 请求 A(慢):200ms 后返回
// 请求 B(快):50ms 后返回
// 未取消:先显示 B 的数据,随后被 A 覆盖
// 已取消:显示 B 的数据,A 的请求被中止

框架模式

  • React:在 useEffect 清理函数中使用 AbortController(见上)
  • Svelte:使用带取消令牌的响应式 store
  • Vue:在 watcher 中使用 AbortController

性能影响

正确取消请求可以减少带宽消耗。HTTP 协议不支持在 TCP 层真正中止服务端处理,服务端可能仍会继续执行,但客户端不会再等待或解析响应。

验证修复

实现请求取消后,在搜索框中快速连续输入,同时观察 Network 面板。先前按键发出的请求应在 Status 列显示为 “(cancelled)”。

来源