JavaScript 新并行原语一览

作者注: 自本文发表以来,postMessage 的 API 已略有变化。 使用 postMessage 发送 SharedArrayBuffer 时,该缓冲区不再应出现在 postMessage 调用的传输列表参数中。 因此,如果 sab 是一个 SharedArrayBuffer 对象,w 是一个工作线程,则 w.postMessage(sab) 会将该缓冲区发送到工作线程。

您可以在 MDN 的 SharedArrayBuffer 文档 中找到更多详细信息。

简而言之 - 我们正在使用一个原语 API 扩展 JavaScript,它允许程序员使用 **多个工作线程** 和 **共享内存** 来实现 **JavaScript 中真正的并行算法**。

多核计算

JavaScript (JS) 已经成长起来,并且运行良好,以至于几乎所有现代网页都包含大量的 JS 代码,我们从不用担心 - 它只是自动运行。 JS 也被用于更具挑战性的任务:客户端图像处理(在 Facebook 和 Lightroom 中)是用 JS 编写的;像 Google Docs 这样的浏览器内办公套件是用 JS 编写的;以及 Firefox 的一些组件,例如内置的 PDF 查看器 pdf.js 和语言分类器,是用 JS 编写的。 事实上,这些应用程序中的一些采用的是 asm.js 的形式,asm.js 是一个简单的 JS 子集,是 C++ 编译器的常用目标语言;最初用 C++ 编写的游戏引擎正在被重新编译成 JS,以便作为 asm.js 程序在 Web 上运行。

JS 在这些任务以及许多其他任务中的常规使用,得益于 JS 引擎中使用即时 (JIT) 编译器带来的显著性能提升,以及不断更快的 CPU。

但是,JS JIT 的改进速度现在正在放缓,CPU 性能的提升也基本停滞不前。 除了更快的 CPU,所有消费类设备 - 从台式机系统到智能手机 - 现在都配备了多个 CPU(实际上是 CPU 内核),而且除了低端设备,它们通常拥有超过两个内核。 如果一位程序员想要提高其程序的性能,就必须开始并行使用多个内核。 对于使用多线程编程语言(Java、Swift、C# 和 C++)编写的“原生”应用程序来说,这不是问题,但对于 JS 来说,这是一个问题,因为 JS 在多个 CPU 上运行的能力非常有限(Web 工作线程、缓慢的消息传递以及很少的避免数据复制的方法)。

因此,JS 面临着一个问题:如果我们希望 Web 上的 JS 应用程序继续成为每个平台上原生应用程序的可行替代方案,我们必须赋予 JS 在多个 CPU 上良好运行的能力。

构建模块:共享内存、原子操作和 Web 工作线程

在过去的一年左右的时间里,Mozilla 的 JS 团队一直在领导一项标准化倡议,为 JS 添加多核计算的构建模块。 其他浏览器供应商一直在与我们合作进行这项工作,我们的 提案 正在经历 JS 标准化流程 的各个阶段。 我们在 Mozilla JS 引擎中的原型实现有助于设计,并在 Firefox 的某些版本中可用,如下所述。

本着 可扩展 Web 的精神,我们选择通过公开尽可能少限制程序的低级构建模块来促进多核计算。 这些构建模块是一个新的共享内存类型、对共享内存对象的原子操作,以及一种将共享内存对象分发到标准 Web 工作线程的方式。 这些想法并不新鲜;有关高级背景和一些历史,请参阅 Dave Herman 的博客文章

新的共享内存类型称为 SharedArrayBuffer,它与现有的 ArrayBuffer 类型非常相似;主要区别在于,由 SharedArrayBuffer 表示的内存可以同时被多个代理引用。(代理可以是网页的主程序或其 Web 工作线程之一。)共享是通过使用 postMessageSharedArrayBuffer 从一个代理传输到另一个代理来创建的。

let sab = new SharedArrayBuffer(1024)
let w = new Worker("...")
w.postMessage(sab, [sab])   // Transfer the buffer

工作线程在消息中接收 SharedArrayBuffer

let mem;
onmessage = function (ev) { mem = ev.data; }

这将导致以下情况:主程序和工作线程都引用同一内存,而该内存不属于它们中的任何一个

shmem1

共享 SharedArrayBuffer 后,每个共享该缓冲区的代理都可以通过在缓冲区上创建 TypedArray 视图并在视图上使用标准数组访问操作来读取和写入其内存。 假设工作线程执行以下操作

let ia = new Int32Array(mem);
ia[0] = 37;

然后,主程序可以读取工作线程写入的单元格,如果它等到工作线程写入完成之后,它将看到值“37”。

对于主程序而言,“等到工作线程写入数据之后”实际上很棘手。 如果多个代理在不协调访问的情况下读取和写入同一位置,那么结果将是垃圾数据。 新的原子操作保证程序操作按可预测的顺序且不中断地执行,从而使这种协调成为可能。 原子操作作为新顶级 Atomics 对象上的静态方法存在。

速度和响应能力

我们可以通过 Web 上的多核计算来解决的两个性能方面是速度,即每单位时间可以完成多少工作,以及响应能力,即用户在浏览器进行计算时与浏览器交互的程度。

我们通过将工作分发到多个工作线程来提高速度,这些工作线程可以并行运行:如果我们可以将计算分成四部分,并在四个工作线程上运行,每个工作线程都获得一个专用的内核,那么我们有时可以将计算速度提高四倍。 我们通过将工作从主程序移到工作线程来提高响应能力,这样一来,即使计算正在进行,主程序也能对 UI 事件做出响应。

共享内存被证明是一个重要的构建模块,原因有两个。 首先,它消除了数据复制的成本。 例如,如果我们在许多工作线程上渲染场景,但必须从主程序显示场景,那么渲染的场景必须复制到主程序,这会增加渲染时间并降低主程序的响应能力。 其次,共享内存使代理之间的协调变得非常便宜,比 postMessage 便宜得多,这会减少代理在等待通信时处于空闲状态的时间。

没有免费的午餐

利用多个 CPU 内核并不总是容易。 为单个内核编写的程序通常需要进行大幅度的重构,而且通常很难确定重构后的程序的正确性。 如果工作线程需要频繁地协调其操作,则从多个内核获得加速也可能很困难。 并非所有程序都会从并行处理中获益。

此外,并行程序中存在全新的错误类型。 如果两个工作线程最终错误地相互等待,那么程序将不再继续执行:程序死锁。 如果工作线程在不协调访问的情况下读取和写入同一内存单元格,则结果有时(以及不可预测地、静默地)是垃圾数据:程序存在数据竞争。 存在数据竞争的程序几乎总是错误的且不可靠的。

示例

注意:要运行本文中的演示,您需要 Firefox 46 或更高版本。 您还必须在 about:config 中将首选项 javascript.options.shared_memory 设置为 true,除非您正在运行 Firefox Nightly

让我们看一下如何将程序并行化到多个内核,以获得良好的加速。 我们将研究一个简单的 Mandelbrot 集动画,该动画将像素值计算到网格中,并在画布中显示该网格,以不断增加的缩放级别。(Mandelbrot 计算被称为“令人尴尬的并行”:获得加速非常容易。 事物通常并不像这样简单。)我们在这里不会进行技术深 dive;请参阅结尾处的指针,了解更深入的内容。

共享内存功能在 Firefox 中默认不启用,原因是它仍在 JS 标准化机构中进行考虑。 标准化过程必须按其流程进行,并且该功能可能在过程中发生变化;我们不希望 Web 上的代码依赖于该 API。

串行 Mandelbrot

让我们首先简要看一下没有并行性的 Mandelbrot 程序:计算是文档的主程序的一部分,并直接渲染到画布中。(当您运行下面的演示时,您可以提前停止它,但后面的帧渲染速度较慢,因此只有在您让它运行到结束时,您才能获得可靠的帧速率。)

如果您好奇,以下是源代码

并行 Mandelbrot

Mandelbrot 程序的并行版本将使用多个工作线程并行地将像素计算到共享内存网格中。 从原始程序进行的改编在概念上很简单:mandelbrot 函数被移到 Web 工作线程程序中,并且我们运行多个 Web 工作线程,每个工作线程都计算输出的水平条带。 主程序仍然负责在画布中显示网格。

我们可以将此程序的帧速率(每秒帧数,FPS)绘制在使用的核心数量上,得到下面的图。 用于测量的计算机是 2013 年末的 MacBook Pro,具有四个超线程核心; 我使用 Firefox 46.0 进行了测试。

mandel3

当我们从一个核心到四个核心时,程序速度几乎呈线性增长,从 6.9 FPS 增长到 25.4 FPS。 之后,随着程序开始在已在使用的核心上的超线程而不是新核心上运行,增量变得更加适度。(同一核心上的超线程共享核心上的部分资源,并且这些资源会存在一定程度的争用。) 但即使如此,程序也为我们添加的每个超线程加速了 3 到 4 FPS,并且在使用 8 个工作线程时,程序计算了 39.3 FPS,比在单个核心上运行的速度提高了 5.7 倍。

这种加速显然非常好。 然而,并行版本比串行版本复杂得多。 复杂性来自多个来源。

  • 为了使并行版本正常工作,它需要_同步_工作线程和主程序:主程序必须告诉工作线程何时(以及做什么)计算,而工作线程必须告诉主程序何时显示结果。 数据可以使用 `postMessage` 双向传递,但通常(即更快)通过共享内存传递数据,而正确有效地做到这一点相当复杂。
  • 良好的性能需要一种策略来将计算分配到工作线程之间,以通过_负载均衡_最大限度地利用工作线程。 在示例程序中,输出图像因此被划分为比工作线程多得多的条带。
  • 最后,共享内存是一个整数值的扁平数组,由此产生了一些混乱; 共享内存中更复杂的数据结构必须手动管理。

考虑同步:新的 `Atomics` 对象有两个方法,`wait` 和 `wake`,它们可以用来在一个工作线程之间发送信号:一个工作线程通过调用 `Atomics.wait` 等待信号,另一个工作线程使用 `Atomics.wake` 发送该信号。 但是,这些是灵活的低级构建块; 要实现同步,程序还必须使用_原子操作_,如 `Atomics.load`、`Atomics.store` 和 `Atomics.compareExchange` 来读取和写入共享内存中的状态值。

更进一步地,网页的主线程不允许调用 `Atomics.wait`,因为主线程_阻塞_不好。 因此,虽然工作线程可以使用 `Atomics.wait` 和 `Atomics.wake` 互相通信,但主线程必须在等待时监听事件,而想要_唤醒_主线程的工作线程必须使用 `postMessage` 发布该事件。

(那些急于测试的人应该知道,在 Firefox 46 和 Firefox 47 中,`wait` 和 `wake` 被称为 `futexWait` 和 `futexWake`。 有关更多信息,请参阅 Atomics 的 MDN 页面。)

可以构建良好的库来隐藏大部分复杂性,如果一个程序(或者通常是程序的一个重要部分)在多个核心上运行时可以显著优于在一个核心上运行,那么复杂性真的值得。 但是,对程序进行并行化不是解决性能不佳的速效药。

在上述免责声明的基础上,以下是并行版本的代码

更多信息

有关可用 API 的参考材料,请阅读 拟议规范,该规范现在基本稳定。 该提案的 Github 存储库 也有一些可能对您有帮助的讨论文档。

此外,Mozilla 开发者网络 (MDN) 有关 SharedArrayBufferAtomics 的文档。

关于 Lars T Hansen

我是一名 Mozilla 的 JavaScript 编译器工程师。 之前我在 Adobe 负责 ActionScript3,在 Opera 负责 JavaScript 和其他浏览器相关工作。

更多 Lars T Hansen 的文章…


26 条评论

  1. Grant Stevens

    优秀的文章……即使如此,该程序的速度也几乎呈线性增长,我们简单地看一下 Mandelbrot 代码在那些程序标准机构的核心上运行的速度。我不确定是否可以解决这个问题。

    这也是我第一次接触 Atomics。感谢您的文章!

    2016 年 5 月 5 日 下午 1:16

  2. Sendil

    嗨 Hansen,

    我无法在我的 Chrome 和 Firefox 浏览器中启用 javascript.options.shared_memory.. 我应该怎么做才能启用它?请在您的文章中添加它,这对其他观众也会有所帮助。

    2016 年 5 月 5 日 下午 9:09

    1. Lars T Hansen

      在 Firefox 46 或更高版本(不是 Chrome)中,您可以创建一个新标签页,并在该标签页的地址栏中输入“about:config”。之后,您可能需要点击一个警告页面。在您随后进入的所有选项的页面上,在搜索字段中输入“shared_memory”,它将找到该选项。双击该选项将它的值从 false 更改为 true。

      2016 年 5 月 5 日 下午 11:06

  3. Jonathan Raoult

    非常令人兴奋的事情。迫不及待地想要尝试 :)

    关于“速度和响应能力”的一点小事,可能有点不完整:今天我们已经有了通过 postMessage 以“无复制”方式(基于所有权)传输二进制数据的途径,这要归功于 Transferable。

    2016 年 5 月 5 日 下午 9:42

    1. Lars T Hansen

      是的,在某些情况下,transfering 可以避免复制。但是,该程序通常需要进行彻底的重构才能使其工作,而使用共享内存,该程序更经常可以保留(更多)其原始形式。更重要的是,共享内存可以实现许多内存访问模式,而 transfering 则不能,通常在多个工作线程使用相同输入数据来生成输出的各个切片的情况下。使用 transfering,无法进行共享。另一种情况是按行处理数据,然后按列处理数据。我们可以使用 transfering 按行拆分网格并将行传输到工作线程,但要按列拆分网格并随后传输列,我们需要复制网格。

      2016 年 5 月 5 日 下午 11:11

  4. Yossi Kreinin

    我对两件事感到好奇

    1. 如何防止从多个线程意外修改随机 JS 对象,也就是说,如何将通信限制在显式共享内存中?它是通过在下面使用 OS 进程来完成的吗?

    2. 公开原子操作会大大降低自动竞态检测工具的有效性。不公开类似于 Cilk 的接口是否存在特定的理由,例如,并行 for 循环和可以等待的并行函数调用?Mandelbrot 示例看起来可以用一个带 OpenMP 所谓动态调度策略的并行 for 循环来很好地处理(也就是说,效率相同,代码更少)。

    确实存在可以使用原始原子操作比使用 Cilk 式接口更有效地处理的任务,但根据我的经验,它们是例外而不是规则;另一方面,并行错误是规则而不是例外,因此有效的自动调试工具是天赐之物。

    Cilk 带有强大的竞态检测工具,这些工具可以为具有类似接口的任何系统开发;使之成为可能的是,Cilk 程序的任务依赖图是一个 fork-join 图,而使用原子操作,它是一个通用的 DAG,自动调试工具需要尝试的 DAG 的任务排序数量可能非常大,而对于 fork-join 图,始终只有两个排序。我在此处写过它 http://yosefk.com/blog/checkedthreads-bug-free-shared-memory-parallelism.html - 不过我的观点并不是要宣传我在那篇文章中提出的自己的 Cilk 模仿,而是要详细说明 Cilk 式接口相对于原始原子操作的优势。

    (这是我对 HN 评论的重复 https://news.ycombinator.com/item?id=11642394

    2016 年 5 月 6 日 上午 1:03

    1. Lars T Hansen

      防止意外修改“随机”JS 对象的保护措施是,您只能共享 SharedArrayBuffer 的内存。一个代理中的任何其他 JS 对象都不能被另一个代理修改。当然,对 SharedArrayBuffer 内存的修改会进行范围检查,因此您不能在该内存之外乱写。

      确实存在公开这种非常低级 API 的特定理由。我们将其视为 JS 构建更高级别抽象的基底(例如,我构建的第一件事之一就是一个数据并行框架)和 C/C++ 的编译目标(作为 asm.js 的一部分)的结合。当前的 API 在一定程度上是这两个用例需求之间的折衷方案。

      但是,即使我们不担心编译目标用例,也不清楚哪种形式的更高级别抽象是合适的。实际上,我们在 Parallel JavaScript(PJS,参见 https://bugzil.la/801869http://smallcultfollowing.com/babysteps/blog/2012/01/09/parallel-javascript/)的形式中对这种抽象进行了原型设计,但是这项工作失败了,原因大多为人所知(在这个主题中进行了广泛的讨论:https://groups.google.com/forum/#!topic/mozilla.dev.tech.js-engine/H-YEsejE6DA)。

      Mandelbrot 示例只是一个示例,并不真正代表我们想要编写的程序类型。更有趣的用例需要更多控制,请查看 http://halide-lang.org 中链接的出版物。(我知道 Cilk 非常适合其中一些用例,但 Cilk API 不适用于 asm.js,asm.js 仍然是一个必要的用例。)

      2016 年 5 月 7 日 上午 3:50

  5. Prem

    但是我的笔记本电脑的 CPU 使用率很高,温度达到 81 摄氏度(这是正常的吗)?

    2016 年 5 月 6 日 上午 10:17

    1. Lars T Hansen

      当我在所有可用核心上运行此示例时,我的 CPU 也会变得更热;这当然是可以预料的……如果没有更多关于您遇到的问题的了解,我就不便多说。

      2016 年 5 月 7 日 上午 3:52

      1. Prem

        热量是唯一的问题。示例运行正常。

        2016 年 5 月 7 日 上午 6:36

  6. James G.

    Lars,文章写得非常好!

    我本来也要指出 Prem 所说的一样东西。毫无疑问,热量是一个问题。

    2016 年 5 月 12 日 下午 09:19

    1. Lars T Hansen

      很明显,当你在所有核心上进行 100% 的计算时,芯片会消耗大量电力并产生热量,但它为此而设计。除此之外还有什么问题吗?

      2016 年 5 月 12 日 下午 09:39

      1. Daniel Earwicker

        看到有人抱怨 CPU 变热很奇怪。

        描述此功能的一种方式是它旨在让您最大限度地提高温度!这就是计算所做的。

        2016 年 5 月 12 日 下午 16:42

  7. Slava

    是的!浏览器中的死锁和信号量!

    2016 年 5 月 12 日 下午 12:32

    1. Lars T Hansen

      这是一个尖锐的工具,并非没有问题。一些事情可以使问题不那么严重

      – 无法在主线程上阻塞确保浏览器不会锁定。
      – 工作人员仍然可以锁定,因此开发人员工具需要跟上并允许诊断死锁和其他问题。(性能问题也是如此。)
      – 再一次,使用框架来实现常见的并行模式将减少自砍的可能性。我自己已经构建了一些简单的框架,但这里是我希望看到 JS 社区做一些好工作的领域。

      2016 年 5 月 13 日 上午 00:03

  8. Jace A Mogill

    对于那些对这种东西感兴趣并想现在进行实验的人,扩展内存语义 (https://github.com/SyntheticSemantics/emshttps://npmjs.net.cn/package/ems) 为 Node.js 添加了共享内存并行性,不仅适用于整数还适用于 JSON 对象,支持持久性、事务和其他共享内存并行编程功能。

    [免责声明:我是 EMS 的作者]

    -J

    2016 年 5 月 12 日 下午 20:39

    1. Lars T Hansen

      有趣的工作!我将更详细地研究一下…

      2016 年 5 月 13 日 上午 00:08

  9. mahem

    迫不及待地想要在浏览器中使用它。

    对于优化和通过共享内存将扩展任务移至工作者非常有用。

    2016 年 5 月 12 日 下午 22:19

  10. mahem

    很棒的文章。期待在浏览器中使用它。

    2016 年 5 月 12 日 下午 22:21

  11. Nidin Vinayakan

    对于所有共享内存支持者,这是我使用 SharedArrayBuffer 实现的全局照明(路径追踪)渲染器。
    演示:https://01alchemist.com/projects/xrenderer/example.html
    源代码:https://github.com/nidin/xrenderer

    热量警告!

    2016 年 5 月 13 日 上午 09:48

  12. Nils

    嘿,
    不错,很有趣的工作!
    有没有办法从网页中确定计算机有多少个可用的核心?这就像一个非常必要的事情,因为否则人们总是猜测(4 个?8 个?)因此要么工作人员不够(CPU 处于闲置状态),要么工作人员太多(切换成本降低了加速)

    2016 年 5 月 14 日 下午 13:10

    1. Lars T Hansen

      试试 navigator.hardwareConcurrency():https://mdn.org.cn/en-US/docs/Web/API/NavigatorConcurrentHardware/hardwareConcurrency.

      2016 年 5 月 17 日 下午 23:34

      1. Nils

        谢谢,这看起来很有帮助。即使浏览器支持不是很好,在可用的浏览器中仍然是一个巨大的进步。

        2016 年 5 月 18 日 上午 04:47

  13. Max Franz

    很棒的文章!

    共享内存绝对是朝着正确方向迈出的一步。我希望看到对共享对象(即 JSON)的支持,以提高易用性。虽然共享数字数组对于简单的数值计算很好,但我认为它在一些更复杂的情况下会很笨拙(您需要使用对象-SharedArrayBuffer 序列化/反序列化——在这种情况下,您也可以直接复制)。

    我将向 Weaver.js (http://weaver.js.org) 添加 SharedArrayBuffer 支持。ems 项目看起来在 Node.js 端可能有用——感谢链接,Jace。

    2016 年 5 月 24 日 上午 08:47

    1. Lars T Hansen

      很明显,我们希望在共享内存中有一些对象支持。可以构建用户级对象系统来实现这一点,我已经构建了一个预处理器 (https://github.com/lars-t-hansen/flatjs) 并使用它在共享内存中构建了带有场景图的光线追踪器,例如。(FlatJS 还没有达到生产质量,但暗示了一种不太动态的类型系统,适合高性能程序。)

      更复杂的

      2016 年 5 月 24 日 下午 12:58

      1. Daniel Earwicker

        梦想是让 JS 获得一个特定的内置概念“记录”(一个不可变的对象),它只能引用其他记录和基本类型,以便可以将记录树在工作者之间共享。

        以及一个创建修改克隆的简洁语法,类似于 Ocaml 的 `with` 关键字

        let b = { a with firstName = “Homer” }

        或者 Haskell 的

        let b = a { firstName = “Homer” }

        (这也有助于在 TypeScript 中进行类型检查。)

        2016 年 5 月 24 日 下午 15:11

本文的评论已关闭。