前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >初探新的 JavaScript 并行特性

初探新的 JavaScript 并行特性

作者头像
疯狂的技术宅
发布2019-03-28 10:20:21
9130
发布2019-03-28 10:20:21
举报
文章被收录于专栏:京程一灯京程一灯

简介——我们给 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 应用有足够能力和原生应用竞争,就必须让它充分利用多核。

基础设施:共享内存,原子性,Web Worker

在过去一年中,Mozilla 的 JS 团队一直致力于构建 JS 多核计算的基础设施。其他浏览器厂商也参与到了这项工作中,我们的提案已经进入JS 标准化流程。在这个过程中,我们在 Mozilla 的 JS 引擎中实现的原型起了很大作用,并且已经可以在某些版本的 Firefox 中使用。

为了保持Web 可扩展性,我们在实现多核计算底层的基础设施时,尽量减少它们对程序的限制。最终我们实现了三个基础设施:一种新的共享内存的类型、对共享类型对象的原子操作以及一种在标准 web worker 之间传递共享内存对象的方法。这些想法并不是我们首创,Dave Herman 的这篇博文中有更多背景知识和发展历史。

这种新的共享内存类型被称为SharedArrayBuffer,和现在的ArrayBuffer类型很相似,两者最大的区别是:SharedArrayBuffer对应的内存可以被多个代理者同时引用(代理者可以是网页的主程序,也可以是其中一个 web worker)。使用PostMessage在两个代理者之间传递SharedArrayBuffer就会触发共享:

代码语言:javascript
复制
let sab = new SharedArrayBuffer(1024)
let w = new Worker("...")
w.postMessage(sab, [sab])   // 传递 buffer

Worker 会通过消息收到被共享的数据:

代码语言:javascript
复制
let mem;
onmessage = function (ev) { mem = ev.data; }

这就引出了下面这种情况,主程序和 worker 引用了同样的内存,但是这块内存并不属于两种中的任何一个:

一旦SharedArrayBuffer被共享,所有引用它的代理人都可以创建一个TypedArray视图并使用标准的数组操作来读写这块内存。假设 worker 做了下面的操作:

代码语言:javascript
复制
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。

这种提速效果相当显著。然而,并行版本比串行版本复杂得多。这种复杂性由很多因素导致:

  • 如果并行版本要正常工作,那就必须同步(synchronize) worker 和主程序:主程序必须通知 worker 何时(以及如何)计算,worker 必须通知主程序何时展示结果。当然,可以使用postMessage双向传递数据,但是通常来说用共享内存会更快。快速、正确地实现同步操作真的很难。
  • 划分计算任务需要一套负载均衡(load balancing)策略来充分利用 worker。在这个示例程序中就是如此,输出的图片被划分成了许多块,比 worker 数量多得多。
  • 最后,共享内存本身只是一个一维整数数组;如果要在共享内存中使用更加复杂的数据结构,必须手动处理。

说说同步吧:新的Atomics对象有两个方法,waitwake,可以向 worker 发送信号:一个 worker 通过调用Atomics.wait来等待一个信号,另一个 worker 使用Atomics.wake发送这个信号。不过这些只是可扩展的底层基础设施;要实现同步,程序需要使用其他的原子操作,比如Atomics.loadAtomics.storeAtomics.compareExchange,从而读写共享内存中的状态值。

更复杂的是,网页的主线程不允许调用Atomics.wait,因为主线程不能阻塞(译者注:如果阻塞页面就无法响应用户操作,直接卡死)。所以虽然 worker 可以用Atomics.waitAtomics.wake通信,主线程必须通过监听事件来实现等待,如果想唤醒主线程,worker 必须使用postMessage发送对应的事件。

(提个醒,在 Firefox 46 和 Firefox 47 中,waitwake的名字是futexWaitfutexWake。详情参见Atomics 的 MDN 页面。)

设计良好的库可以隐藏绝大部分复杂性,如果一个程序——或者一个程序的重要部分——可以利用多核大幅提高性能,那所有的付出都是值得的。不过请注意,并行程序并不是万灵药,它无法拯救烂程序。


往期精选文章

ES6中一些超级好用的内置方法

浅谈web自适应

使用Three.js制作酷炫无比的无穷隧道特效

一个治愈JavaScript疲劳的学习计划

全栈工程师技能大全

WEB前端性能优化常见方法

一小时内搭建一个全栈Web应用框架

干货:CSS 专业技巧

四步实现React页面过渡动画效果

让你分分钟理解 JavaScript 闭包



小手一抖,资料全有。长按二维码关注京程一灯,阅读更多技术文章和业界动态。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2017-09-06,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 京程一灯 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 多核计算
  • 基础设施:共享内存,原子性,Web Worker
  • 性能和响应度
  • 免费午餐?想都别想
  • 举个例子
  • 串行分形
  • 并行分形
相关产品与服务
负载均衡
负载均衡(Cloud Load Balancer,CLB)提供安全快捷的流量分发服务,访问流量经由 CLB 可以自动分配到云中的多台后端服务器上,扩展系统的服务能力并消除单点故障。负载均衡支持亿级连接和千万级并发,可轻松应对大流量访问,满足业务需求。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档