JavaScript 并行 - Web Worker 讲解

2025-05-24

JavaScript 并行 - Web Worker 讲解

这是我的个人博客的转帖

TL;DR

  • JavaScript 是单线程的,长时间运行的脚本会导致页面无响应
  • Web Workers 允许在单独的线程中运行 JavaScript,并使用消息与主线程进行通信。
  • 在 TypedArrays 或 ArrayBuffers 中传输大量数据的消息会由于数据被克隆而导致大​​量内存成本
  • 使用传输可以减轻克隆的内存成本,但会使发送者无法访问数据
  • 所有代码都可以在这个存储库中找到
  • 根据我们的 JavaScript 执行的工作类型,navigator.hardwareConcurrency可能有助于我们将工作分散到各个处理器。

示例应用程序

举个例子,我们要构建一个 Web 应用程序,构建一个表,其中每个条目表示属于它的数字是否为质数

我们将使用ArrayBuffer来保存布尔值,并将其设置为 10 MB 大小。

现在这只是让我们的脚本做一些繁重的工作 - 它不是一个非常有用的东西,但我可能会在以后的帖子中使用这里描述的技术来处理不同类型的二进制数据(例如图像,音频,视频)。

这里我们将使用一个非常简单的算法(有很多更好的算法可用):



function isPrime(candidate) {
  for(var n=2; n <= Math.floor(Math.sqrt(candidate)); n++) {
    // if the candidate can be divided by n without remainder it is not prime
    if(candidate % n === 0) return false
  }
  // candidate is not divisible by any potential prime factor so it is prime
  return true
}


Enter fullscreen mode Exit fullscreen mode

以下是我们的申请的其余部分:

索引.html



<!doctype html>
<html>
<head>
  <style>
    /* make the page scrollable */
    body {
      height: 300%;
      height: 300vh;
    }
  </style>
<body>
  <button>Run test</button>
  <script src="app.js"></script>
</body>
</html>


Enter fullscreen mode Exit fullscreen mode

我们使页面可滚动,以便稍后查看 JavaScript 代码的效果。

应用程序.js



document.querySelector('button').addEventListener('click', runTest)

function runTest() {
  var buffer = new ArrayBuffer(1024 * 1024 * 10) // reserves 10 MB
  var view = new Uint8Array(buffer) // view the buffer as bytes
  var numPrimes = 0

  performance.mark('testStart')
  for(var i=0; i<view.length;i++) {
    var primeCandidate = i+2 // 2 is the smalles prime number
    var result = isPrime(primeCandidate)
    if(result) numPrimes++
    view[i] = result
  }
  performance.mark('testEnd')
  performance.measure('runTest', 'testStart', 'testEnd')
  var timeTaken = performance.getEntriesByName('runTest')[0].duration

  alert(`Done. Found ${numPrimes} primes in ${timeTaken} ms`)
  console.log(numPrimes, view)
}

function isPrime(candidate) {
  for(var n=2; n <= Math.floor(Math.sqrt(candidate)); n++) {
    if(candidate % n === 0) return false
  }
  return true
}


Enter fullscreen mode Exit fullscreen mode

我们正在使用用户计时 API来测量时间并将我们自己的信息添加到时间线中。

现在我在我的老款 Nexus 7(2013)上进行了测试:

好吧,这没什么特别的,不是吗?
更糟糕的是,网站在这39秒内完全停止响应——无法滚动、无法点击、无法输入。页面完全冻结了。

发生这种情况是因为 JavaScript 是单线程的,在单个线程中同一时间只能发生一件事。更糟糕的是,几乎所有与页面交互相关的操作(例如浏览器滚动、文本输入等代码)都在同一个线程中运行。

那么,我们就不能做任何繁重的工作吗?

Web Workers 来帮忙

不,这只是我们可以使用Web Workers完成的工作

Web Worker 是一个与我们的 Web 应用程序来自同一来源的 JavaScript 文件,它将在单独的线程中运行。

在单独的线程中运行意味着:

  • 它将并行运行
  • 它不会通过阻塞主线程导致页面无响应
  • 它将无法访问 DOM 或主线程中的任何变量或函数
  • 它可以使用网络并通过消息与主线程进行通信

那么,在进行主要搜索工作的同时,我们该如何保持页面的响应呢?具体步骤如下:

  • 我们启动一个 worker 并将 ArrayBuffer 发送给它
  • 工人完成工作
  • 当工作线程完成后,它会将 ArrayBuffer 和找到的素数发送回主线程

以下是更新后的代码:

应用程序.js



document.querySelector('button').addEventListener('click', runTest)

function runTest() {
  var buffer = new ArrayBuffer(1024 * 1024 * 10) // reserves 10 MB
  var view = new Uint8Array(buffer) // view the buffer as bytes

  performance.mark('testStart')
  var worker = new Worker('prime-worker.js')
  worker.onmessage = function(msg) {
    performance.mark('testEnd')
    performance.measure('runTest', 'testStart', 'testEnd')
    var timeTaken = performance.getEntriesByName('runTest')[0].duration
    view.set(new Uint8Array(buffer), 0)
    alert(`Done. Found ${msg.data.numPrimes} primes in ${timeTaken} ms`)
    console.log(msg.data.numPrimes, view)
  }
  worker.postMessage(buffer)
}


Enter fullscreen mode Exit fullscreen mode

prime-worker.js



self.onmessage = function(msg) {
  var view = new Uint8Array(msg.data),
      numPrimes = 0
  for(var i=0; i<view.length;i++) {
    var primeCandidate = i+2 // 2 is the smalles prime number
    var result = isPrime(primeCandidate)
    if(result) numPrimes++
    view[i] = result
  }
  self.postMessage({
    buffer: view.buffer,
    numPrimes: numPrimes
  })
}

function isPrime(candidate) {
  for(var n=2; n <= Math.floor(Math.sqrt(candidate)); n++) {
    if(candidate % n === 0) return false
  }
  return true
}


Enter fullscreen mode Exit fullscreen mode

这是我在 Nexus 7 上再次运行时得到的结果:

嗯,嗯,所有这些仪式有什么好处吗?毕竟现在速度更慢了

这里最大的优势不是让它变得更快,而是试着滚动页面或进行其他交互……它始终保持响应!由于计算被转移到它自己的线程,我们不会妨碍主线程响应用户。

但在我们继续加快速度之前,我们应该弄清楚postMessage工作原理的一个重要细节。

克隆的代价

如前所述,主线程和工作线程是分开的,因此我们需要使用消息在它们之间传递数据

但是它们之间究竟是如何移动数据的呢?我们之前的方法就是结构化克隆

这意味着我们将10 MB 的 ArrayBuffer复制到 worker,然后将 ArrayBuffer 从 worker 复制回来。

我假设这将总共占用 30 MB 内存:10 MB 在我们的原始 ArrayBuffer 中,10 MB 的副本在发送给工作进程的副本中,另外 10 MB 的副本在发送回的副本中。

这是开始测试之前的内存使用情况:

测试结束后如下:

等等,这又多了50MB。结果是:

  • 我们从 10mb 的 ArrayBuffer 开始
  • 克隆本身* 又创建了 +10mb
  • 克隆被复制到worker,+10mb
  • 工作者再次克隆其副本,+10mb
  • 克隆的副本复制到主线程,+10mb

*) 我不太清楚为什么克隆没有移动到目标线程而是被复制,但序列化本身似乎产生了意外的内存成本

可转让物拯救了一切

幸运的是,在可选的第二个参数中,有一种在线程之间传输数据的不同方式,postMessage称为传输列表

第二个参数可以保存可转移对象的列表,这些对象将被排除在克隆之外,而是被移动或转移

然而,传输对象会在源线程中使其无效,例如,我们的 ArrayBuffer 在传输到 Worker 后,主线程中将不会包含任何数据,其byteLength值为零。
这样做是为了避免在多线程访问共享数据时,需要额外实现一些机制来处理一系列问题。

以下是使用转移调整后的代码:

应用程序.js



worker.postMessage(buffer, [buffer])


Enter fullscreen mode Exit fullscreen mode

prime-worker.js



  self.postMessage({
    buffer: view.buffer,
    numPrimes: numPrimes
  }, [view.buffer])


Enter fullscreen mode Exit fullscreen mode

以下是我们的数据:

所以,我们比克隆工作器的速度稍微快了一点,接近原来的主线程阻塞版本。内存方面表现如何?

因此,从 40mb 开始,到最后得到略大于 50mb 听起来是正确的。

工人越多=速度越快?

到目前为止我们已经

  • 解除主线程阻塞
  • 消除了克隆带来的内存开销

我们能加快速度吗?

我们可以将数字范围(和缓冲区)划分给多个工作者,并行运行它们并合并结果:

应用程序.js

我们不会启动单个 Worker,而是会启动四个。每个 Worker 都会收到一条消息,指示其起始偏移量以及需要检查的数字数量。

当一个 worker 完成时,它会报告

  • 一个 ArrayBuffer,包含哪些条目是素数的信息
  • 它找到的素数数量
  • 其原始偏移量
  • 其原始长度

然后我们将数据从缓冲区复制到目标缓冲区,计算找到的素数总数。

一旦所有工人完成,我们就会显示最终结果。



document.querySelector('button').addEventListener('click', runTest)

function runTest() {
  const TOTAL_NUMBERS = 1024 * 1024 * 10
  const NUM_WORKERS = 4
  var numbersToCheck = TOTAL_NUMBERS, primesFound = 0
  var buffer = new ArrayBuffer(numbersToCheck) // reserves 10 MB
  var view = new Uint8Array(buffer) // view the buffer as bytes

  performance.mark('testStart')
  var offset = 0
  while(numbersToCheck) {
    var blockLen = Math.min(numbersToCheck, TOTAL_NUMBERS / NUM_WORKERS)
    var worker = new Worker('prime-worker.js')
    worker.onmessage = function(msg) {
      view.set(new Uint8Array(msg.data.buffer), msg.data.offset)
      primesFound += msg.data.numPrimes

      if(msg.data.offset + msg.data.length === buffer.byteLength) {
        performance.mark('testEnd')
        performance.measure('runTest', 'testStart', 'testEnd')
        var timeTaken = performance.getEntriesByName('runTest')[0].duration
        alert(`Done. Found ${primesFound} primes in ${timeTaken} ms`)
        console.log(primesFound, view)
      }
    }

    worker.postMessage({
      offset: offset,
      length: blockLen
    })

    numbersToCheck -= blockLen
    offset += blockLen
  }
}


Enter fullscreen mode Exit fullscreen mode

prime-worker.js

工作者创建一个足够大的 Uint8Array 视图来保存length主线程排序的字节。

主要检查从所需的偏移量开始,最后将数据传回。



self.onmessage = function(msg) {
  var view = new Uint8Array(msg.data.length),
      numPrimes = 0
  for(var i=0; i<msg.data.length;i++) {
    var primeCandidate = i+msg.data.offset+2 // 2 is the smalles prime number
    var result = isPrime(primeCandidate)
    if(result) numPrimes++
    view[i] = result
  }
  self.postMessage({
    buffer: view.buffer,
    numPrimes: numPrimes,
    offset: msg.data.offset,
    length: msg.data.length
  }, [view.buffer])
}

function isPrime(candidate) {
  for(var n=2; n <= Math.floor(Math.sqrt(candidate)); n++) {
    if(candidate % n === 0) return false
  }
  return true
}


Enter fullscreen mode Exit fullscreen mode

结果如下:

因此,该解决方案花费了大约一半的时间,但内存成本相当高(40mb 基本内存使用量 + 10mb 目标缓冲区 + 4 x 2.5mb 每个工作者的缓冲区 + 每个工作者 2mb 的开销)。

以下是使用 4 名工人的应用程序的时间表:

我们可以看到,各个工作器并行运行,但速度并没有提升 4 倍,因为有些工作器比其他工作器耗时更长。这是我们对数值范围进行划分的结果:由于每个工作器都需要将每个数值x除以 2 到 之间的所有数值√x,因此数值较大的工作器需要进行更多次除法运算,也就是做更多工作。当然,可以通过更均匀地划分数值,使操作在各个工作器之间分配,来最大限度地减少这一负担。我将把这个练习留给热心的读者 ;-)

另一个问题是:我们可以投入更多的工人吗?

以下是 8 名工人的结果:

好吧,速度变慢了!时间线显示了发生这种情况的原因:

我们看到,除了轻微的重叠之外,同时活跃的工人不会超过 4 人。

这将取决于系统和工人的特点,并不是一个固定的数字。

一个系统同时能做的工作是有限的,而且工作通常要么受 I/O 限制(即受网络或文件吞吐量限制),要么受 CPU 限制(即受在 CPU 上运行计算的限制)。

在我们的例子中,每个 Worker 都会占用 CPU 进行计算。我的 Nexus 7 有四个核心,所以它可以同时处理四个完全 CPU 密集型的 Worker。

通常,您最终会遇到 CPU 和 I/O 密集型工作负载,或者难以拆分成多个较小工作负载的问题,因此有时很难判断工作线程的数量。如果您想了解有多少个逻辑 CPU 可用,可以使用navigator.hardwareConcurrency

包起来

这需要学习的内容相当多,所以让我们回顾一下!

我们发现JavaScript 是单线程的,并且与浏览器任务在同一个线程上运行,以保持我们的 UI 新鲜而敏捷。

然后我们使用Web Workers将工作卸载到单独的线程并使用 `postMessage* 在线程之间进行通信。

我们注意到线程不能无限扩展,因此建议考虑我们运行的线程数量。

在这样做时,我们发现数据默认被克隆,这很容易导致比看起来更多的内存损失。

我们通过传输数据解决了这个问题,这对于某些类型的数据(称为可传输数据)来说是一个可行的选择。

文章来源:https://dev.to/g33konaut/javascript-in-parallel-web-workers-explained-5588
PREV
了解 CORS
NEXT
软件工程师和自由职业者的 10 条经验教训