JS 多线程并发

434 阅读3分钟

建议阅读原文,体验更好

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




function execTask() {
    const t = performance.now()
    
    while(performance.now() - t < 3000){}
}
execTask()

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

并发面临的问题


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

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

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

拓展阅读并发问题

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


JS 采用 Web Worker API 来实现多线程并发。

任务说明:将 11000 每个数字求平方(每次随机卡住 0100ms 模拟耗时任务)

# 分配任务,多 Worker 执行

function workerSetup() {
  self.onmessage = (evt) => {
    const t = performance.now()
    
    while(performance.now() - t < Math.random() * 100){}

    const { idx, val } = evt.data
    
    self.postMessage({
      idx: idx,
      val: val * val
    })
  }
}

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

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 是 JS 提供的唯一可在不同线程间共享内存的方式。

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

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


function workerSetup() {
  function execTask(val) {
    const t = performance.now()
    
    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){
      
      
      
      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)
}


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 
}

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 对象提供了一组静态方法对 SharedArrayBuffer 对象进行原子操作。

两个方法对比


# 方法 1(分配任务)

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

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 ,postMessage(arrayBuffer, [arrayBuffer]) 可以转移这些对象的所有权,无需clone,原线程将无法读写被转移所有权的对象。

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

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

所以应优先采用该方法

# 方法 2(共享内存)

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

其他


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

# Cluster

Cluster文档

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

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

# worker-threads

worker-threads文档
Node.js 上的 Worker 实现。

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

# Worklet

Worklet 用于特定场景,非通用多线程能力

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

  • PaintWorklet 自定义 css 绘制行为
  • AudioWorklet 用于自定义AudioNodes的音频处理

本文使用 文章同步助手 同步