# JS 多线程并发

# 为什么需要并发

我们常听说 JS 是单线程模型,即所有代码都在主线程 (opens new window)中执行的。
如果某些任务计算量较大,将阻塞主线程,UI 界面轻则掉帧、重则卡死。

// 提示:本文所有代理均可复制到浏览器控制台中执行,验证效果

// 在任意网页控制台执行以下代码,页面将卡住 3s
function execTask() {
    const t = performance.now()
    // 模拟耗时任务
    while(performance.now() - t < 3000){}
}
execTask()

所以在计算量大的场景,JS 需要支持并发能力,避免主线程阻塞,影响用户体验。

# 并发面临的问题

用一个极简化示例,来说明并发面临问题:
10 个线程同时执行 1000 个任务,如何避免某个任务被重复执行?

方法 1:
任务列表对线程不可见,而是新开一个线程来统一分配任务,并收集其他线程的执行结果。

方法 2:
任务列表对所有线程可见(共享内存),线程先排队去领取任务编号,然后执行对应编号的任务。

拓展阅读并发问题 (opens new window)

# JS 中如何实现上述两种方法

JS 采用 Web Worker (opens new window) API 来实现多线程并发。

任务说明:将 1~1000 每个数字求平方(每次随机卡住 0~100ms 模拟耗时任务)

# 分配任务,多 Worker 执行

function workerSetup() {
  self.onmessage = (evt) => {
    const t = performance.now()
    // 模拟耗时任务,随机消耗时间 0~100ms
    while(performance.now() - t < Math.random() * 100){}

    const { idx, val } = evt.data
    // 实际上只是算一下参数的平方
    self.postMessage({
      idx: idx,
      val: val * val
    })
  }
}
// 创建一个运行 workerSetup 函数的 worker
const createWorker = () => {
  const blob = new Blob([`(${workerSetup.toString()})()`])
  const url = URL.createObjectURL(blob)
  return new Worker(url)
}
// 模拟 1000 个任务
const tasks = Array(1000).fill(0).map((_, idx) => idx + 1)
const result = []
let rsCount = 0
const onMsg = (evt) => {
  result[evt.data.idx] = evt.data.val
  rsCount += 1
  // 所有任务完成时打印结果
  if (rsCount === tasks.length) {
    console.log('task:', tasks)
    console.log('result:', result)
  }
}

// 模拟线程池
const workerPool = Array(10).fill(0).map(createWorker)
workerPool.forEach((worker, idx) => {
  worker.onmessage = onMsg
  worker.id = idx
})

for (const idx in tasks) {
  // 随机分配任务
  const worker = workerPool[Math.floor(Math.random() * workerPool.length)]
  worker.postMessage({ idx, val: tasks[idx] })
  console.log(`Worker ${worker.id}, process task ${idx}`)
}

# 多 Worker 共享任务(内存)

SharedArrayBuffer (opens new window) 是 JS 提供的唯一可在不同线程间共享内存的方式。

为应对幽灵漏洞,所有主流浏览器均默认于 2018 年 1 月 5 日禁用 SharedArrayBuffer。
在 2020 年,一种新的、安全的方法已经标准化,以重新启用 SharedArrayBuffer。
需要设置两个 HTTP 消息头以跨域隔离你的站点:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

TIP

在浏览器中执行以下代码,请先确保 SharedArrayBuffer 可用。
可复制代码在 该页面 的控制台执行测试

// 执行以下代码,预期打印 1000 次 ’process task‘
function workerSetup() {
  function execTask(val) {
    const t = performance.now()
    // 模拟耗时任务,随机消耗时间 0~100ms
    while (performance.now() - t < Math.random() * 100) {}
    return val * val
  }
  self.onmessage = (evt) => {
    const { idx, sab } = evt.data
    const uint16Arr = new Uint16Array(sab)
    while(true){
      // Atomics.add 模拟排队领取任务
      // 如果分成两步(取值、+1)获取任务编号,会出现抢任务的现象(重复执行任务)
      // taskNo = uint16Arr[0]; uint16Arr[0] = taskNo + 1
      const taskNo = Atomics.add(uint16Arr, 0, 1) 
      if (taskNo >= uint16Arr.length) break
      
      // 每个任务写不同的位置,所以不需要原子操作
      uint16Arr[taskNo] = execTask(uint16Arr[taskNo])
      console.log(`Worker ${idx}, process task ${taskNo}`)
    }
    self.postMessage(true)
  }
}

const createWorker = () => {
  const blob = new Blob([`(${workerSetup.toString()})()`])
  const url = URL.createObjectURL(blob)
  return new Worker(url)
}

// 第一位存放下一个任务编号, 后面 1000 存放对应任务及结果
const sab = new SharedArrayBuffer((1 + 1000) * 2)
const uint16Arr = new Uint16Array(sab)
uint16Arr[0] = 1
for (let i = 1; i < uint16Arr.length; i++) {
  uint16Arr[i] = i 
}
// 模拟线程池,创建 10 个 worker
const workerPool = Array(10).fill(0).map(createWorker)

let rsCount = 0
const onMsg = () => {
  rsCount += 1
  if (rsCount === workerPool.length) {
    console.log('result:', uint16Arr, sab)
  }
}
workerPool.forEach((worker, idx) => {
  worker.onmessage = onMsg
  worker.postMessage({ idx, sab })
})

Atomics (opens new window)对象提供了一组静态方法对 SharedArrayBuffer 对象进行原子操作。

# 两个方法对比

# 方法 1(分配任务)

处理 1000 个任务,调用了 2000 次(分配任务、反馈结果) postMessage,也就是数据在两个 worker 间传递,经历了 2000 次结构化克隆 (opens new window)
通常来说结构化克隆的速度比较快,影响不大 (opens new window)

Even on the slowest devices, you can postMessage() objects up to 100KiB and stay within your 100ms response budget. If you have JS-driven animations, payloads up to 10KiB are risk-free. This should be sufficient for most apps.
即使在非常慢的设备上,你也可以使用 postMessage() 传递 100KiB 的对象,可保证在 100 毫秒内响应。如果有用 JS 驱动的动画,那么传递 10KiB 的数据是无风险的。这对于大多数应用程序来说应该足够了。

另外,部分原生对象是 Transferable objects (opens new window),postMessage(arrayBuffer, [arrayBuffer]) 可以转移这些对象的所有权,无需clone,原线程将无法读写被转移所有权的对象。

目前实现 Transferrable 的对象有:ArrayBuffer, MessagePort, ReadableStream, WritableStream, TransformStream, AudioData, ImageBitmap, VideoFrame, OffscreenCanvas, RTCDataChannel

Web Worker 传递 ArrayBuffer 时间消耗验证 (opens new window),结论:传输 ArrayBuffer 成本几乎可以忽略。

所以应优先采用该方法

# 方法 2(共享内存)

共享内存( SharedArrayBuffer )节省了线程间通信的消耗,但增加了代码复杂性,只能共享二进制数据,且 SharedArrayBuffer 、Atomics 有一定的兼容性问题。
(目前只看到 WASM 相关的场景用到了 SharedArrayBuffer )

# 其他

JS 中可在其他线程/进程中执行代码的其他方法。

# Cluster

Cluster文档 (opens new window)

工作进程使用 child_process.fork() 方法衍生,因此它们可以通过 IPC 与父进程通信并且来回传递服务器句柄。
尽管 node:cluster 模块的主要使用场景是网络,但它也可用于需要工作进程的其他使用场景。

多进程,一般用于在 Node.js 上运行 WEB 服务器。
Cluster共享端口有点骚 (opens new window)

# worker-threads

worker-threads文档 (opens new window)
Node.js 上的 Worker 实现。

worker-threads对于执行 CPU 密集型的 JavaScript 操作很有用。 它们对 I/O 密集型的工作帮助不大。 Node.js 内置的异步 I/O 操作比工作线程更高效。
与 child_process 或 cluster 不同,worker_threads 可通过传输 ArrayBuffer 实例或共享 SharedArrayBuffer 实例来实现共享内存。

# Worklet

Worklet (opens new window)用于特定场景,非通用多线程能力

Worklet接口是Web Workers的轻量级版本,使开发人员能够访问渲染管道的低级部分。
通过Worklet,你可以运行JavaScript和WebAssembly代码来进行图形渲染或需要高性能的音频处理。

  • PaintWorklet 自定义 css 绘制行为
  • AudioWorklet 用于自定义AudioNodes的音频处理
💗 博主正处于裸辞待业状态,欢迎 商务合作 💗

相关文章

从 React 看前端 UI 代码范式革命

alt text 前言 本来打算写的主题是“我为什么讨厌 React Hooks API”,展开聊聊“小甜甜”是如何变成“牛夫人”的,没想到越写越严肃:) React 是两次前端范式革命的引领者,至今仍有繁荣的社区和旺盛的创造力; React 多次天才又激进的创新,一些想法被借鉴改良、一些引发广泛质疑,大部分是被认同和接受的; ...

WebCodecs 性能表现及优化思路

笔者开源 WebAV 已经一年半,还写了系列文章帮助初学者入门 Web 音视频。 之前一直隐隐担心在 Web 平台处理音视频与 Native APP 会有明显性能差距,因为 WebCodecs API 毕竟被浏览器代理了一层,且一些数据处理需要 js 配合,不确定有多大的性能损耗。 相信刚接触 WebCodecs 的读者也非常关心它的性能表现如何。 ...

Web 网页自集成 HTTP 代理方案

前言 大部分程序员,想必都有会一个常用的抓包代理工具; 但在座的各位,可曾见过这样一款集成在 Web 应用中的代理工具? 它是明显区别于传统代理工具的,有以下特性: 零安装,零配置,Web 点击即用、APP 扫码即用;_(不 ...

Web 文件系统(OPFS 及工具)介绍

文件系统是往往是构建大型软件的基石之一,很长一段时间 Web 平台因缺失成熟的文件系统成为构建大型软件的阻碍,如今 OPFS 可弥补这一缺憾。 本文介绍 OPFS 背景和基本使用方法、使用过程中的注意事项,及如何配合笔者开源的 opfs-tools、opfs-tools-explorer 两个项目,充分发挥 OPFS 的性能与开发效率。 Web 存储 A ...

opfs-tools (文件系统 API) 项目介绍

文件系统是许多领域程序的基石,所有通用编程语言都会内置完备的文件系统 API。 Web 很长一段时间没有提供完善的访问文件系统的规范,使得需要高频读写文件、大文件处理软件在 Web 端都会受到一些限制,比如音视频剪辑、游戏、数据库等等。 之前在浏览器中实现视频裁剪、截帧等相关功能时,发现缺少基本的操作文件的 API,比如读写、移动、复制文件。 而 [OPFS] ...

Web 终极拦截技巧(全是骚操作)

拦截的价值 > 计算机科学领域的任何问题都可以通过增加一个中间层来解决。 —— Butler Lampson 如果系统的控制权、代码完全被掌控,很容易添加中间层; 现实情况我们往往无法控制系统的所有环节,所以需要使用一些 “非常规”(拦截) 手段来增加中间层。 拦截的方法 拦截/覆写 浏览器 API 最常见的场景有通过拦截 console ...

Web 音视频(六)图像素材处理

Web 音视频目录 前序章节介绍了如何在浏览器中解析、创建视频,以及给视频添加一些自定义素材(图片、音频、文字...); 本章介绍如何给图像素材加特效、加动画,实现转场、移动水印、图像滤镜美化等功能。 你可以跳过原理介绍,直接查看 WebAV 示例 素材动画 在视频制作中实现动画跟其他场景略有不同,因为视频 ...