前段时间,我写过一篇文章,聊了并发、并行和竞态之间的区别。其中有一个结论很重要:
竞态不是一种执行方式,而是并发过程中可能出现的一类 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 , 竞态。
二、竞态到底在"竞"的是什么?
很多人第一次听到"竞态",很容易把它理解成:
两段代码同时运行,所以发生了竞争。
这个理解并不准确。
在前端开发中,大量竞态问题其实并不存在"两段 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 的理解,就已经开始从:
会写异步代码
进入到了:
会控制异步状态
这也是理解竞态真正有价值的地方。