WebAssembly 无疑是近年来让人最为兴奋的新技术之一,它虽始于浏览器但已经开始不断地被各个语言及平台所集成。在实际的工业化落地中,区块链、边缘计算、游戏及图像视频等多个领域都依靠 WebAssembly 创造了让人称赞的产品。WebAssembly 技术本身具有非常多优点,其中最为被人所熟知的三点有:

  • 二进制格式
  • Low-Level 的编译目标
  • 接近 Native 的执行效率

那么 WebAssembly 是从何演变而来,它为什么具有这些优点与特性,又是如何被标准化的,更重要的是作为普通开发者,我们应如何更好地入手 WebAssembly 开发及实践呢?本专题将围绕 WebAssembly 及 Emscripten 工具链,通过一系列文章依次介绍 WebAssembly 的演变历程、工具链使用、实践案例、最新应用场景及使用技巧,帮助普通开发者正确理解 WebAssembly 的使用场景,并能够顺利使用 Emscripten 工具链完成自己的 WebAssembly 相关项目。

本文作为专题的第一篇文章,将会较为详细地介绍 WebAssembly 的演变历程,使读者深入理解 WebAssembly 这门技术的使用场景,从而更好地学习和使用 WebAssembly 技术。

JavaScript 的弊端

JavaScript 毫无疑问是技术领域的佼佼者。自 Brendan Eich 于 1995 年花费 10 天时间为 Netscape 开发出 JavaScript 为始,到现在已经走过了 20 多个年头。随着技术的蓬勃发展,不管是 NPM 与 GitHub 上丰富的 JavaScript 库与框架,还是 React Native、Node.js、Electron、QuickJS 等领域技术的出现,无一不彰显着 JavaScript 生态的繁荣,JavaScript 这门语言也变得越来越流行和重要。

但与此同时,随着各类应用功能的复杂化,受限于 JavaScript 语言本身动态类型和解释执行的设计,其性能问题也逐渐凸现。我们急需新技术帮助我们解决 JavaScript 的性能问题。在 2008 年底,Google、Apple、Mozilla 为 JavaScript 引入了 JIT(Just-In-Time)引擎,试图解决 JavaScript 的性能问题,并取得了非常好的效果。其中的佼佼者非 Google 的 V8 莫属,其大举提升了 JavaScript 的性能,并拉开了 JavaScript 引擎竞速的序幕。

那 JIT(Just-In-Time)引擎是如何提升 JavaScript 性能的呢?

我们知道,由于 JavaScript 是解释型语言,因此 JavaScript 引擎需要逐行将 JavaScript 代码翻译为可执行的代码。可执行代码有多种形式,其中较为常见的是基于 AST 的直接执行以及 ByteCode 的执行方式。显而易见,这些做法相比于直接运行机器码而言都并不高效,如果我们能根据代码的执行频次将部分代码实时编译为机器码,就能获得更大的性能提升。这就是 JIT(Just-In-Time)的基本思路。

在实际生产中,JIT(Just-In-Time)引擎一般会引入多层次的决策来优化代码:

  • warm 阶段(解释执行的代码被执行多次): 将解释执行的代码发送给 JIT(Just-In-Time)引擎,并创建出编译为机器码的执行代码,但此处并不进行替换;
  • hot 阶段(解释执行的代码被执行得十分频繁): 解释执行代码被替换为 warm 阶段的机器码执行代码;
  • very hot 阶段:将解释执行的代码发送给优化编译器(Optimising Compiler),创建和编译出更高效的机器码的执行代码并进行替换;

假设我们的 JavaScript 代码中有部分代码被执行了多次,此时这部分代码会被标记为 warm,同时被送往 JIT(Just-In-Time)引擎进行优化。JIT(Just-In-Time)引擎此时会针对这些代码逐行进行机器码编译,然后存储在一张表的单元中(实际上表单元仅指向了被编译的机器码)。当解释执行的代码被执行得非常频繁时会进入 hot 阶段,JIT(Just-In-Time)引擎会将解释执行的代码直接替换为编译的机器码版本。

需要注意的是,表单元的引用依据实际上会依赖于行号以及参数类型,假设我们有如下的代码:

function doSomething(value){

    // performing some operations

}

const arr = [0, "String"];

for (let i = 0; i < arr.length; i++) {

    doSomething(arr[i])

}

由于数组 arr 中存在两种数据类型(Number/String),当我们多次执行相关代码时,doSomething函数会被 JIT(Just-In-Time)引擎创建并编译出两个不同类型的机器码执行代码版本,并且使用不同的表单元引用。当然,由于机器码执行代码的创建和编译存在代价,因此不同的 JIT(Just-In-Time)引擎会有不同的优化策略。

如果部分代码执行得异常频繁,那么自然的这部分解释执行的代码会被发送给优化编译器(Optimising Compiler)进行更高程度的优化,从而创建并编译出相比 warm 阶段更高效的机器码执行代码版本。

与此同时,在创建这些高度优化的机器码执行代码期间,编译器将会严格限制执行代码的适用类型(比如仅适用于 Number/String 或某些特定类型参数),并且在每次调用执行前都会检查参数类型。如果匹配则使用这些高度优化的机器码执行代码,否则将会回退到 warm 阶段生成的机器码执行代码或是直接解释执行。

JavaScript 有了 JIT(Just-In-Time)后就能高枕无忧了么?不尽然。从上面的介绍中我们可以看到,JIT(Just-In-Time)引擎的优化并非是完全无代价的。同时由于 JavaScript 自身的灵活性,如果我们编写 JavaScript 代码时并没有将数据类型严格固定,那么 JIT(Just-In-Time)的效果将会大打折扣。在 Google V8 团队的《JIT-less V8》文章中我们可以看到,使用JIT-less 模式的V8 在运行Youtube 的Living Room 页面时,其测试成绩与使用JIT 的V8 实际差距仅为6%。这个测试侧面反应了JIT 在生产中并不是完全的“性能银弹”。

图0:WebAssembly成为“浏览器第二编程语言”?

JIT-less 模式下 V8 与基线的对比
那么 JavaScript 能变得更快吗?还是说我们需要其他技术来解决 JavaScript 的性能问题?此时 NaCl 和 PNaCl 应运而生。

NaCl 与 PNaCl

尽管 JavaScript 由于 JIT 的加入在性能上有了很大的提升,但在许多性能敏感的领域,JavaScript 仍旧无法满足需求。因此在 2008 年,Google 的 Brad Chen、Bennet Yee 以及 David Sehr 开源了 NaCl 技术,2009 年,NaCl 技术正式达到生产可用状态。NaCl 全称为“Native Client”,其由 C/C++ 语言编写并定义了一套 Native Code 的安全子集(SFI 技术),同时执行于自己独立的沙盒环境之中,以防止安全性未知的 C/C++ 代码对操作系统本身产生危害。

NaCl 应用及其模块在性能上与原生应用的差距非常小,但由于 NaCl 与 CPU 架构强关联且不具有可移植性,需要针对不同的平台进行开发和编译,导致开发者无法自由分发 NaCl 应用及模块。为了解决这个问题,NaCl 改进技术 PNaCl 出现了。

图1:WebAssembly成为“浏览器第二编程语言”?

NaCl 的性能损耗极小
PNaCl 的全称为”Portable Native Client”,其通过替换 Native Code 为 LLVM IR 子集并在客户端编译为 NaCl 的方式解决了 NaCl 的分发问题。PNaCl 不依赖于特定的 CPU 架构,更易于被部署和使用,“一次编译,到处运行”在 PNaCl 上得到了实现。但同样的,PNaCl 也是运行在自己的独立沙盒之中,其无法直接的访问 Web APIs,而是需要通过一个名为“PPAPI”的接口来与 JavaScript 通信。

PNaCl 技术在当时看起来是一个非常理想的方案,其兼具高性能和易于分发的特点,但实际上在当时并没有受到非常强的支持。PPAPI 出现的时代正好是处于人们尽可能试图摆脱 Flash、Java Applet 等插件的时代,尽管当时 Chrome 已经直接集成了 NaCl 与 PNaCl,但其运行在独立沙盒环境与使用独立 API 的方式,跟 Flash、Java Applet 等插件非常类似。同时,其开发难度、成本以及糟糕的兼容性问题(2011 年开始 Firefox 及 Opera 正式支持 PPAPI 及 NaCl)都成为了 NaCl/PNaCl 普及的最大障碍。

让人惊艳的 asm.js

谈到 asm.js 和 WebAssembly,就不得不提其中的关键人物 Alon Zakai。2010 年,Alon Zakai 结束了两年的创业项目,加入 Mozilla 负责 Android 版 Firefox 的开发。在 Mozilla 的本职工作之外,Alon Zakai 继续编写着自己的 C/C++ 游戏引擎。在项目临近尾声之时,Alon Zakai 突发奇想,想将自己的 C/C++ 游戏引擎运行在浏览器上。在 2010 年,NaCl 还是一门非常新的技术,而 PNaCl 才刚刚开始开发,此时并没有一个非常好的技术方案能够将 Alon 的 C/C++ 游戏引擎跑在浏览器上。但好在 C/C++ 是强类型语言,而 JavaScript 是弱类型语言,将 C/C++ 代码编译为 JavaScript 代码在技术实现上是完全可行的。于是 Alon Zakai 自此开始编写相关的 Compiler 实现,Emscripten(LLVM into JavaScript)由此诞生了!

图2:WebAssembly成为“浏览器第二编程语言”?

到 2011 年,Emscripten 已经具备编译像 Python 以及 DOOM 等中大型项目的能力,与此同时 Emscripten 也在 JSConfEU 会议上首次亮相,并取得了一定的影响力。Mozilla 看到了 Emscripten 项目的巨大潜力(相较于 NaCl 而言对 Web 更加友好),Brendan 及 Andreas 邀请 Alon 加入 Mozilla 的 Research 团队全职负责 Emscripten 项目的开发,Alon Zakai 欣然接受并将工作的重心放在了如何提升 Emscripten 编译的 JavaScript 代码执行速度上。

JavaScript 的弊端章节中我们可以看到,尽管 JavaScript 拥有 JIT(Just-In-Time),但由于 JavaScript 本身的语言特性,导致 JIT(Just-In-Time)难以被预测,在实际的生产环境当中 JIT(Just-In-Time)的效果往往并没有那么显著。

为了使得 JavaScript 运行得更快,我们应该要更充分地利用 JIT(Just-In-Time),因此在 2013 年,Alon Zakai 联合 Luke Wagner、David Herman 发布了 asm.js。

asm.js 的思想很简单,就是尽可能明确对应的类型,以便 JIT(Just-In-Time)被充分利用。如下图示例所示:

图3:WebAssembly成为“浏览器第二编程语言”?

我们可以看到,对于add函数而言,由于传入参数xy以及返回值进行了|0的操作,其能够很明确地为 JIT(Just-In-Time)指明对应的类型(i32),因此可以被 JIT(Just-In-Time)充分优化(不考虑后期 AOT 的情况)。

通过添加类似的类型注解,Emscripten 编译的 asm.js 在运行速度上相比普通 JavaScript 有了质的飞跃。在 Benchmark 中,asm.js 能达到 Native 性能的 50% 左右,相比于普通的 JavaScript 代码而言取得了极大的性能提升,这无疑是让人兴奋的成果。但是 asm.js 自身也存在一些无法忽视的问题,其总体而言并不是一个非常理想的技术方案。

图4:WebAssembly成为“浏览器第二编程语言”?

最显而易见的就是 asm.js 代码的“慢启动”问题。由于 asm.js 还是和 JavaScript 一样的文本格式,因此对于大中型项目而言,其解析花费的时间会非常长,无法与高效的二进制格式相提并论。

其次,asm.js 实质上是一种较为 hack 的实现方式,类似|0的类型标注不具有可读性,同时拓展 asm.js 也变得越来越复杂且不可靠:随着 asm.js 想要更加接近于 Native 的执行性能,不免会对诸多 Math 函数(例如 Math.imul 及 Math.fround 等)进行拓展和改写。从长远来看,这对 TC39 标准的制定并不友好,同时 asm.js 自身的相关实现(例如 memory growth 等)也遭遇了非常多的问题,导致 asm.js 标准被迫不断修订。“The hacks had a cost”,我们需要一个全新的技术来解决 asm.js 所遇到的这些问题。

合作共赢 – WebAssembly

在 2013 年,NaCl/PNaCl 与 asm.js/Emscripten 形成了不同路线发展的竞争态势,但与此同时,Google 及 Mozilla 也在工具及虚拟机层面加强了许多合作,其中包括:

  • 由 Google 的 JF Bastien 牵头,每月 Google 和 Mozilla 工具团队之间开展交流会;
  • Emscripten 和 PNaCl 开始共享部分代码,包括 Legalization Passes、le32 triple 等;
  • 尝试将 NaCl 应用通过 Emscripten 编译,并开源 Pepper.js;
  • Google 及 Mozilla 共同向 asm.js 贡献代码,并规划未来 Native Code 在 Web 上的合理方案;
  • 就 WebAssembly 前身“WebAsm”进行标准和方案的讨论;

最终在 2015 年的 4 月 1 号,“WebAssembly”击败了“WebAsm”、“WebMachine”和其它名称,在 Google 和 Mozilla 的团队交流邮件中被确定使用。至 2015 年 6 月 17 号,两方就 WebAssembly 的标准化工作达成一致,并搭建了 WebAssembly 官网开始对外宣传。WebAssembly 的设计汲取了 NaCl 与 asm.js 两者的优点:

  • WebAssembly 并不依赖于 JavaScript,与 NaCl/PNaCl 一样,它基于二进制格式,能够被快速解析;
  • 与 asm.js 一样,依靠 Emscripten 等工具链提供的 API,它以非常自然的方式直接操作 Web APIs,而不用像 PNaCl 一样需要处理与 JavaScript 之间的通信;
  • WebAssembly 依赖于 LLVM IR 并使用独立的 VM 环境,因此其它语言 / 平台能够以较低成本接入,同时能够且易于被持续优化至接近 Native 的性能;

目前各大主流浏览器已经完全实现了 WebAssembly 的 MVP 版本,并将其接纳为“浏览器的第二语言”。依靠优秀的设计,WebAssembly 也从浏览器平台走向更多平台,WASI(WebAssembly System Interface)将为 WebAssembly 提供更多的可能性。随着 WebAssembly 相关标准逐渐确定和完善,WebAssembly 技术的应用领域将会越来越广。

小结

本文从 JavaScript 开始,介绍了 NaCl/PNaCl 以及 asm.js 技术方案的优缺点。通过简单回顾 WebAssembly 相关历史背景,我们能更好地够理解 WebAssembly 技术的演变过程及其适用场景。在后面的文章中,我们将基于 Emscripten 工具链继续探讨 WebAssembly,并通过具体的实例介绍 WebAssembly 应用的基本方法和相关实现。

致谢

感谢 Emscripten 核心作者 Alon Zakai 在我写作此篇文章时对邮件所提问题的耐心解答和帮助,如此才使得我能够更全面、详细及正确地还原有关 WebAssembly 的技术演变历程。

作者介绍:

赵洋,Coupang 资深全栈工程师,曾任百度、腾讯、全民直播前端工程师,Modern Web/GMTC/FDCon 等多个会议讲师,编写了多个 WebAssembly 项目实践,目前正在尝试 WebAssembly 在图形相关领域的实践。

余下全文(1/3)

本文最初发表在www.infoq.cn,文章内容属作者个人观点,不代表本站立场。

分享这篇文章:

请关注我们:

发表评论

电子邮件地址不会被公开。 必填项已用*标注