百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 编程网 > 正文

Javascript 多线程编程的前世今生

yuyutoo 2025-04-07 20:59 2 浏览 0 评论




作者: jolamjiang 腾讯技术工程

转发链接:
https://mp.weixin.qq.com/s/87C9GAFb0Y_i5iPbIL5Hzg

为什么要多线程编程

大家看到文章的标题《Javascript 多线程编程》可能立马会产生疑问:Javascript 不是单线程的吗?Javascript IO 阻塞和其他异步的需求(例如 setTimeout, Promise, requestAnimationFrame, queueMicrotask 等)不是通过事件循环(Event Loop)来解决的吗?

没有错,Javascript 的确是单线程的,阻塞和其他异步的需求的确是通过实现循环来解决的,但是这套机制当线程需要处理大规模的计算的时候就不大适用了,试想一下一下的场景:

  1. 你需要实现对文件的加解密。
  2. 你的 VirtualDom 树有很多元素(例如上万棵),你需要对这棵树进行 Diff 操作。
  3. 你需要在浏览器“挖矿”。

上面这些场景都会阻塞主线程,也就是当进行这些操作的时候,你的页面是卡住的,设置当页面卡住一段时间之后,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 具体包含:

  1. Atomics.add()
  2. Atomics.and()
  3. Atomics.compareExchange()
  4. Atomics.exchange()
  5. Atomics.isLockFree()
  6. Atomics.load()
  7. Atomics.notify()
  8. Atomics.or()
  9. Atomics.store()
  10. Atomics.sub()
  11. Atomics.wait()
  12. 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 的三个状态以及对应的状态机

  1. UNLOCK: 未锁定
  2. LOCKED: 被锁定
  3. WAITED: 被锁定且大于等于 1 个线程在等待该锁

对于 Worker 线程来说 Mutex 的每个状态都可能是初始态,状态与状态间扭转会产生一些操作且进入下一状态:

加锁 lock()

  1. 初始状态为UNLOCK: 锁未被抢占,将状态扭转为 LOCKED,线程进行后续操作。
  2. 初始状态为LOCKED: 锁已被抢占,将状态扭转为 WAITED,并将线程设置为等待态,并将线程设置为当锁的状态不为 WAITED 的时候可能被唤醒,一旦被唤醒则该线程拥有锁,线程进行后续操作。
  3. 初始状态为WAITED: 锁已被抢占,并将线程设置为等待态,并将线程设置为当锁的状态不为 WAITED 的时候可能被唤醒,一旦被唤醒则该线程拥有锁,线程进行后续操作。

释放 unlock()

1.初始状态为LOCKED: 锁被抢占且未被等待,将状态扭转为 UNLOCK,线程进行后续操作。

  1. 初始状态为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 实现的,它的大致功能是持有锁的情况下可进行两种操作:

  1. wait(): 本线程进度进入等待态,并且被唤醒的时候重新持有锁。
  2. 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 只有两个状态:

  1. NORMAL: 非等待态,调用 wait() 转化为 WAITED 状态,并把线程设置为等待态,并且被唤醒的时候重新持有锁,然后进行后续操作。
  2. WAITED: 等待态(不对应上述 Lock 的 WAITED 态),调用 notifyOne() 将状态设置为 NORMAL 态,重新唤醒一个处于等待态的线程,然后进行后续操作。

异步锁

上述介绍的锁都是同步的,Atomics.wait 不能在主线程使用,在主线程使用的话浏览器会抛出异常:

Uncaught TypeError: Atomics.wait cannot be called in this context

所以我们需要设计所谓的”异步锁“,所谓的异步锁原理很简单,就是将同步锁里面的 Atomics.wait() 操作交给一个新的线程,主线程和这个线程通过事件通信来异步化这里的操作。具体实现可以参照这个文件)。

demo 实现

介绍完上述的知识之后,就可以用相关的 API 就可以实现我们的 demo 了,首先画一下我们 demo 的架构图:

如图所示,在线解压缩这个 demo 主要分为两个线程:

  1. 主线程:负责调用 Dom API 等,主要负责 UI 更新。
  2. 工作线程:负责文件的压缩/解压。

两个线程间的通信是通过读写两段共享内存来实现的,对于共享内存的访问,通过锁来解决竞争问题。需要注意的是,主线程的写缓存也即工作线程的读缓存,反之亦然。

demo 的具体实现可以参照 demo 的 Github 地址

目前多线程编程的不足

目前只通过浏览器提供的 API 来进多线程开发的话成本非常大,主要有两方面问题:

过于底层的 API

  1. 需要你实现语言级、或者系统级的 lock API,参照 Golang 的 lock API。
  2. 没有语法上的支持,例如 Java synchronized 关键字等。

普通的 Javascript Object 无法共享

这其实也是 API 过于底层的另一方面的体现,也就是说对 JS 对象进行内存共享的话,你需要开辟一段 SharedArrayBuffer,然后在此之上实现对 JS 对象的序列化、反序列化、更新等操作,实现成本也是比较大的。

事实上我们也不应该轻易手动实现相关的库或者功能,因为相关领域的问题非常复杂、需要仔细的设计和实现。例如我们可以先使用下面这两个库:

  1. parlib-simple: 这个库里面有类似于 Golang 里面 channel 一样的 API。
  2. js-lock-and-condition: 这里库有 Mutex 和 Cond 实现。

总结

浏览器提供给了我们进行多线程的能力,例如 PWA 或者 WebAseembly 与 JS 混用等场景都会用到上述的机制,如果你想实现一个高性能的网页客户端程序(例如 Figma 一样的杀手级应用),你最好也用上上述的机制。值得注意的是,用了锁可能会降低你的程序的性能,具体要看线程切换和等待是的成本是否能够抵消内存拷贝的成本,例如 demo 完全可以改成无锁的,代价将文件内容拷贝到共享线程,并把工作线程的内容拷贝回主线程。

虽然上面建议不要轻易实现自己的库,例如上面的 lock 代码短短几行,但是其中的推导可以足够写十几页的 Paper 了,但是这里的基础能力很匮乏,据笔者了解,TC39 提案中鲜少出现关于多线程编程的提案,目前仅发现以下这个:

  1. proposal-atomics-wait-async

但是,如果自信有能力和时间建设这些基础能力的话,这个领域的确是“广阔天地,大有作为”,特别是如果你的项目准备用 WebAseembly 和 JS 混用的情况(例如 Figma 就是用了 WebAssembly 和 React)。

推荐JavaScript经典实例学习资料文章

微前端方案 qiankun(实践及总结)

「图文」V8 垃圾回收原来这么简单?

Webpack 5模块联邦引发微前端的革命?

基于 Web 端的人脸识别身份验证「实践」

「前端进阶」高性能渲染十万条数据(时间分片)

「前端进阶」高性能渲染十万条数据(虚拟列表)

图解 Promise 实现原理(一):基础实现

图解 Promise 实现原理(二):Promise 链式调用

图解 Promise 实现原理(三):Promise 原型方法实现

图解 Promise 实现原理(四):Promise 静态方法实现

实践教你从零构建前端 Lint 工作流「干货」

高性能多级多选级联组件开发「JS篇」

深入浅出讲解Node.js CLI 工具最佳实战

延迟加载图像以提高Web网站性能的五种方法「实践」

比较 JavaScript 对象的四种方式「实践」

使用Service Worker让你的 Web 应用如虎添翼(上)「干货」

使用Service Worker让你的 Web 应用如虎添翼(中)「干货」

使用Service Worker让你的 Web 应用如虎添翼(下)「干货」

前端如何一次性处理10万条数据「进阶篇」

推荐三款正则可视化工具「JS篇」

如何让用户选择是否离开当前页面?「JS篇」

JavaScript开发人员更喜欢Deno的五大原因

仅用18行JavaScript实现一个倒数计时器

图文细说JavaScript 的运行机制

一个轻量级 JavaScript 全文搜索库,轻松实现站内离线搜索

推荐Web程序员常用的15个源代码编辑器

10个实用的JS技巧「值得收藏」

细品269个JavaScript小函数,让你少加班熬夜(一)「值得收藏」

细品269个JavaScript小函数,让你少加班熬夜(二)「值得收藏」

细品269个JavaScript小函数,让你少加班熬夜(三)「值得收藏」

细品269个JavaScript小函数,让你少加班熬夜(四)「值得收藏」

细品269个JavaScript小函数,让你少加班熬夜(五)「值得收藏」

细品269个JavaScript小函数,让你少加班熬夜(六)「值得收藏」

深入JavaScript教你内存泄漏如何防范

手把手教你7个有趣的JavaScript 项目-上「附源码」

手把手教你7个有趣的JavaScript 项目-下「附源码」

JavaScript 使用 mediaDevices API 访问摄像头自拍

手把手教你前端代码如何做错误上报「JS篇」

一文让你彻底搞懂移动前端和Web 前端区别在哪里

63个JavaScript 正则大礼包「值得收藏」

提高你的 JavaScript 技能10 个问答题

JavaScript图表库的5个首选

一文彻底搞懂JavaScript 中Object.freeze与Object.seal的用法

可视化的 JS:动态图演示 - 事件循环 Event Loop的过程

教你如何用动态规划和贪心算法实现前端瀑布流布局「实践」

可视化的 js:动态图演示 Promises & Async/Await 的过程

原生JS封装拖动验证滑块你会吗?「实践」

如何实现高性能的在线 PDF 预览

细说使用字体库加密数据-仿58同城

Node.js要完了吗?

Pug 3.0.0正式发布,不再支持 Node.js 6/8

纯JS手写轮播图(代码逻辑清晰,通俗易懂)

JavaScript 20 年 中文版之创立标准

值得收藏的前端常用60余种工具方法「JS篇」

箭头函数和常规函数之间的 5 个区别

通过发布/订阅的设计模式搞懂 Node.js 核心模块 Events

「前端篇」不再为正则烦恼

「速围」Node.js V14.3.0 发布支持顶级 Await 和 REPL 增强功能

深入细品浏览器原理「流程图」

JavaScript 已进入第三个时代,未来将何去何从?

前端上传前预览文件 image、text、json、video、audio「实践」

深入细品 EventLoop 和浏览器渲染、帧动画、空闲回调的关系

推荐13个有用的JavaScript数组技巧「值得收藏」

前端必备基础知识:window.location 详解

不要再依赖CommonJS了

犀牛书作者:最该忘记的JavaScript特性

36个工作中常用的JavaScript函数片段「值得收藏」

Node + H5 实现大文件分片上传、断点续传

一文了解文件上传全过程(1.8w字深度解析)「前端进阶必备」

【实践总结】关于小程序挣脱枷锁实现批量上传

手把手教你前端的各种文件上传攻略和大文件断点续传

字节跳动面试官:请你实现一个大文件上传和断点续传

谈谈前端关于文件上传下载那些事【实践】

手把手教你如何编写一个前端图片压缩、方向纠正、预览、上传插件

最全的 JavaScript 模块化方案和工具

「前端进阶」JS中的内存管理

JavaScript正则深入以及10个非常有意思的正则实战

前端面试者经常忽视的一道JavaScript 面试题

一行JS代码实现一个简单的模板字符串替换「实践」

JS代码是如何被压缩的「前端高级进阶」

前端开发规范:命名规范、html规范、css规范、js规范

【规范篇】前端团队代码规范最佳实践

100个原生JavaScript代码片段知识点详细汇总【实践】

关于前端174道 JavaScript知识点汇总(一)

关于前端174道 JavaScript知识点汇总(二)

关于前端174道 JavaScript知识点汇总(三)

几个非常有意思的javascript知识点总结【实践】

都2020年了,你还不会JavaScript 装饰器?

JavaScript实现图片合成下载

70个JavaScript知识点详细总结(上)【实践】

70个JavaScript知识点详细总结(下)【实践】

开源了一个 JavaScript 版敏感词过滤库

送你 43 道 JavaScript 面试题

3个很棒的小众JavaScript库,你值得拥有

手把手教你深入巩固JavaScript知识体系【思维导图】

推荐7个很棒的JavaScript产品步骤引导库

Echa哥教你彻底弄懂 JavaScript 执行机制

一个合格的中级前端工程师需要掌握的 28 个 JavaScript 技巧

深入解析高频项目中运用到的知识点汇总【JS篇】

JavaScript 工具函数大全【新】

从JavaScript中看设计模式(总结)

身份证号码的正则表达式及验证详解(JavaScript,Regex)

浏览器中实现JavaScript计时器的4种创新方式

Three.js 动效方案

手把手教你常用的59个JS类方法

127个常用的JS代码片段,每段代码花30秒就能看懂-【上】

深入浅出讲解 js 深拷贝 vs 浅拷贝

手把手教你JS开发H5游戏【消灭星星】

深入浅出讲解JS中this/apply/call/bind巧妙用法【实践】

手把手教你全方位解读JS中this真正含义【实践】

书到用时方恨少,一大波JS开发工具函数来了

干货满满!如何优雅简洁地实现时钟翻牌器(支持JS/Vue/React)

手把手教你JS 异步编程六种方案【实践】

让你减少加班的15条高效JS技巧知识点汇总【实践】

手把手教你JS开发H5游戏【黄金矿工】

手把手教你JS实现监控浏览器上下左右滚动

JS 经典实例知识点整理汇总【实践】

2.6万字JS干货分享,带你领略前端魅力【基础篇】

2.6万字JS干货分享,带你领略前端魅力【实践篇】

简单几步让你的 JS 写得更漂亮

恭喜你获得治疗JS this的详细药方

谈谈前端关于文件上传下载那些事【实践】

面试中教你绕过关于 JavaScript 作用域的 5 个坑

Jquery插件(常用的插件库)

【JS】如何防止重复发送ajax请求

JavaScript+Canvas实现自定义画板

Continuation 在 JS 中的应用「前端篇」

作者: jolamjiang 腾讯技术工程

转发链接:
https://mp.weixin.qq.com/s/87C9GAFb0Y_i5iPbIL5Hzg

相关推荐

国内外注塑机及电脑密码大全(常见注塑机通用密码)

一、国外注塑机(日本、德国等)东洋注塑机万能码:9422345日精注塑机密码:222|7777DAMEG注塑机密码:000000000新泻注塑机密码:241650|261450住友注塑机密码:...

并发编程实战来咯(并发编程的艺术和并发编程实战)

提到并发编程,就不得不提C++ConcurrencyinAction(SecondEdition)(《C++并发编程实战第2版》)啦!《C++并发编程实战第2版》英文原版&中文译版看到这个...

无锁队列Disruptor原理解析(无锁队列应用场景)

队列比较队列...

理解 Memory barrier(内存屏障)(内存屏障 volatile)

...

并发编程 --- CAS原子操作(cas概念、原子类实现原理)

...

无锁CAS(附无锁队列的实现)(cas是一种无锁算法)

本文所有代码对应的Github链接为:https://github.com/dongyusheng/csdn-code/tree/master/cas_queue...

Linux高性能服务器设计(linux 服务器性能)

C10K和C10M计算机领域的很多技术都是需求推动的,上世纪90年代,由于互联网的飞速发展,网络服务器无法支撑快速增长的用户规模。1999年,DanKegel提出了著名的C10问题:一台服务器上同时...

浅谈Go语言的并发控制(go语言 并发)

前言本文原创,著作权归...

Datenlord |Etcd 客户端缓存实践(etcd 数据存储)

简介和背景...

无锁编程——从CPU缓存一致性讲到内存模型

缓存是一个非常常用的工程优化手段,其核心在于提升数据访问的效率。缓存思想基于局部性原理,这个原理包括时间局部性和空间局部性两部分:...

打通 JAVA 与内核系列之 一 ReentrantLock 锁的实现原理

...

如何利用CAS技术实现无锁队列(cas会锁总线吗)

linux服务器开发相关视频解析:...

Kotlin协程之一文看懂Channel管道

概述Channel类似于Java的BlockingQueue阻塞队列,不同之处在于Channel提供了挂起的send()和receive()方法。另外,通道Channel可以...

详解C++高性能无锁队列的原理与实现

1.无锁队列原理1.1.队列操作模型...

Javascript 多线程编程的前世今生

...

取消回复欢迎 发表评论: