简介——我们给 JavaScript 添加了一个 API,开发者可以在 JavaScript 中使用多个 worker 和共享内存来实现真正的并行算法。
现如今,JavaScript(JS)已经获得了广泛应用,每个现代网页都包含大量 JS 代码,我们也从未有过顾虑——因为所有的代码都运行在一个进程中。除了网页,JS 还被用于许多高计算量的任务中:(Facebook 和 Lightroom)使用 JS 做服务端图片处理;类似 Google Docs 这样的浏览器端办公套件用 JS 开发;Firefox 的许多组件(比如内置的 PDF 阅读器、pdf.js 以及分词工具)也用 JS 开发。这些应用中有几个实际上使用的是 asm.js,这是一个简单的 JS 子集,可以由 C++ 编译器生成;原本使用 C++ 开发的游戏引擎可以被重新编译成 JS 并通过 asm.js 运行在网页中。
JS 引擎中的 Just-in-Time(JIT)编译器配合性能更强的 CPU 可以为上述任务以及类似的任务带来极大的性能提升。
但是目前 JS 的 JIT 发展缓慢,CPU 性能提升也遇到了瓶颈。由于没有更强的 CPU,所有的电子设备——从电脑系统到智能手机——都选择了多 CPU(多核)作为解决方案。除了低端设备,其他大部分设备都有超过两个核心。对于那些想要提高程序性能的开发者来说,他们需要并行使用多个核心。对于“原生”应用来说这不是什么难题,因为原生应用使用的语言本来就支持多线程(Java、Swift、C# 和 C++)。不幸的是,JS 对多核的支持很差,开发者能用的东西很少(web worker、低效的消息传递和少数几种避免数据拷贝的方法)。
因此,如果我们想让 JS 应用有足够能力和原生应用竞争,就必须让它充分利用多核。
在过去一年中,Mozilla 的 JS 团队一直致力于构建 JS 多核计算的基础设施。其他浏览器厂商也参与到了这项工作中,我们的提案已经进入JS 标准化流程。在这个过程中,我们在 Mozilla 的 JS 引擎中实现的原型起了很大作用,并且已经可以在某些版本的 Firefox 中使用。
为了保持Web 可扩展性,我们在实现多核计算底层的基础设施时,尽量减少它们对程序的限制。最终我们实现了三个基础设施:一种新的共享内存的类型、对共享类型对象的原子操作以及一种在标准 web worker 之间传递共享内存对象的方法。这些想法并不是我们首创,Dave Herman 的这篇博文中有更多背景知识和发展历史。
这种新的共享内存类型被称为SharedArrayBuffer
,和现在的ArrayBuffer
类型很相似,两者最大的区别是:SharedArrayBuffer
对应的内存可以被多个代理者同时引用(代理者可以是网页的主程序,也可以是其中一个 web worker)。使用PostMessage
在两个代理者之间传递SharedArrayBuffer
就会触发共享:
let sab = new SharedArrayBuffer(1024)
let w = new Worker("...")
w.postMessage(sab, [sab]) // 传递 buffer
Worker 会通过消息收到被共享的数据:
let mem;
onmessage = function (ev) { mem = ev.data; }
这就引出了下面这种情况,主程序和 worker 引用了同样的内存,但是这块内存并不属于两种中的任何一个:
一旦SharedArrayBuffer
被共享,所有引用它的代理人都可以创建一个TypedArray
视图并使用标准的数组操作来读写这块内存。假设 worker 做了下面的操作:
let ia = new Int32Array(mem);
ia[0] = 37;
如果主程序在 worker 写入之后读取第一个元素,它会看到那里写着“37”。
那么,要怎么让主程序“等待 worker 写入”呢?如果多个代理人随意读写同一块内存,那就乱套了。我们需要一套全新的原子操作,从而保证程序对内存的操作按照预定的顺序发生,不会出岔子。原子操作是一组静态方法,存放在一个新的顶层Atomics对象中。
使用多核计算可以解决两个问题:第一个是性能,也就是单位时间内我们可以完成的工作量;第二个是响应度,也就是浏览器在计算时还能在多大程度上响应用户交互。
我们可以把任务分配给多个并行 worker,从而提高性能:如果我们能把一次计算任务分成四部分,分别运行在四个 worker 上,并且让每个 worker 运行在一个核上,那理论上就可以提速四倍。对于响应度,我们可以把任务从主程序转移到 worker 中,这样主程序可以在不影响任务运行的前提下响应 UI 事件。
之所以需要共享内存,有两个原因。首先,它可以避免数据拷贝。举个例子,如果我们在多个 worker 中渲染一个场景,渲染完毕之后通过主程序展示,那就必须把渲染后的场景拷贝到主程序中,这会增加渲染时间,并且降低主程序的响应度。其次,共享内存会大大降低代理人之间的协作成本,比postMessage
要高效得多,这样就可以降低代理人的通信等待时间。
多核不是那么好驾驭的。针对单核编写的程序通常需要大幅重构,而且很难验证重构之后程序的正确性。如果 worker 之间需要频繁通信,那就很难发挥多核的性能。并不是所有程序都适合并行。
此外,并行程序会带来许多全新的 bug。如果不小心让两个 worker 互相等待,那程序就无法继续运行:它死锁(deadlock)了。如果 worker 随意读写同样的内存单元,那可能会(无意中并且没有任何征兆地)产生错误数据:程序中出现了数据竞争(data race)。有数据竞争的程序基本可以确定是不正确并且不可靠的。
注意: 如果要运行本文中的示例代码,你需要安装 Firefox 46 或者更新的版本。你还需要在about:config
页面中把javascript.options.shared_memory
设置成true
,除非你使用的是Firefox Nightly。
我们看看如何让程序通过多核并行来提高性能。下面我们来编写一个简单的分形动画,这个动画需要计算一组像素值并展示在 canvas 中,与此同时不断放大图像。(分形计算通常被认为“极易并行”:很容易通过并行提速。不过事情往往没有你想的那么简单。)这里我们只讨论并行相关的内容,如果你想了解更多信息,可以点击阅读每节结尾列出的链接。
为什么 Firefox 默认关闭了共享内存特性?因为目前它还没有正式成为 JS 标准。成为标准还需要一段时间,这个特性也可能会继续发生变化,我们不希望任何代码依赖现在的 API。
我们先来看看不应用并行的分形程序:计算在页面的主程序中进行,直接把结果渲染到 canvas 中。(运行下面的示例代码时,不要持续太长时间,时间越长越卡。)
<iframe src="https://axis-of-eval.org/blog/mandel0.html" height="560" width="660"></iframe>
下面是源代码:
并行版本的分型程序会用多个 worker 在一块共享内存中计算像素。从串行变成并行并不难:把mandelbrot
方法放到多个 worker 中,每个 worker 计算总像素的一部分。主程序可以在展示 canvas 图像的同时保持响应。
<iframe src="https://axis-of-eval.org/blog/mandel3.html?numWorkers=4" height="560" width="660"></iframe>
下图显示了不同核心数对应的帧率(FPS,每秒帧数)。我们使用的是一台 late-2013 的 MacBook Pro,有四个超线程核心(hyperthreaded core),浏览器是 Firefox 46.0。
核心从一到四的过程中,程序的性能提升基本上是线性的,从 6.9 FPS 增加到了 25.4 FPS。从四核开始,性能提升开始减速,因为程序并不是运行在新的核心上,而是运行在(已被使用的)核心的超线程上。(同一个核心的超线程会共享一些资源,这些资源可能有冲突,从而影响性能。)尽管如此,每增加一个超线程,我们都可以提高 3~4 FPS,增加到 8 个 worker 时,程序的计算速度达到 39.3 FPS,单个核心只有 5.7 FPS。
这种提速效果相当显著。然而,并行版本比串行版本复杂得多。这种复杂性由很多因素导致:
postMessage
双向传递数据,但是通常来说用共享内存会更快。快速、正确地实现同步操作真的很难。说说同步吧:新的Atomics
对象有两个方法,wait
和wake
,可以向 worker 发送信号:一个 worker 通过调用Atomics.wait
来等待一个信号,另一个 worker 使用Atomics.wake
发送这个信号。不过这些只是可扩展的底层基础设施;要实现同步,程序需要使用其他的原子操作,比如Atomics.load
、Atomics.store
和Atomics.compareExchange
,从而读写共享内存中的状态值。
更复杂的是,网页的主线程不允许调用Atomics.wait
,因为主线程不能阻塞(译者注:如果阻塞页面就无法响应用户操作,直接卡死)。所以虽然 worker 可以用Atomics.wait
和Atomics.wake
通信,主线程必须通过监听事件来实现等待,如果想唤醒主线程,worker 必须使用postMessage
发送对应的事件。
(提个醒,在 Firefox 46 和 Firefox 47 中,wait
和wake
的名字是futexWait
和futexWake
。详情参见Atomics 的 MDN 页面。)
设计良好的库可以隐藏绝大部分复杂性,如果一个程序——或者一个程序的重要部分——可以利用多核大幅提高性能,那所有的付出都是值得的。不过请注意,并行程序并不是万灵药,它无法拯救烂程序。
往期精选文章 |
---|
ES6中一些超级好用的内置方法 |
浅谈web自适应 |
使用Three.js制作酷炫无比的无穷隧道特效 |
一个治愈JavaScript疲劳的学习计划 |
全栈工程师技能大全 |
WEB前端性能优化常见方法 |
一小时内搭建一个全栈Web应用框架 |
干货:CSS 专业技巧 |
四步实现React页面过渡动画效果 |
让你分分钟理解 JavaScript 闭包 |
小手一抖,资料全有。长按二维码关注京程一灯,阅读更多技术文章和业界动态。