解决前端JavaScript中的一个并发bug

通常我们认为 JavaScript 是单线程的,不需要处理并发 bug。但是,类似并发的 bug 仍然有可能发生。

我最近在写一个在浏览器中运行的输入法(WebIME),在写的过程中遇到了一个并发 bug 。这篇文章分析了该 bug 并提出了一种解决方法。

原因分析

在 WebIME 中,我们需要通过 addEventListener 捕捉 keydown 事件并且在回调函数中更新输入 buffer 和候选词。如果我们将每一次 keydown 事件后运行的回调函数视为一个线程,候选词和 buffer 视为共享内存,我们很容易发现这里存在发生并发 bug 的可能:两次敲击中前一次的候选词因为某种原因更晚被更新,覆盖了后一次敲击的候选词。

那么单线程的 JavaScript 为什么会发生并发 bug 呢?因为在进行 fetch 以向后端请求候选词的过程中函数被阻塞,最先完成 fetch 的回调函数最先被调度来执行。而我们不能保证此时被调度到的函数是最先发起 fetch 的那个。

输入buffer和候选词

考虑过的解决方案

我考虑过以下几种解决方案,但是都放弃了

Web Locks API

WebAPI 规范提供了一些关于锁的 API:

Web Locks APIhttps://developer.mozilla.org/en-US/docs/Web/API/Web_Locks_API

遗憾的是,该 API 要求在 secure contexts (HTTPS) 中才能使用,而我这个项目是一个作业,计划以 Docker image 的形式提交,不能要求验收的老师给它配上一个证书,所以只得放弃。

如果你只通过 HTTPS 提供网页内容,使用锁 API 是解决此问题的最佳方案。

Atomics 模块

WebAPI 中的 Atomics 模块提供了对 SharedArrayBufferArrayBuffer 的一系列原子化操作。

但是我仔细查看文档后发现,其提供的 API 不足以构造一个可靠的锁。如果你面对的并发问题比较简单,你或许可以试一试。注意由于安全漏洞,很多浏览器已经废弃了对 SharedArrayBuffer 的支持

最终采用的方案

解决这个问题一定需要使用锁吗?或许不一定。有一种处理并发问题的方法是将所有并发操作都交给同一个线程,其他线程要进行并发操作时调用该线程。我们可以利用 JavaScript 的 Promise 来实现这一点。

我设置了一个全局变量 let queue=Promise.resolve() ,并将原来 addEventListener 的回调函数作为一个 Promise 加到它的末尾:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
inputArea.addEventListener('keydown', evt => {
if(condition){
evt.preventDefault();
evt.stopPropagation();
}
queue = queue.then(() => {
return new Promise(
async function (resolve, reject) {
doSomething();
resolve(true);
}
)
});
});

这样,我们可以认为全局变量 queue 代表了处理并发操作的线程内的队列,而该线程同时只处理一个函数。

这样做需要注意几个问题:

  • preventDefault 等函数和其相应的条件判断要从原回调函数中提取出来,放到 Promise 外面,就像我在 2-5 行做的那样
  • 原回调函数中调用的一些异步函数可能需要添加 await

是否有潜在的问题?

如果是在真正的线程调度中,这样做会导致一个潜在的问题:在对 queue 重新赋值的过程中会产生条件竞争。

我们可以考虑这样一种情况:

  1. 用户按下按键 a ,线程 A 启动,读取 queue ,向其末尾添加一个 f1 ,但还没有将 queue 更改为新的值
  2. 用户按下按键 b ,线程 B 启动,读取 queue ,此时读取的 queue 不包含 f1
  3. 线程 A 将 queue 更改为新的值
  4. 线程 B 向它读取的 queue 末尾添加 f2 并完成对 queue 的重新赋值

此时我们可以发现,f1 丢失了。

但是,这一问题在 JavaScript 中并不存在,因为更改后的 addEventListener 中并未调用异步函数,它并不会运行到一半就被调度走。

作者

Cao Mingjun

发布于

2022-07-01

更新于

2022-07-01

许可协议

评论