## WebAssembly,Web 的新时代
文/张敏
>在浏览器之争中,Chrome 凭借 JavaScript 的卓越性能取得了市场主导地位,然而由于 JavaScript 的无类型特性,导致其运行时消耗大量的性能做为代价,这也是 JavaScript 的瓶颈之一。WebAssembly 旨在解决这一问题。本文从 WebAssembly 的起源到开发实践对其做全面探究,帮助开发者对 WebAssembly 有全面的了解。
### 缘起
让我们从浏览器大战说起。微软凭借 Windows 系统捆绑 Internet Explorer 的先天优势击溃 Netscape 后,进入了长达数年的静默期。而 Netscape 则于1998年将
Communicator 开源,并由 Mozilla 基金会衍生出 Firefox 浏览器,在2004年发布了1.0版本。从此,第二次浏览器大战拉开帷幕。这场大战由 Firefox 浏览器领衔,Safari、Opera 等浏览器也积极进取,Internet Explorer 的主导地位首次受到挑战。2008年 Google 推出 Chrome 浏览器,不但逐步侵蚀 Firefox 的市场,更是压制了老迈的 Internet Explorer。在此次大战之后的2012年,StatCounter 的数据指出 Chrome 以微弱优势超越 Internet Explorer 成为世界上最流行的浏览器。
分析 Google Chrome 浏览器战胜 Internet Explorer 的原因,除了对 Web 标准更友善的支持外,卓越的性能是其中相当重要的因素,而浏览器性能之争的本质则体现在 JavaScript 引擎。此前,JavaScript 引擎的实现方式经历了遍历语法树到字节码解释器等较为原始的方式,将每条源代码翻译成相应的机器码并执行,并不保存翻译后的机器码,使得解释执行很慢。2008年9月,Google 发布了 V8 JavaScript 引擎。V8被设计用于提高 Web 浏览器中 JavaScript 的执行性能,通过即时编译 JIT(Just-In-Time)技术,在执行时将 JavaScript 代码编译成更为高效的机器代码并保存,下次执行同一代码段时无需再编译,使得 JavaScript 获得了几十倍的性能提升。
然而,JavaScript 是个无类型(untyped,变量没有类型)的语言,这直接导致表达式 c=a+b 有多重含义:
1. a、b均为数字,则算术运算符+表示值相加;
2. a、b为字符串,则+运算符表示字符串连接;
3. …
表达式执行时,JIT 编译器需要检查 a 和 b 的类型,确定操作行为。若 a、b 均为数字,JIT 编译器则将 a、b 确认为整型,而一旦某一变量变成字符串,JIT 编译器则不得不将之前编译的机器码推倒重来。由此可见,JavaScript 的无类型特性建立在消耗大量性能代价的基础之上。即便 JIT 编译器在对变量类型发生变化时已进行相应优化,但仍然有很多情况 JavaScript 引擎未进行或无法优化,例如 for-of、try-catch、try-finally、with 语句以及复合 let、const 赋值的函数等。
由此可见,JavaScript 的无类型是 JavaScript 引擎的性能瓶颈之一,改进方案有两种:一是设计一门新的强类型语言并强制开发者进行类型指定;二是给现有的 JavaScript 加上变量类型。
微软开发的 TypeScript 属于第一种改进方案。它是扩展了 JavaScript 特性的语言,包含了类型批注,编译时类型检查,类型推断和擦除等功能,TypeScript 开发者在声明变量时指定类型,使得 JavaScript 引擎能够更快将这种强类型的语言编译成弱类型。
看看第二种方案:
代码1表示带有两个参数(a 和 b)的 JavaScript 函数,和通常 JavaScript 代码不同的地方在于 a=a | 0及b=b | 0,以及返回值后面均利用标注进行了按位 OR
操作。这么做的优点是使 JavaScript 引擎强制转换变量的值为整型执行。通过标注加上变量类型,JavaScript 引擎就能更快地编译。
既然增加变量类型能够提升 Web 性能,有没有办法将静态类型代码例如 C/C++ 等转换成 JavaScript 指令的子集呢?上面的这段代码恰恰是作为 JavaScript 子集的 asm.js,由代码2的 C 语言编译而来:
事实上,早在1995年起就已经有 Netscape Plugin API(NPAPI)在内的可以使用浏览器运行 C/C++ 程序的项目在开发。而2013年问世的 asm.js 是目前较为广泛的方案。asm.js 是一种中间编程语言,允许用
C/C++ 语言编写的计算机软件作为 Web 应用程序运行,并保持更好的性能,而 Mozilla Firefox 从版本22起成为第一个为 asm.js 特别优化的网页浏览器。
Google 也同样在为原生代码运行在 Web 端而努力。Google Native Client(NaCl)采用沙盒技术,让 Intel x86、ARM 或 MIPS 子集的机器码直接在沙盒上运行。它能够在无需安装插件的情况下从浏览器直接运行原生可执行代码,使 Web 应用程序可以用接近于机器码运作的速度来运行。而 Google Portable Native Client(PNaCl)则稍有变化,通过一些前端编译器将 C/C++ 源代码编译成 LLVM 的中间字节码而不是 x86 或 ARM 代码,并且进行优化以及链接(如表1所示)。
表1 JavaScript 及原生代码支持对比
有了类型支持,第二种方案性能提升潜力远远大于第一种。
然而,无论是 asm.js 或现有 PNaCl 的解决方案,都面临着一些缺陷(例如 1KB 的 C 源码编译生成 asm.js 后的大小有480KB)或其他浏览器不支持的窘境,而2016年10月对 Chromium 问题跟踪代码的评论更是表明,Google Native Client 小组已被关闭。
作为 Web 浏览器性能和代码重用的解决方案,asm.js 及 PNaCl 都没能被普遍接受,那么有没有上述表格中的特性全部占优,且跨厂商的解决方案呢?
WebAssembly 旨在解决这个问题。
### 新时代
WebAssembly (简称 Wasm)是一种新的适合于编译到Web的,可移植的,大小和加载时间高效的格式。这是一个新的与平台无关的二进制代码格式,目标是解决 JavaScript 性能问题。这个新的二进制格式远小于 JavaScript,可由浏览器的 JavaScript 引擎直接加载和执行,这样可节省从 JavaScript 到字节码,从字节码到执行前的机器码所花费的即时编译 JIT(Just-In-Time)时间。 作为一种低级语言,它定义了一个抽象语法树(Abstract Syntax Tree,AST),开发人员可以以文本格式进行调试。
WebAssembly 描述了一个内存安全的沙箱执行环境,可以在现有的 JavaScript 虚拟机中实现。 当嵌入到 Web 中时,WebAssembly 将强制执行浏览器的同源和权限安全策略。因此,和经常出现安全漏洞的 Flash 插件相比,WebAssembly 是一个更加安全的解决方案。
WebAssembly 可由 C/C++ 等语言编译而来。此外,WebAssembly 由 Google、Mozilla、微软以及苹果公司牵头的 W3C 社区组共同努力,基本覆盖主流的浏览器厂商,因此其可移植性相较 Silverlight 等有极大提升,平台兼容问题将不复出现。
在 Web 平台的很多项目中,对于原生新功能的支持需要 Web 浏览器或 Runtime 提供复杂的标准化的 API 来实现,但是 JavaScript API 往往较慢。使用 WebAssembly ,这些标准 API 可以更简单,并且操作在更低的水平。例如,对于一个面部识别的 Web 项目,对于访问数据流我们可以由简单的 JavaScript API 实现,而把面部识别原生 SDK 做的事情交由 WebAssembly 实现。
需要了解的是,WebAssembly 不是将 C/C++ 等其他语言编译到 JavaScript ,更不是一种新的编程语言。
### 探究
##### asm.js
上文的 C 语言求和代码经由编译器生成 asm.js 后如代码3所示。
上述代码转换为 WebAssembly 的文本格式稍显复杂,为了理解方便,我们从精简的 asm.js 开始(见代码4)。
#### wast 文本文件
将 asm.js 代码转换为 WebAssembly 的文本格式 add.wast(转换工具见本文工具链章节,如代码5所示)。
WebAssembly 中代码的可装载和可执行单元被称为一个模块(module)。在运行时,一个模块可以被一组 import 值实例化,多个模块实例能够访问相同的共享状态。目前文本格式中的 module 主要用 S 表达式来表示。虽然 S 表达格式不是正式的文本格式,但它易于表示
AST。WebAssembly 也被设计为与 ES6 的 modules 集成。
一个单一的逻辑函数定义包含两个部分:功能部分声明在模块中每个内部函数定义的签名,代码段部分包含由功能部分声明的每个函数的函数体。WebAssembly 是带有返回值的静态类型,并且所有参数都含有类型。上面的 add.wast可以解读为:
1. 声明了一个名为 $add 的函数;
2. 包含两个参数 $ a 和 $ b,两者都是32位整型;
3. 结果是一个32位整型;
4. 函数体是一个32位的加法:
5. 上面是局部变量 $a 得到的值;
6. 下面是局部变量 $b 得到的值;
7. 由于没有明确的返回节点,因此 return 是该加法函数的最后加载指令。
#### 二进制 Wasm 文件
如图1所示,由 C 语言求和代码经过编译生成二进制文件,通读文件可以找到相应的头部、类型、导入、函数以及代码段等。通过 JavaScript API 载入 Wasm 二进制文件后,最终转换到机器码执行。
图1 经过编译的二进制文件
### 工具链
开发人员现在可以使用相应的工具链从 C/C++ 源文件编译 WebAssembly 模块。WebAssembly 由许多工具支持,以帮助开发人员构建和处理源文件和生成的二进制内容。
#### Emscripten
Emscripten 是其中无法回避的工具之一,如图2所示。在图2中,Emscripten SDK 管理器(emsdk)用于管理多个 SDK 和工具,并且指定当前正被使用到编译代码的特定 SDK 和工具集。
图2 Emscripten 工具链流程图及生成 JavaScript (asm.js)流程
Emscripten 的主要工具是 Emscripten 编译器前端(emcc),它是例如 GCC 的标准编译器的简易替代实现。
Emcc 使用 Clang 将 C/C++ 文件转换为 LLVM(源自于底层虚拟机 Low Level Virtual Machine)字节码,使用 Fastcomp(Emscripten 的编译器核心,一个 LLVM 后端)把字节码编译成J avaScript 。输出的 JavaScript 可以由 Node.js 执行,或者嵌入 HTML 在浏览器中运行。这带来的直接结果就是,C 和 C++ 程序经过编译后可在 JavaScript 上运行,无需任何插件。
#### WABT 和 Binaryen
除此之外,对于想要使用由其他工具(如 Emscripten)生成的 WebAssembly 二进制文件感兴趣的开发者,目前 http:// WebAssembly .org/ 官方额外提供了另外两组不同的工具:
1. WABT ——WebAssembly 二进制工具包;
2. Binaryen——编译器和工具链。
WABT 工具包支持将二进制 WebAssembly 格式转换为可读的文本格式。其中 wasm2wast 命令行工具可以将 WebAssembly 二进制文件转换为可读的 S 表达式文本文件。而 wast2wasm 命令行工具则执行完全相反的过程。
Binaryen 则是一套更为全面的工具链,是用 C++ 编写成用于 WebAssembly 的编译器和工具链基础结构库(如图3所示)。WebAssembly 是二进制格式(Binary Format)并且和 Emscripten 集成,因此该工具以 Binary 和 Emscript-en 的末尾合并命名为 Binaryen。它旨在使编译 WebAssembly 容易、快速、有效。它包含且不仅仅包含下面的几个工具。
图3 Binaryen 生成 WebAssembly 流程
1. wasm-as:将 WebAssembly 由文本格式(当前为S表达式格式)编译成二进制格式;
2. wasm-dis:将二进制格式的 WebAssembly 反编译成文本格式;
3. asm2wasm:将 asm.js 编译到 WebAssembly 文本格式,使用 Emscripten 的 asm 优化器;
4. s2wasm:在 LLVM 中开发,由新 WebAssembly 后端产生的 .s 格式的编译器;
5. wasm.js:包含编译为 JavaScript 的 Binaryen 组件,包括解释器、asm2wasm、S 表达式解析器等。
Binaryen 目前提供了两个生成 WebAssembly 的流程,由于 emscripten 的 asm.js 生成已经非常稳定,并且 asm2wasm 是一个相当简单的过程,所以这种将 C/C++ 编译为 WebAssembly 的方法已经可用(如图4所示)。
图4 Emscripten+Binaryen 生成 WebAssembly 的完整流程
由此可见,Emscripten 以及 Binaryen 提供了完整的 C/C++ 到 WebAssembly 的解决方案。而 Binaryen 则帮助提升了 WebAssembly 的工具链生态。
#### 提示
由于 WebAssembly 正处于活跃开发阶段,各项编译步骤和编译工具会有大幅变更和改进,相信最终的编译工具和步骤会趋于便捷,开发者需要留意官方网站的最新动态。
### 实战
Linux 和 mac OS 平台编译原生代码到 WebAssembly 可由如下步骤实现。
#### 编译环境准备
操作系统必须有可以工作的编译器工具链,因此需要安装 GCC、cmake 环境,此外 Python、Node.js 及 Java 环境也是需要的(其中 Java 为可选,如图5所示)。
图5 编译环境安装
如果是以其他方式安装了 Node.js,可能需要更新 ~/.emscripten 文件的 NODE _ JS 属性。
#### 安装正确的 emscripten 分支
要编译原生代码到 WebAssembly ,我们需要 emscripten 的 incoming 分支。由于 emscripten 不仅仅是用于 WebAssembly 的编译工具链,选择正确的分支尤为重要(如图6所示)。
图6 安装 emscripten 的 incoming 分支
其中 URLTO 具体的 URL 是 https://s3.amazonaws.com/mozilla-games/emscripten/releases/emsdk-portable.tar.gz。
#### 处理安装异常
可运行 emcc -v 命令进行验证安装。如果遇到如图7所示的错误,表明带有 JavaScript 后端的 LLVM 编译器并未被生成。
图7 emcc -v 命令报错
图8 emcc -v 命令报错解决方案
通过图8步骤,可以解决该问题,并且在 ~/.emscripten 文件中修改如下配置:
#### 开始编译程序
现在一个完整的工具链已经具备,我们可以使用它来编译简单的程序到 WebAssembly 。但是,还有一些其他注意事项:
1. 必须通过参数 -s Wasm=1 到 emcc(否则默认 emcc 将编译出 asm.js);
2. 除了 Wasm 二进制文件和 JavaScript wrapper 外,如果还希望 emscripten 生成一个可直接运行的程序的 HTML 页面,则必须指定一个扩展名为 .html 的输出文件。
在编译之前,首先准备一个最基本的 add.c 程序,见代码6。
按代码7所示的命令编辑好 add.c 程序并编译:
#### 运行 WebAssembly 应用
以 Chrome 浏览器为例,如果直接在浏览器内本地打开 HTML 文件,会有图9所示的错误:
图9 XMLHttpRequest 本地访问的跨域请求错误
由于 XMLHttpRequest 跨域请求不支持 file:// 协议,必须经由 HTTP 实际输出,可以由 Python 的 SimplHTTPServer 改进,见代码8:
在浏览器中输入 http://127.0.0.1:8080 并打开 add.html,就能直接看到转换成 WebAssembly 的应用程序输出结果。
#### 创建独立 WebAssembly
默认情况下,emcc 会创建 JavaScript 文件和 WebAssembly 的组合,其中 JS 加载包含编译代码的 WebAssembly 。对于 C/C++ 开发人员,他们可能更倾向于创建独立的 WebAssembly ,用于 JavaScript 开发人员调用,见代码9。
上述命令运行后,我们可以得到独立的 Wasm 文件。需要说明的是,该参数仍然在开发中,可能随时发生规范和实现变更。
#### JavaScript API 调用
从 C/C++ 程序编译获得一个 .wasm 模块之后,JavaScript 开发人员可以通过如下方式进行载入 .wasm 文件并执行。WebAssembly 社区组也有计划通过
Streams 使用 streaming 以及异步编译,见代码10。
最后一行调用导出的 WebAssembly 函数,它反过来调用我们导入的 JS 函数,最终执行 add(201700, 2),并且在控制台获得期望的结果输出(如图10所示)。
图10 WebAssembly 求和函数在控制台的输出
### 性能
那么,WebAssembly 的真实性能如何呢?首先我们用一直被用来作为 CPU 基准测试的斐波那契 (Fibonacci)数列来进行对比,这里使用的是性能较差的递归算法,在 Node.js v7.2.1 环境下,能够看到 WebAssembly 性能优势越发明显(如图11所示)。
图11 CPU 基准测试反应 WebAssembly 的真实性能
再看看最基本的1000毫秒时间内,求和计算的运算量统计,在同一台计算机的 Firefox 50.1.0 版本的运算结果如图12所示。
图12 1000毫秒内求和计算的运算量统计
尽管重复测试时结果不尽相同,重启浏览器并多次测试取平均值后依然可以看到 WebAssembly 的运算量比 JavaScript 快了近一个量级。
### Demo
图13展示了 Angry Bots Demo,它是由 WebAssembly 项目发布的一个 Demo,由 Unity 游戏移植而来。
图13 Angry Bots Demo/Google Chrome 55.0.2883.87
通过如下方式可以体验 WebAssembly 在浏览器中的强大性能。即便 Google Chrome 较新的稳定版也已支持 WebAssembly ,还是推荐使用 canary 版及 Firefox 的 nightly 版进行测试。
1. 下载浏览器:
- Google Chrome;
- Mozilla Firefox;
- Opera;
- Vivaldi。
2. 打开 WebAssembly 支持 :
- Google Chrome:chrome://flags/#enable- WebAssembly ;
- Mozilla Firefox:about:config→接受→搜索 JavaScript .options.wasm→设置为 true;
- Opera:opera://flags/#enable- WebAssembly ;
- Vivaldi:vivaldi://flags#enable- WebAssembly 。
访问:http:// WebAssembly .org/demo/。
使用 W、A、S、D 等键实现移动操作,点击鼠标进行射击。该 WebAssembly 游戏在浏览器中运行相当流畅,媲美原生性能。
除了最新的浏览器开始对 WebAssembly 逐步支持外,Intel 开源技术中心开发的 Crosswalk 项目(https://crosswalk-project.org/)早在2016年11月初的 Crosswalk 22稳定版(Windows 及 Android 平台)即已加入对 WebAssembly 实验性的支持,开发者可以使用该版本体验 Angry Bots Demo。
### 开发者
WebAssembly 对于 Web 有显著的性能提升,对于开发者尤其是前端或者 JavaScript 开发人员而言,并不意味着 WebAssembly 将会取代 JavaScript (如图14所示)。
图14 WebAssembly 与JavaScript 引擎的关系
WebAssembly 被设计为对 JavaScript 的补充,而不是替代,是为了提供一种方法来获得应用程序的关键部分接近原生性能。随着时间的推移,虽然 WebAssembly 将允许多种语言(不仅仅是 C/C++)被编译到 Web,但是 JavaScript 的发展势头不会因此被削弱,并且仍然将保持 Web 的单一动态语言。此外,由于 WebAssembly 构建在 JavaScript 引擎的基础架构上,JavaScript 和 WebAssembly 将在许多场景中配合使用。
那么 WebAssembly 是不是仅仅面向 C/C++ 开发者呢?答案依旧是否定的。WebAssembly 最初实现的重点是 C/C++,由 Mozilla 主导开发的注重高效、安全和并行的 Rust 也能在2016年末被成功编译到 WebAssembly 了,未来还会继续增加其他语言的支持,见代码11。
在未来,通过 ES6 模块接口与 JavaScript 集成,Web 开发人员并不需要编写 C++,而是可以直接利用其他人编写的库,重用模块化 C++ 库可以像使用 JavaScript 中的 modules 一样简单。
### 进展
依据开发路线图,2016年10月31日,WebAssembly 到达浏览器预览的里程碑。Google Chrome V8 引擎及 Mozilla Firefox SpiderMonkey 引擎都已经在 trunk上 支持 WebAssembly 浏览器预览。2016年12月下旬,Microsoft Edge 浏览器使用的JavaScript 引擎 ChakraCore v1.4.0 启用了 WebAssembly 浏览器预览支持。而 Webkit JavaScript Core 引擎对于该支持也在积极进行中。
目前,WebAssembly 社区组已经有初始(MVP)二进制格式发布候选和 JavaScript API 在多个浏览器中实现。作为浏览器预览期间的一部分,WebAssembly 社区组(WebAssembly Community Group)现在正在征求更广泛的社区反馈。社区组的初步目标是浏览器预览在2017年第一季度结束,但在浏览器预览期间的重大发现可能会延长该周期。当浏览器预览结束时,社区组将产生 WebAssembly 的草案规范,并且浏览器厂商可以开始默认提供符合规范的实现。预计在2017年上半年,四大主流浏览器对原生的 WebAssembly 支持将到达稳定版。
具体到 Google V8 引擎的最新进展,asm.js 代码将不再通过 Turbofan JavaScript 编译器而是编译到 WebAssembly 后,在 WebAssembly 的原生执行环境中执行最终的机器码。这种改变带来的好处有,为 asm.js 将预先编译(AOT,Ahead Of Time Compilation)带到了 Chrome,且完全向后兼容。新的 WebAssembly 编译渠道重用了一些 Turbofan JavaScript 编译器后端部分,因此能够在少了很多编译和优化消耗的前提下,产生类似的代码。在 Google Chrome中,WebAssembly 将很快在 Canary 版中默认启用,开发团队也期望能够发布到2017年第一季度末的稳定版中。
### 社区
包含所有主要浏览器厂商代表的 W3C Web——Assembly社区组于2015年4月底成立。该小组的任务是,在编译到适用于 Web 的新的、便携的、大小和加载时间高效的格式上,促进早期的跨浏览器协作。该社区组也正在将 WebAssembly 设计为 W3C 开放标准。目前,除了文中所述主流浏览器厂商 Mozilla、Google、微软、及苹果公司之外,Opera CTO 及 Intel 的8位该领域专家均参与了该社区组。当然,并不是只有社区组成员才能参与标准的制定,任何人都可以在 https://github.com/ WebAssembly 做出贡献。
### 展望
由于主要的浏览器厂商对 WebAssembly 支持表现积极,并且都在实现 WebAssembly 的各项功能,因此在 Web 中高性能需求的应用例如在线游戏、音乐、视频流、AR/VR、平台模拟、虚拟机、远程桌面、压缩及加密等都能够获得接近于原生的性能。相信 WebAssembly 将会开创 Web 的新时代。