解决前端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/