Javascript 多线程编程的前世今生
myzbx 2025-05-09 20:34 3 浏览
作者: jolamjiang 腾讯技术工程
转发链接:
https://mp.weixin.qq.com/s/87C9GAFb0Y_i5iPbIL5Hzg
为什么要多线程编程
大家看到文章的标题《Javascript 多线程编程》可能立马会产生疑问:Javascript 不是单线程的吗?Javascript IO 阻塞和其他异步的需求(例如 setTimeout, Promise, requestAnimationFrame, queueMicrotask 等)不是通过事件循环(Event Loop)来解决的吗?
没有错,Javascript 的确是单线程的,阻塞和其他异步的需求的确是通过实现循环来解决的,但是这套机制当线程需要处理大规模的计算的时候就不大适用了,试想一下一下的场景:
- 你需要实现对文件的加解密。
- 你的 VirtualDom 树有很多元素(例如上万棵),你需要对这棵树进行 Diff 操作。
- 你需要在浏览器“挖矿”。
上面这些场景都会阻塞主线程,也就是当进行这些操作的时候,你的页面是卡住的,设置当页面卡住一段时间之后,Chrome 等浏览器或者操作系统会建议你 Kill 掉整个 Tab 或者进程。这显然不是我们想看到的事情。正因为这些场景的存在,浏览器提出了 W3C 在 2013 年提出了 Web Worker 草案,这个草案的提出就是为了解决上述这些问题。
为了让大家感受 JS 多线程能够干什么,笔者写了一个基于 Web Worker(线程)、ShareArrayBuffer(共享内存)、Atomics(锁)等 Web API 的在前端压缩和解压文件(基于 DEFLATE 算法)的 demo:
查看视频,点击 Demo 的在线地址 自己来试试吧。
Web Worker
Chrome 浏览器中每个 Tab 都是一个进程,每个进程都会有一个主线程,网页的渲染(Style, Layout, Paint, Composite)会在主线程进行操作。主线程可以发起多个 Web Worker,Web Worker 对应“线程”的概念。
每个 Web Worker 都对应一个脚本文件,主线程可以通过像以下的代码去发起多个 Web Worker,并且通过基于事件的 API 与 Web Worker 通信:
main.js
let worker = new Worker("work.js");
worker.postMessage("Hello World");
worker.onmessage = function (event) {
console.log("Received message " + event.data);
}
Web Worker 也通过相应的实现 API 与主线程进行通信
worker.js
this.addEventListener("message", function (e) {
this.postMessage("You said: " + e.data);
}, false);
Web Worker 通讯的效率与同步问题
主线程与 Web Worker 通过 postMessage(data: any) 通信的时候,data会先被 copy 一份再传给 Web Worker;同样地,当 Web Worker 通过 postMessage(data: any) 与主线程通信的时候,data 也会同样先被 copy 一份再传给主线程。
这样做显然会导致通信上的效率问题,试想一下你需要在 Web Worker 里面解压一个 1G 大小的问题,你需要把整个 1G 的文件 copy 到 Web Worker 里,Web Worker 解压完这个 1G 文件后,再把解压完的文件 copy 回主线程里。
SharedArrayBuffer
为了解决通讯效率问题,浏览器提出了 ShareArrayBuffer,ShareArrayBuffer 基于 ArrayBuffer 和 TypedArray API。ArrayBuffer 对应一段内存(二进制内容),为了操作这段内存,浏览器需要提供一些视图(Int8Array 等),例如可以把这段内存当做每 8 位一个单元的 byte 数组,每 16 位一个单元的 16 位有符号数数组。
注意:ArrayBuffer 中的二进制流被翻译成各种视图的时候采用小端还是大端是由具体硬件决定的,绝大部分情况下是采用小端字节顺序。
这段内存可以在不同的 Worker 之间共享,但是内存的共享又会产生另外的问题,也就是竞争的问题(race onditions):
计算机指令对内存操作进行运算的时候,我们可以看做分两步:一是从内存中取值,二是运算并给某段内存赋值。当我们有两个线程对同一个内存地址进行 +1 操作的时候,假设线程是先后顺序运行的,为了简化模型,我们可以如下图表示:
上面两个线程的运行结果也符合我们的预期,也即线程分别都对同一地址进行了 +1 操作,最后得到结果 3。但因为两个线程是同时运行的,往往会发生下图所表示的问题,也即读取与写入可能不在一个事务中发生:
这种情况就叫做竞争问题(Race Condition)。
Atomics
为了解决上述的竞争问题,浏览器提供了 Atomics API,这组 API 是一组原子操作,可以将读取和写入绑定起来,例如下图中的 S1 到 S3 操作就被浏览器封装成 Atomics.add() 这个 API,从而解决竞争问题。
Atomics API 具体包含:
- Atomics.add()
- Atomics.and()
- Atomics.compareExchange()
- Atomics.exchange()
- Atomics.isLockFree()
- Atomics.load()
- Atomics.notify()
- Atomics.or()
- Atomics.store()
- Atomics.sub()
- Atomics.wait()
- Atomics.xor()
有了这套 API,我们可以实现像 Golang 中的 Golang Synchronization Primitives 的功能。Mutex 和 Cond 的实现会在下面介绍。
WebAssembly
有了 SharedArrayBuffer 和 Atomics 能力之后,证明浏览器能够提供内存共享和锁的实现了,也就是说 WebAssembly 线程在浏览器机制上能够高效地得到保证。
其实我严重怀疑 SharedArrayBuffer 和 Atomics 是为了支持 WebAssembly 才把 API 顺便提供给 JS Runtime 的,因为目前为止没有看到 ES 有比较丰富的关于锁的草案(例如像 Java 中的 synchronized 关键字)。
Mutext 和 Cond 的实现
上面提到了,基于 ShareArrayBuffer 和 Atomics 可以开发像 Golang Synchronization Primitives 一样的 API,下面介绍一下 Mutex 和 Cond 的实现。实现的介绍是基于 Mozzila Javascript 编译器工程师 Lars T Hansen 实现关于锁的库。
Mutex
首先说一下 Mutex 的功能,Mutex 的 API 大概是这样的:
let mutex = new Lock(shareArrayBuffer, ...);
mutex.lock();
doSomething();
mutex.unlock();
Mutex 可以保证 lock() 和 unlock() 之间的代码代码不会被打断。下面是介绍具体实现:
首先定义 Mutex 的三个状态以及对应的状态机
- UNLOCK: 未锁定
- LOCKED: 被锁定
- WAITED: 被锁定且大于等于 1 个线程在等待该锁
对于 Worker 线程来说 Mutex 的每个状态都可能是初始态,状态与状态间扭转会产生一些操作且进入下一状态:
加锁 lock()
- 初始状态为UNLOCK: 锁未被抢占,将状态扭转为 LOCKED,线程进行后续操作。
- 初始状态为LOCKED: 锁已被抢占,将状态扭转为 WAITED,并将线程设置为等待态,并将线程设置为当锁的状态不为 WAITED 的时候可能被唤醒,一旦被唤醒则该线程拥有锁,线程进行后续操作。
- 初始状态为WAITED: 锁已被抢占,并将线程设置为等待态,并将线程设置为当锁的状态不为 WAITED 的时候可能被唤醒,一旦被唤醒则该线程拥有锁,线程进行后续操作。
释放 unlock()
1.初始状态为LOCKED: 锁被抢占且未被等待,将状态扭转为 UNLOCK,线程进行后续操作。
- 初始状态为WAITED: 锁被抢占且被等待,将状态扭转为 LOCKED,唤醒一个在等待态的线程,线程进行后续操作。
上面描述的逻辑的对应的代码如下:
// lock
Lock.prototype.lock = function () {
const iab = this._iab;
const stateIdx = this._ibase;
let c;
if ((c = Atomics.compareExchange(iab, stateIdx, 0, 1)) != 0) {
do {
if (c == 2 || Atomics.compareExchange(iab, stateIdx, 1, 2) != 0)
Atomics.wait(iab, stateIdx, 2);
} while ((c = Atomics.compareExchange(iab, stateIdx, 0, 2)) != 0);
}
}
// unlock
Lock.prototype.unlock = function () {
const iab = this._iab;
const stateIdx = this._ibase;
let v0 = Atomics.sub(iab, stateIdx, 1);
// Wake up a waiter if there are any
if (v0 != 1) {
Atomics.store(iab, stateIdx, 0);
Atomics.notify(iab, stateIdx, 1);
}
}
可以看到锁的实现用到了 Atomics.compareExchange() 和 Atomics.wait()(相当于 Linux 中的 futex)两个原子操作。
Cond
Cond 是基于 Mutex 实现的,它的大致功能是持有锁的情况下可进行两种操作:
- wait(): 本线程进度进入等待态,并且被唤醒的时候重新持有锁。
- notifyOne(): 唤醒一个正在等待态的线程。
具体使用方法如下:
// thread A
var msg = new Int32Array(sab, msgLoc, 1);
lock.lock();
while (msg[0] < numWorkers)
cond.wait();
lock.unlock();
// thread B, C, D, E, …
var msg = new Int32Array(sab, msgLoc, 1);
lock.lock();
msg[0]++;
cond.notifyOne();
lock.unlock();
由于 Cond 是基于 Mutex,前置条件是持有锁,后置条件是释放锁,你可以看做 Cond 只有两个状态:
- NORMAL: 非等待态,调用 wait() 转化为 WAITED 状态,并把线程设置为等待态,并且被唤醒的时候重新持有锁,然后进行后续操作。
- WAITED: 等待态(不对应上述 Lock 的 WAITED 态),调用 notifyOne() 将状态设置为 NORMAL 态,重新唤醒一个处于等待态的线程,然后进行后续操作。
异步锁
上述介绍的锁都是同步的,Atomics.wait 不能在主线程使用,在主线程使用的话浏览器会抛出异常:
Uncaught TypeError: Atomics.wait cannot be called in this context
所以我们需要设计所谓的”异步锁“,所谓的异步锁原理很简单,就是将同步锁里面的 Atomics.wait() 操作交给一个新的线程,主线程和这个线程通过事件通信来异步化这里的操作。具体实现可以参照这个文件)。
demo 实现
介绍完上述的知识之后,就可以用相关的 API 就可以实现我们的 demo 了,首先画一下我们 demo 的架构图:
如图所示,在线解压缩这个 demo 主要分为两个线程:
- 主线程:负责调用 Dom API 等,主要负责 UI 更新。
- 工作线程:负责文件的压缩/解压。
两个线程间的通信是通过读写两段共享内存来实现的,对于共享内存的访问,通过锁来解决竞争问题。需要注意的是,主线程的写缓存也即工作线程的读缓存,反之亦然。
demo 的具体实现可以参照 demo 的 Github 地址。
目前多线程编程的不足
目前只通过浏览器提供的 API 来进多线程开发的话成本非常大,主要有两方面问题:
过于底层的 API
- 需要你实现语言级、或者系统级的 lock API,参照 Golang 的 lock API。
- 没有语法上的支持,例如 Java synchronized 关键字等。
普通的 Javascript Object 无法共享
这其实也是 API 过于底层的另一方面的体现,也就是说对 JS 对象进行内存共享的话,你需要开辟一段 SharedArrayBuffer,然后在此之上实现对 JS 对象的序列化、反序列化、更新等操作,实现成本也是比较大的。
事实上我们也不应该轻易手动实现相关的库或者功能,因为相关领域的问题非常复杂、需要仔细的设计和实现。例如我们可以先使用下面这两个库:
- parlib-simple: 这个库里面有类似于 Golang 里面 channel 一样的 API。
- js-lock-and-condition: 这里库有 Mutex 和 Cond 实现。
总结
浏览器提供给了我们进行多线程的能力,例如 PWA 或者 WebAseembly 与 JS 混用等场景都会用到上述的机制,如果你想实现一个高性能的网页客户端程序(例如 Figma 一样的杀手级应用),你最好也用上上述的机制。值得注意的是,用了锁可能会降低你的程序的性能,具体要看线程切换和等待是的成本是否能够抵消内存拷贝的成本,例如 demo 完全可以改成无锁的,代价将文件内容拷贝到共享线程,并把工作线程的内容拷贝回主线程。
虽然上面建议不要轻易实现自己的库,例如上面的 lock 代码短短几行,但是其中的推导可以足够写十几页的 Paper 了,但是这里的基础能力很匮乏,据笔者了解,TC39 提案中鲜少出现关于多线程编程的提案,目前仅发现以下这个:
- proposal-atomics-wait-async
但是,如果自信有能力和时间建设这些基础能力的话,这个领域的确是“广阔天地,大有作为”,特别是如果你的项目准备用 WebAseembly 和 JS 混用的情况(例如 Figma 就是用了 WebAssembly 和 React)。
推荐JavaScript经典实例学习资料文章
《图解 Promise 实现原理(二):Promise 链式调用》
《图解 Promise 实现原理(三):Promise 原型方法实现》
《图解 Promise 实现原理(四):Promise 静态方法实现》
《使用Service Worker让你的 Web 应用如虎添翼(上)「干货」》
《使用Service Worker让你的 Web 应用如虎添翼(中)「干货」》
《使用Service Worker让你的 Web 应用如虎添翼(下)「干货」》
《一个轻量级 JavaScript 全文搜索库,轻松实现站内离线搜索》
《细品269个JavaScript小函数,让你少加班熬夜(一)「值得收藏」》
《细品269个JavaScript小函数,让你少加班熬夜(二)「值得收藏」》
《细品269个JavaScript小函数,让你少加班熬夜(三)「值得收藏」》
《细品269个JavaScript小函数,让你少加班熬夜(四)「值得收藏」》
《细品269个JavaScript小函数,让你少加班熬夜(五)「值得收藏」》
《细品269个JavaScript小函数,让你少加班熬夜(六)「值得收藏」》
《手把手教你7个有趣的JavaScript 项目-上「附源码」》
《手把手教你7个有趣的JavaScript 项目-下「附源码」》
《JavaScript 使用 mediaDevices API 访问摄像头自拍》
《一文彻底搞懂JavaScript 中Object.freeze与Object.seal的用法》
《可视化的 JS:动态图演示 - 事件循环 Event Loop的过程》
《可视化的 js:动态图演示 Promises & Async/Await 的过程》
《Pug 3.0.0正式发布,不再支持 Node.js 6/8》
《通过发布/订阅的设计模式搞懂 Node.js 核心模块 Events》
《「速围」Node.js V14.3.0 发布支持顶级 Await 和 REPL 增强功能》
《JavaScript 已进入第三个时代,未来将何去何从?》
《前端上传前预览文件 image、text、json、video、audio「实践」》
《深入细品 EventLoop 和浏览器渲染、帧动画、空闲回调的关系》
《推荐13个有用的JavaScript数组技巧「值得收藏」》
《36个工作中常用的JavaScript函数片段「值得收藏」》
《一文了解文件上传全过程(1.8w字深度解析)「前端进阶必备」》
《手把手教你如何编写一个前端图片压缩、方向纠正、预览、上传插件》
《JavaScript正则深入以及10个非常有意思的正则实战》
《前端开发规范:命名规范、html规范、css规范、js规范》
《100个原生JavaScript代码片段知识点详细汇总【实践】》
《手把手教你深入巩固JavaScript知识体系【思维导图】》
《一个合格的中级前端工程师需要掌握的 28 个 JavaScript 技巧》
《身份证号码的正则表达式及验证详解(JavaScript,Regex)》
《127个常用的JS代码片段,每段代码花30秒就能看懂-【上】》
《深入浅出讲解JS中this/apply/call/bind巧妙用法【实践】》
《干货满满!如何优雅简洁地实现时钟翻牌器(支持JS/Vue/React)》
《面试中教你绕过关于 JavaScript 作用域的 5 个坑》
作者: jolamjiang 腾讯技术工程
转发链接:
https://mp.weixin.qq.com/s/87C9GAFb0Y_i5iPbIL5Hzg
相关推荐
- MORROR ART:毫无音质可言,真的只是好看而已...
-
今天早上我在微博上发了一条短视频,内容是某款网红音箱正在放声歌唱——这玩意就是此前曾经在网上挺火的所谓“悬浮歌词音箱”。这款产品是我同事收到的礼品,但她嫌在家里放着没用,所以拿到公司里做我们的拍摄道具...
- 「JS优化篇」你的 if - else 代码肯定没我写的好
-
作者:小生方勤转发链接:https://mp.weixin.qq.com/s/JzOQ_OwAYoP5Ic1VBtCZNA前言最近部门在对以往的代码做一些优化,我在代码中看到一连串的if(){}el...
- 细聊微内核架构在前端的应用「干货」
-
作者:semlinker转发链接:https://mp.weixin.qq.com/s/ywc98dS4TVB4t3L2tIyk8g一、微内核架构简介1.1微内核的概念微内核架构(Microke...
- ThreeJS 入门教程(一) 是选择桌面的固守还是云原生?
-
导读:最近我购置了一台新的电脑,硬盘空间只有1T。我很担心这个电脑还能用多久。性能限制或者空间的限制,都使得在未来3-5年内,这个电脑会被淘汰。但是,基于云APP的使用,老的电脑是足够了,而且,我们也...
- 推荐三款正则可视化工具「JS篇」(正则在线调试)
-
作者:代码先森转发链接:https://mp.weixin.qq.com/s/rw29yKBwti5sIsx2GKG9pw前言最近老王对可视化非常着迷。例如,算法可视化、正则可视化、Vue数据劫持可...
- Javascript 多线程编程的前世今生
-
作者:jolamjiang腾讯技术工程转发链接:https://mp.weixin.qq.com/s/87C9GAFb0Y_i5iPbIL5Hzg为什么要多线程编程大家看到文章的标题《Javasc...
- Pug 3.0.0正式发布,不再支持 Node.js 6/8
-
作者:李俊辰前端之巅转发链接:https://mp.weixin.qq.com/s/q-49Gf-SFijeu7d2MqztIQ前言近日,Pug3.0.0正式发布,Pug原名Jade,是由...
- 36个工作中常用的JavaScript函数片段「值得收藏」
-
作者:Eno_Yao转发链接:https://segmentfault.com/a/1190000022623676前言如果文章和笔记能带您一丝帮助或者启发,请不要吝啬你的赞和收藏,你的肯定是我前进的...
- 深入JavaScript教你内存泄漏如何防范
-
作者:大道至简转发链接:https://mp.weixin.qq.com/s/0w6aWwpR3MAJnmyLwDnAzA前言一般情况下,忽视内存管理不会对传统的网页产生显著的后果。这是因为,用户刷新...
- 由浅入深,66条JavaScript面试知识点(七)
-
作者:JakeZhang转发链接:https://juejin.im/post/5ef8377f6fb9a07e693a6061目录由浅入深,66条JavaScript面试知识点(一)由浅入深,66...
- 用STM32做了个电子秤,成本仅两位数,精度高!解析一下原理
-
俗话说得好!人在胖,秤在看!所以,我想DIY一个精度高的体重秤!并希望它不只能称体重:还能像这样称克重(可设置KG,G,最低可称100克)……这样一来,做甜品的时候,还能拿来应应急。保姆级教程,记录在...
- 前端开发需要了解常用7种JavaScript设计模式
-
作者|Deven译者|王强策划|小智转发链接:https://mp.weixin.qq.com/s/Lw4D7bfUSw_kPoJMD6W8gg前言JavaScript中的设计模式指的是...
- 毛姆的一个手法|王培军(毛姆作品简介)
-
鲁本斯画《海伦娜·芙尔曼肖像》钱锺书在《宋诗选注》文同小传中说:“具体的把当前风物比拟为某种画法或某某大画家的名作”,是“从文同正式起头”。如钱先生所举的:“峰峦李成似,涧谷范宽能”,“独坐水轩人不到...
- 欣赏 | 朝戈:我渴望找到直达心灵的永恒
-
朋友,通过艺术让我们共同感知世界的永恒与不朽。——朝戈橙色的人物117X71cm布面油画2003包与陈185cm×103cm2007年白色80cm×40cm2009年光布面油画-Light-Oilo...
- Web页面如此耗电!到了某种程度,会是大损失
-
现在用户上网大多使用移动设备或者笔记本电脑。对这两者来说,电池寿命都很重要。在这篇文章里,我们将讨论影响电池寿命的因素,以及作为一个web开发者,我们如何让网页耗电更少,以便用户有更多时间来关注我们的...
- 一周热门
- 最近发表
- 标签列表
-
- HTML 简介 (30)
- HTML 响应式设计 (31)
- HTML URL 编码 (32)
- HTML Web 服务器 (31)
- HTML 表单属性 (32)
- HTML 音频 (31)
- HTML5 支持 (33)
- HTML API (36)
- HTML 总结 (32)
- HTML 全局属性 (32)
- HTML 事件 (31)
- HTML 画布 (32)
- HTTP 方法 (30)
- 键盘快捷键 (30)
- CSS 语法 (35)
- CSS 选择器 (30)
- CSS 轮廓 (30)
- CSS 轮廓宽度 (31)
- CSS 谷歌字体 (33)
- CSS 链接 (31)
- CSS 中级教程 (30)
- CSS 定位 (31)
- CSS 图片库 (32)
- CSS 图像精灵 (31)
- SVG 文本 (32)