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 引擎竞速的序幕。
我们知道,由于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 ) {
}
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 在生产中并不是完全的“性能银弹”。
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 出现了。
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)由此诞生了!
到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)被充分利用。如下图示例所示:
我们可以看到,对于add 函数而言,由于传入参数x 、y 以及返回值进行了|0 的操作,其能够很明确地为JIT (Just-In-Time)指明对应的类型(i32) ,因此可以被JIT (Just-In-Time)充分优化(不考虑后期AOT 的情况) 。
通过添加类似的类型注解,Emscripten 编译的asm.js 在运行速度上相比普通JavaScript 有了质的飞跃。在Benchmark 中,asm.js 能达到Native 性能的50% 左右,相比于普通的JavaScript 代码而言取得了极大的性能提升,这无疑是让人兴奋的成果。但是asm.js 自身也存在一些无法忽视的问题,其总体而言并不是一个非常理想的技术方案。
最显而易见的就是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 技术的应用领域将会越来越广。