JavaScript 中的竞态是什么,为啥会有竟态?

前段时间,我写过一篇文章,聊了并发、并行和竞态之间的区别。其中有一个结论很重要:

竞态不是一种执行方式,而是并发过程中可能出现的一类 Bug。

但理解到这里,其实还不够。因为真正写业务代码的时候,我们经常会碰到一个疑问:

JavaScript 明明是单线程,为什么还会发生"竞态"?

这篇文章,我们不讲太多抽象概念,就从一个最常见的搜索框开始,把这件事情讲明白。

一、从一个很普通的搜索框说起

假设我们写了这样一段代码:

javascript 复制代码
async function search(keyword) {
  const res = await fetch(`/api/search?q=${keyword}`)
  const data = await res.json()

  renderList(data)
}

用户输入关键词时调用它:

css 复制代码
input.addEventListener('input', e => {
  search(e.target.value)
})

看起来没有任何问题。

现在用户先输入:

复制代码
vue

于是发送请求 A。

紧接着,他又输入:

复制代码
react

于是发送请求 B。

从业务上看,结果应该非常明确:

用户最后输入的是 react,页面最终就应该展示 react 的结果。

但网络请求有一个特点:

先发出去,不代表先回来!!

可能出现下面这种情况:

css 复制代码
时间 ───────────────────────────────→

请求 A:vue
├───────────────────────────────● 返回

        请求 B:react
        ├────────────● 返回

也就是说:

css 复制代码
A 先发出
B 后发出

但是:

B 先返回
A 后返回

于是页面发生了这样的变化:

css 复制代码
B 返回
↓
页面显示 react

A 返回
↓
页面又被改成 vue

最终页面显示的是:

复制代码
vue

可用户最后输入的明明是:

复制代码
react

Bug 这就出现了。

这就是一个非常典型的:Race Condition 竞态。 \color{red}{竞态。} 竞态。

二、竞态到底在"竞"的是什么?

很多人第一次听到"竞态",很容易把它理解成:

两段代码同时运行,所以发生了竞争。

这个理解并不准确。

在前端开发中,大量竞态问题其实并不存在"两段 JavaScript 同时执行"。

真正的问题是:

多个任务都想修改同一个结果,而最终结果取决于不可控的完成顺序。

可以把它总结成一个非常简单的公式:

markdown 复制代码
多个任务
   +
修改同一个状态
   +
完成顺序不确定
   ↓
竞态风险

拿刚才的搜索请求来说:

css 复制代码
请求 A ─┐
        ├──→ 搜索结果 list
请求 B ─┘

A 和 B 都拥有:

ini 复制代码
list = data

的权力。

问题就出在这里。

三、一个咖啡店的比喻

我们换个生活里的例子。

上午 9:00,你对同事小王说:

帮我买一杯美式。

一分钟以后,你突然改主意了。

于是又对小李说:

算了,我想喝拿铁。

很明显,你现在真正想喝的是:

复制代码
拿铁

结果小李去的咖啡店比较近。

5 分钟以后,拿铁送到了。

又过了 15 分钟,小王才回来。

然后他把美式放在你桌上。

如果程序的规则是:

谁最后回来,就听谁的。

那么你最后得到的就是:

复制代码
美式

但真正合理的规则应该是:

谁代表了用户最新的意图,就听谁的。

这里就出现了竞态最核心的一句话:

完成得晚,不代表它更新。

同样:

最后完成的任务,也不一定拥有最终状态的写入权。

四、JavaScript 是单线程为什么也会有竞态?

这是很多人的疑问。

JavaScript 主线程确实可以简单理解成:

复制代码
一次只执行一段 JavaScript 代码。

但问题在于: 异步任务不需要按照发起顺序完成。

可以把 JavaScript 主线程想象成一家奶茶店的收银员。但是这家店只有一个收银员。

所以:

css 复制代码
顾客 A
↓
收银员接单

顾客 B
↓
收银员接单

接单确实是一个一个来的。

但后厨制作饮料的时候:

css 复制代码
订单 A:珍珠奶茶,8 分钟

订单 B:美式,2 分钟

结果很可能是:

css 复制代码
B 先做好
A 后做好

JavaScript 的异步编程也是类似的。

例如:

csharp 复制代码
const data = await request()

执行到 await 的时候,只是:

当前这个 async 函数暂停等待。

并不是:

整个 JavaScript 世界暂停。

在它等待期间:

复制代码
用户仍然可以点击

watch 仍然可能触发

新的请求仍然可以发出

路由仍然可能变化

所以:

css 复制代码
请求 A 等待中
      ↓
请求 B 又开始了

两个异步任务就发生了重叠。

因此有一句话非常值得记住:

JavaScript 单线程,限制的是代码执行方式;竞态讨论的,是异步任务之间的时序关系。

这两件事情并不矛盾。

五、await 为什么解决不了竞态?

很多人刚开始写 async/await 时,会产生一个错觉:

ini 复制代码
const data = await request()

state.value = data

既然已经 await 了,看起来是不是就"一件一件执行"了?

不是。

我们来看一个简单例子:

javascript 复制代码
async function load(keyword) {
  const data = await request(keyword)

  state.value = data
}

然后快速调用:

lua 复制代码
load('vue')
load('react')

实际上可能形成:

csharp 复制代码
load('vue')
   │
   ├── await ───────────────────┐
   │                            │
load('react')                   │
   │                            │
   ├── await ───────┐           │
                    │           │
                    ● react完成 │
                                │
                                ● vue完成

每一个 load() 内部确实是顺序执行的。

但是:

两个 load() 之间并没有顺序关系。

所以:

await 解决的是"异步代码怎么写得像同步代码"。

它并没有解决:

"多个异步任务之间谁应该赢"。

这是理解竞态非常关键的一步。

六、判断竞态,只需要问三个问题

以后看到异步代码,不需要马上想到什么 Event Loop、微任务、宏任务。

先问三个问题。

我把它叫做:

竞态三角

markdown 复制代码
        多个任务
           ▲
          / \
         /   \
        /     \
       /       \
共享状态 ───── 完成顺序不可控

第一个问题:有没有多个任务同时存在?

例如:

css 复制代码
请求 A 还没结束
请求 B 又开始了

如果没有,就谈不上竞争。

第二个问题:它们会不会修改同一个状态?

例如:

ini 复制代码
list.value = data

两个请求最后都会写:

复制代码
list.value

那么这里就存在共享状态。

而且共享状态不只是接口数据。

还可能包括:

css 复制代码
loading
error
currentPage
selected
form
store

第三个问题:完成顺序能保证吗?

如果答案是:

复制代码
不能

那么竞态三角基本就形成了。

以后排查异步 Bug,可以先套这个模型:

diff 复制代码
多个任务
+
共享状态
+
不确定时序
=
竞态风险

非常有用。

七、解决竞态之前,先回答一个"让谁赢"的问题

很多开发者看到竞态,第一反应就是:

把前一个请求取消掉。

但这其实跳过了最重要的一步。

真正应该先问的是:

如果两个任务发生竞争,到底应该谁赢?

例如搜索框:

复制代码
搜索 vue
↓
搜索 react

我们希望:

复制代码
最新的一次操作赢

这叫:

复制代码
Latest Wins

但有些业务可能恰好相反。

例如"提交订单":

复制代码
点击一次
点击两次
点击三次

我们可能希望:

复制代码
第一次成功提交后
后面的操作全部忽略

也就是:

sql 复制代码
First Wins

还有一些业务要求:

css 复制代码
A 完成
↓
B 才能执行
↓
C 再执行

这种情况应该排队。

所以:

解决竞态,本质上不是选择 API,而是先定义业务规则。

八、搜索场景怎么解决?给请求加一个"号码牌"

继续看我们的搜索场景。假设业务规则已经确定:

最新请求赢。

那么最简单的做法,就是给每一次请求发一个号码牌。

javascript 复制代码
let requestId = 0

async function search(keyword) {
  const currentId = ++requestId

  const res = await fetch(`/api/search?q=${keyword}`)
  const data = await res.json()

  if (currentId !== requestId) {
    return
  }

  renderList(data)
}

第一次搜索:

ini 复制代码
vue

requestId = 1

请求 A 拿到号码:

复制代码
1

第二次搜索:

ini 复制代码
react

requestId = 2

请求 B 拿到号码:

复制代码
2

假设 B 先回来:

ini 复制代码
currentId = 2
requestId = 2

一致。

说明:

我仍然是最新请求。

于是可以更新页面。

后来 A 回来了:

ini 复制代码
currentId = 1
requestId = 2

不一致。

说明:

在我等待期间,已经出现了更新的请求。

所以:

kotlin 复制代码
return

直接丢弃结果。

整个过程可以画成:

ini 复制代码
请求 A #1 ─────────────────────●
                              │
                              └─ 1 !== 2
                                 丢弃

       请求 B #2 ───────●
                        │
                        └─ 2 === 2
                           更新页面

这里真正重要的不是 requestId 这几行代码。

而是背后的设计思想:

异步任务完成之后,不应该直接修改状态。

它应该先检查:我现在有没有写入资格?

九、把 requestId 理解成"版本号"

其实 requestId 还有一个更通用的名字:

版本号。

例如:

ini 复制代码
第一次操作  version = 1

第二次操作  version = 2

第三次操作  version = 3

一个异步任务回来以后,只需要问:

复制代码
我处理的是不是当前版本?

如果不是:

复制代码
丢弃

这种思想以后会在很多地方遇到。

名字可能叫:

复制代码
requestId
version
sequence
token
generation

名字不同。

思想都是一样的:

旧任务不能修改新状态。

十、AbortController 又解决了什么?

前面的版本号方案其实已经能够保证结果正确。

但还有一个问题:

旧请求虽然不能更新页面了,它仍然可能在继续执行。

如果请求已经没用了,我们还可以进一步把它取消。

javascript 复制代码
let controller

async function search(keyword) {
  controller?.abort()

  controller = new AbortController()

  try {
    const res = await fetch(
      `/api/search?q=${keyword}`,
      {
        signal: controller.signal
      }
    )

    const data = await res.json()

    renderList(data)
  } catch (error) {
    if (error.name === 'AbortError') {
      return
    }

    throw error
  }
}

新的搜索出现时:

scss 复制代码
旧请求
   │
   └── abort()

新请求
   │
   └── 开始

但是这里要区分两个概念。

版本号

解决的是:

复制代码
旧结果还能不能写状态?

AbortController

解决的是:

复制代码
旧任务还有没有必要继续执行?

所以很多时候:

diff 复制代码
版本控制
+
取消旧任务

可以同时使用。

一个负责:

正确性。

一个负责:

减少无意义工作。

十一、别忘了 loading 也会有竞态

竞态还有一个特别容易被忽略的地方。

很多人只盯着:

kotlin 复制代码
data.value

其实:

复制代码
loading.value

一样会发生竞态。

例如:

csharp 复制代码
async function load() {
  loading.value = true

  try {
    await request()
  } finally {
    loading.value = false
  }
}

如果同时存在 A、B 两个请求:

ini 复制代码
A 开始
loading = true

B 开始
loading = true

A 完成
loading = false

B 其实还没完成

这时候 loading 已经消失了。

所以:

共享状态不仅仅是接口数据。

这些东西都值得警惕:

go 复制代码
data

loading

error

disabled

selected

page

它们都可能被多个异步任务竞争。

十二、防抖能解决竞态吗?

不能。

这是一个很常见的误区。

例如搜索框用了:

scss 复制代码
debounce(search, 300)

防抖解决的是:

用户连续输入时,不要发送那么多请求。

也就是:

复制代码
控制请求数量

但是只要最终仍然有两个请求发生重叠:

css 复制代码
A ─────────────────●

    B ───────●

B 仍然可能先回来。

所以:

复制代码
debounce

解决的是:

复制代码
发多少次

而:

arduino 复制代码
requestId
AbortController
queue

解决的是:

复制代码
已经发生重叠以后怎么办

这是两个不同的问题。

十三、一个重要的工程习惯:减少"谁都能写状态"

很多复杂项目最后难维护,并不是因为异步特别难。

而是因为:

写同一个状态的人太多。

例如:

perl 复制代码
请求 A ─────→ state
请求 B ─────→ state
请求 C ─────→ state
请求 D ─────→ state

你很难回答:

state 当前这个值,到底是谁写进去的?

更好的结构通常是:

css 复制代码
请求 A ─┐
请求 B ─┼──→ 统一判断 ──→ state
请求 C ─┘

让异步任务负责:

复制代码
产生结果

让统一的逻辑负责:

复制代码
决定谁有资格写入结果

代码就会容易推理很多。

这其实也是竞态控制非常重要的工程思想。

十四、竞态最讨厌的地方:它经常不能稳定复现

普通 Bug:

复制代码
点击
↓
必现

其实不难解决。

竞态最烦的是:

复制代码
点十次
可能九次正常

第十次突然出错

原因就在于:

它依赖时序。

例如正常情况下:

css 复制代码
A:100ms
B:500ms

没有问题。

但某一次网络突然变化:

css 复制代码
A:2000ms
B:100ms

Bug 就出现了。

所以调试竞态时,一个很好用的方法就是:

故意把时序打乱。

例如模拟随机延迟:

javascript 复制代码
function randomDelay() {
  return new Promise(resolve => {
    setTimeout(
      resolve,
      Math.random() * 3000
    )
  })
}

或者直接在 Chrome DevTools 的 Network 中开启网络限速。

然后快速:

复制代码
输入关键词
切换 Tab
切换路由
修改筛选条件

很多平时隐藏的竞态问题,很快就会暴露出来。

十五、真正理解竞态,要换一个问题

以前看到两个请求时,我们可能会问:

哪个请求先发出去?

理解竞态以后,更应该问:

哪个请求现在还有资格修改状态?

前者关注:

复制代码
执行顺序

后者关注:

复制代码
业务规则

一个可靠的程序,不应该依赖:

css 复制代码
希望 A 先回来

希望网络正常

希望服务器快一点

它应该做到:

复制代码
即使任务完成顺序完全被打乱

最终结果仍然正确

十六、再往深一层:竞态其实是"时间参与了业务决策"

还是回到搜索框。

我们真正想实现的业务规则是:

复制代码
展示用户最后输入关键词的结果

但存在竞态的代码,实际上实现成了:

复制代码
展示最后返回的请求结果

仔细看。

这两句话完全不是一个意思。

业务规则是:

复制代码
最后输入者赢

程序却变成:

复制代码
最后返回者赢

于是一个本来不应该决定结果的因素:

复制代码
网络快慢

偷偷参与了业务决策。

这就是竞态非常本质的一面:

程序的正确性错误地依赖了任务完成时间。

十七、最后记住这套判断方法

以后看到:

scss 复制代码
await xxx()

不妨下意识问自己几个问题:

markdown 复制代码
我等待的时候,
还能不能再次触发这个任务?

        ↓

如果可以,
多个任务会不会修改同一个状态?

        ↓

如果会,
它们的完成顺序能不能保证?

        ↓

如果不能,
到底应该谁拥有最终写入权?

最后可以浓缩成两个公式。

判断竞态:

diff 复制代码
竞态风险
=
多个任务
+
共享状态
+
不可控时序

解决竞态:

diff 复制代码
竞态控制
=
明确业务规则
+
控制写入资格

十八、总结

如果这篇文章只记住五个点,我希望是下面这些。

第一,JavaScript 单线程,不代表异步任务按照发起顺序完成。

第二,await 只暂停当前 async 函数,不会暂停整个世界。

第三,竞态的关键不是"有没有同时执行",而是多个任务是否在竞争同一个状态。

第四,解决竞态之前,先确定谁应该赢。

搜索场景可能是:

复制代码
Latest Wins

重复提交可能是:

sql 复制代码
First Wins

有些任务则应该:

复制代码
排队执行

最后,也是最重要的一句话:

可靠的异步代码,不应该依赖任务碰巧按照正确顺序完成。

即使完成顺序被完全打乱,程序也应该得到正确结果。

当你开始下意识思考:

"这个异步任务回来以后,它还有没有资格修改状态?"

其实你对 async/await 的理解,就已经开始从:

复制代码
会写异步代码

进入到了:

复制代码
会控制异步状态

这也是理解竞态真正有价值的地方。

相关推荐
DevUI团队3 小时前
从“即兴创作”到“规格先行”,华为云码道(CodeArts)代码智能体持续深耕企业级规范驱动开发能力
前端·人工智能·后端
weixin_431600444 小时前
NestJS 入门(3):Guard 如何挡住未登录请求?
前端·后端·学习·nest.js
kyriewen5 小时前
我用Claude Code两天干完了团队两周的排期——周报发出去那一刻我就后悔了
前端·javascript·ai编程
IT_陈寒5 小时前
JavaScript类型转换把我坑惨了,这破玩意真该早点搞明白
前端·人工智能·后端
用户938515635075 小时前
Type vs Interface:读完这篇就没有面试官能难倒你了
前端·面试·typescript
油丶酸萝卜别吃6 小时前
jquery-ajax.js 说明文档
前端·javascript·jquery
windliang6 小时前
Claude Code 源码分析(九):子 Agent 如何分叉、继续与回到父会话
前端·javascript·面试
柒和远方7 小时前
V063: TS 面试必考:interface 与 type 的四大差异,与 LLM Harness 的自动化择优
前端·javascript
半个落月7 小时前
React useRef 详解:DOM 引用、持久化值与 Worker 实例
前端·react.js