解决前端JavaScript中的一个并发bug
通常我们认为 JavaScript 是单线程的,不需要处理并发 bug。但是,类似并发的 bug 仍然有可能发生。
我最近在写一个在浏览器中运行的输入法(WebIME),在写的过程中遇到了一个并发 bug 。这篇文章分析了该 bug 并提出了一种解决方法。
原因分析
在 WebIME 中,我们需要通过 addEventListener
捕捉 keydown
事件并且在回调函数中更新输入 buffer
和候选词。如果我们将每一次 keydown
事件后运行的回调函数视为一个线程,候选词和 buffer
视为共享内存,我们很容易发现这里存在发生并发 bug 的可能:两次敲击中前一次的候选词因为某种原因更晚被更新,覆盖了后一次敲击的候选词。
那么单线程的 JavaScript 为什么会发生并发 bug 呢?因为在进行 fetch
以向后端请求候选词的过程中函数被阻塞,最先完成 fetch
的回调函数最先被调度来执行。而我们不能保证此时被调度到的函数是最先发起 fetch
的那个。
考虑过的解决方案
我考虑过以下几种解决方案,但是都放弃了
Web Locks API
WebAPI 规范提供了一些关于锁的 API:
遗憾的是,该 API 要求在 secure contexts (HTTPS) 中才能使用,而我这个项目是一个作业,计划以 Docker image 的形式提交,不能要求验收的老师给它配上一个证书,所以只得放弃。
如果你只通过 HTTPS 提供网页内容,使用锁 API 是解决此问题的最佳方案。
Atomics 模块
WebAPI 中的 Atomics
模块提供了对 SharedArrayBuffer
和 ArrayBuffer
的一系列原子化操作。
但是我仔细查看文档后发现,其提供的 API 不足以构造一个可靠的锁。如果你面对的并发问题比较简单,你或许可以试一试。注意由于安全漏洞,很多浏览器已经废弃了对 SharedArrayBuffer
的支持。
最终采用的方案
解决这个问题一定需要使用锁吗?或许不一定。有一种处理并发问题的方法是将所有并发操作都交给同一个线程,其他线程要进行并发操作时调用该线程。我们可以利用 JavaScript 的 Promise
来实现这一点。
我设置了一个全局变量 let queue=Promise.resolve()
,并将原来 addEventListener
的回调函数作为一个 Promise
加到它的末尾:
1 |
|
这样,我们可以认为全局变量 queue
代表了处理并发操作的线程内的队列,而该线程同时只处理一个函数。
这样做需要注意几个问题:
preventDefault
等函数和其相应的条件判断要从原回调函数中提取出来,放到Promise
外面,就像我在 2-5 行做的那样- 原回调函数中调用的一些异步函数可能需要添加
await
是否有潜在的问题?
如果是在真正的线程调度中,这样做会导致一个潜在的问题:在对 queue
重新赋值的过程中会产生条件竞争。
我们可以考虑这样一种情况:
- 用户按下按键
a
,线程 A 启动,读取queue
,向其末尾添加一个f1
,但还没有将queue
更改为新的值 - 用户按下按键
b
,线程 B 启动,读取queue
,此时读取的queue
不包含f1
- 线程 A 将
queue
更改为新的值 - 线程 B 向它读取的
queue
末尾添加f2
并完成对queue
的重新赋值
此时我们可以发现,f1
丢失了。
但是,这一问题在 JavaScript 中并不存在,因为更改后的 addEventListener
中并未调用异步函数,它并不会运行到一半就被调度走。
解决前端JavaScript中的一个并发bug
https://blog.caomingjun.com/solve-concurrent-bug-in-front-end-javascript/