## WebAssembly 初步探索
文 / 题叶,张翰
过去十年越来越多厂商基于浏览器提供软件服务,对于 JavaScript 引擎执行效率也越来越重视。在 Chrome 发布,借助 JIT(即时编译)优化后 JavaScript 性能得到了巨大的提升,然而由于动态类型的限制,某些部分难以很好地被优化,做不到媲美 Native 代码的效率。
出于对性能的追求,Google 和 Mozilla 分别做出 Native Client 和 asm.js 的技术尝试。asm.js 是 Mozilla 在2013年提出的,它通过 Emscripten 将 C/C++ 编译到包含类型信息的 JavaScript 子集,借助 JavaScript 引擎做进一步优化,可以实现接近 Native 的运行速度。然而 asm.js 问题在于,它的源代码以文本的形式传输和解析,这个过程效率不高。而且不同的引擎做了不同优化,asm.js 并没有做到跨浏览器一致的高性能实现。
### WebAssembly 简介
WebAssembly(或者 wasm)在2015年出现,它是一个可移植、体积小、加载快并且兼容 Web 的全新格式。wasm 以 asm.js 的工作为基础,设计了一套浏览器可执行的字节码,实现接近原生的运行效率。WebAssembly 遵循以下设计目标:
- 安全。Web 应用可以从任何网站打开,这就要求 wasm 做到内存安全,能阻止恶意代码通过浏览器盗取用户设备上的信息,甚至对系统进行破坏。
- 快速。要求 wasm 的性能接近 Native 代码,最大程度地发挥设备的计算能力。
- 可移植。要求 wasm 支持大量的硬件、操作系统、浏览器,并且具有一致的行为。
- 精简。wasm 为二进制的格式,相比 JavaScript 大幅减少了网络传输体积、降低了解析代码的开销。
WebAssembly 是一种编译目标,已有的 C/C++ 项目可以编译成 wasm 运行在浏览器上。其他支持 LLVM 的编程语言,比如 Rust,也可以编译成 wasm 运行在浏览器上。同时出现了一些新的语言,比如 ThinScript、TurboScript 等,可以编译成 wasm。随着 wasm 完善和扩展,会有更多编程语言加入队伍。
### 简单的例子
我们首先来看一个例子,它可以在支持 WebAssembly 的浏览器中运行:
代码中的 Uint8Array 就可以视为使用十六进制表示的 wasm 代码,通过 WebAssembly.compile 方法编译成 wasm 模块,接着使用编译成功的模块创建 wasm 实例。这时也可以传入第二个参数向模块注入 Memory、Table 等变量。然后在实例的 exports 属性中就可以获取到 wasm 模块定义的方法,可以和普通 JavaScript 方法一样使用。
概括地讲,代码的执行过程可以简化为图1所示。
图1 代码执行过程
### 编译和执行
在实际应用中,没人愿意直接手写二进制或者十六进制来创建 wasm 文件。wasm 作为一种编译目标,通常由其他高级语言编译而来,目前在支持编译成 wasm 文件的语言中 C/C++ 和 Rust 是相对比较成熟的。
#### 把 C/C++ 编译成 wasm
以 C 语言为例,在写好 C 代码以后,要想编译生成 wasm 文件,需要借助 Emscripten 等工具。工具的配置过程这里不再介绍,结果见图2。
图2 将 C 代码编译成 wasm 代码
可以看到 C 代码会被转成一个 wasm 模块,其中定义并且导出了 square 方法,保留了参数和返回值的类型声明。
图2中的 wast 是 WebAssembly 的一种文本格式,这里采用了S-表达式的写法,下文会再解释。wast 和 wasm 文件是可以互相等价转换的,通常工具并不会生成 wast 中间文件,这里为了方便理解,演示了与二进制结果等价的文本写法。
#### 使用 wasm 模块
刚才也提到过执行 wasm 代码可以分为编译模块、创建实例、获取接口三个步骤,如果将这个过程再稍微封装一下,可以写成如下形态:
上边的代码使用了 fetch 方法来获取 wasm 文件,然后将其转换成 ArrayBuffer,接着把其编译成模块并且创建实例,最终可以获取到 wasm 文件中定义的接口。
WebAssembly 没有定义任何平台相关的特性,如果想要使用平台接口(如 Web API),就必须在创建 wasm 实例的时候传入第二个参数,声明要传递的值、方法、Memory 或者 Table,而且需要在 wasm 模块内部也声明好支持传入的数据类型。
#### 在引擎中的解析过程
wasm 文件也是被 JavaScript 引擎编译执行的,复用了部分编译流程,但是编译效率和 JavaScript 相比有很大的提升。
首先以 V8 为例,看一下 JavaScript 的编译执行过程,见图3所示。
图3 V8 中,JavaScript 的编译过程
在浏览器获取到 JavaScript 文件之后,会解析代码文本生成 AST(抽象语法树),然后 Ignition(解释器)会将其解释成字节码,这是浏览器内部定义的中间码,然后 TurboFan(优化编译器)会将此中间码编译并优化生成可执行代码。由于 JavaScript 是动态类型的语言,如果在执行过程中某个变量的类型发生了变化,将会触发 Deoptimize 使部分优化后的代码失效,这时将会触发引擎的重新优化。
然而编译 wasm 的过程就更简单一些,见图4所示。
图4 wasm 的编译过程更简单
由于 wasm 文件本身就是二进制文件,只需要简单解码就可以转化成字节码,而且它的格式是在规范中定义了的,可以跨浏览器通用的,然后就可以复用优化编译器的部分功能将字节码编译成可执行代码。
由于编译方法的差异,wasm 和 JavaScript 相比有如下优势:
- wasm 文件通常比 JavaScript 源码文件要小得多,网络加载速度快。
- 解码二进制文件的速度比解析文本生成 AST 再解释成中间码的速度要快很多,甚至能快二十倍。
- wasm 不会因为类型改变而触发 Deoptimize,有利于优化。
### 技术细节
wasm 本身是二进制格式,同时提供了对应的文本格式(wast)帮助开发者调试和语言开发者使用。文本格式在一定程度上保留了代码的可读性,普通开发者一般只会在调试中遇到 wasm 的文本格式,对于编程语言的开发者来说,理解 wasm 指令是必要的工作。
#### 基本概念
值得注意的是,wasm 的线性内存和 code space 以及 execution stack 是分隔开的,wasm 模块只能访问自身的内存空间,而不能访问任意的内存进行危险的操作,或者对其他的进程造成影响。那么 wasm 引擎被集成到其他的运行环境当中,不会影响宿主环境的安全性。
表1 wasm 基本概念注解
#### 文本格式
WebAssembly 当中的一个模块,对应的文本格式看起来像是这样,下面这个例子当中定义了`$add`函数并将其导出:
可以看到 wasm 中函数带着类型签名,它的返回值是 i32 类型的。中间的函数体不再是 S 表达式,而是基于 Stack Machine 的一些指令:
- 两个函数参数默认就是局部变量`$lhs、$rhs`;
- `get_local $lhs`从局部变量中取出`$lhs`,放进 Stack;
- `get_local $rhs`取出`$rhs`,放进 Stack。这时 Stack 有两个数据;
- i32.add 取出两个数据进行计算,把结果放回 Stack;
- 函数尝试返回 i32 时使用 Stack 中唯一一个数据。
这样就得到了两个 i32 类型的参数的计算结果。
关于 wasm 的文本格式,早期文档采用的是基于 AST 的写法,考虑到引擎的性能和便利,调整到现在基于 Stack Machine 的 post-order encoding 写法。调整之后 wasm 代码 decode 和 verify 的性能得到了巨大提升。
图5可以看到高级语言代码与 wasm 采用的 Stack Machine 的指令写法的区别和联系。
图5 高级语言与 wasm 在代码写法上的区别对比
高级语言中的 while 结构在 wasm 当中被更底层的 loop、`br_if`、br 指令所替代。
### 技术特点
#### 和 JavaScript 的关系
WebAssembly 并不会取代 JavaScript,而是会补足 JavaScript 在计算能力上的短板。
WebAssembly 的其中一个设计目标也是兼容现有的 Web 技术,并不颠覆原有的开发体验。目前标准中只设计了 JavaScript 接口来编译 wasm 模块,换句话说,目前 wasm 文件无法单独运行,只能通过 JavaScript 脚本来加载和编译,然后才能调用其中定义的接口。
目前也没有工具能直接把 JavaScript 代码编译成 wasm。由于 JavaScript 是动态类型语言,至少要加上一些语法限制才有机会编译成 wasm;例如 asm.js 虽然可以编译成 wasm,但是它是 JavaScript 语法的子集,使用了一系列底层语法来标注类型,也是作为一种编译目标而设计的,同样不适合在开发时使用。
#### 优缺点分析
**优势**
WebAssembly 不仅得到了 W3C 标准的支持,而且 Chrome、Edge、Firefox 和 Safari 等浏览器厂商从一开始就都参与了规范的设计和讨论,对第一版的特性已经达成了共识,在各自浏览器中都实现了该特性。有标准的支撑和主流浏览器厂商的支持,WebAssembly 的技术生命力将会很持久。
wasm 文件不仅体积小,解码的速度也特别快,具有明显的性能优势。它对现有 Web 技术没有破坏性,可以很简单地融入现有 Web 技术中,也天生具有动态化和跨平台的能力,最新发布的 Node.js 8.0.0 也默认支持了 WebAssembly 的特性。
WebAssembly 支持将多种语言编译成 wasm 模块,对于很多已有的 C/C++ 或者 Rust 的程序库,可以很容易得移植到 Web 平台上来。它拓宽了 Web 应用的边界,开辟了新的开发途径。
**劣势**
WebAssembly 目前还出于起步阶段,浏览器兼容性也不够好,第一版的特性不够全。它目前无法直接操作 DOM 等 Web API,只能通过 JavaScript API 向 wasm 模块传递数据和方法;它第一版中也没有提供 GC(垃圾回收)指令,这使得很多依赖 GC 的编程语言编译到 wasm 时受到很大阻力。
此外目前 WebAssembly 的开发体验很差,靠谱的工程实践也比较少。无论是 C/C++ 还是 Rust,将源码编译成 wasm 文件的过程都比较繁琐;而且浏览器调试工具的功能也有限,程序源码很难阅读。整体来讲,短期内 WebAssembly 的开发效率并不高,大部分实践都是将现有程序库编译到 Web 平台。
对于在移动端或者 Node.js 原生模块依赖的 C/C++ 程序库,是能够直接编译成平台支持的二进制代码的,如果把这部分功能编译成 wasm 再交给 JavaScript 引擎来执行,它的性能未必有提升。
#### 使用场景
首先,WebAssembly 适合开发模块,而不适合开发完整的应用。它很难实现 Web 开发中各种复杂的逻辑,而且它本身的设计理念也是补足 JavaScript 在计算性能上的劣势而非独立实现 Web 开发。它比较适合写计算密集型的工具库,或者用于实现框架中部分复杂计算的功能,例如图像处理、视音频处理、机器学习算法、加密算法、游戏引擎、AR/VR 计算框架等技术,短期内可以将这些现有的功能编译成 wasm 然后移植到 Web 平台中。像 three.js 这类框架也可以把内部矩阵运算的部分用 WebAssembly 实现,上层保持原有 JavaScript 接口,也是一种比较好的实践场景。
WebAssembly 不适合编写接口封装型和策略型的库或框架。因为在封装平台接口或语言特性的库中,会涉及大量的格式转换和数据校验,在策略型框架中,通常会包含很多逻辑处理、事件响应,也会存储很多中间状态,有些还会依赖 JavaScript 的语言特性,WebAssembly 并不擅长这方面的处理,性能上也未必会有优势。
### 结语
WebAssembly 对于 Web 平台只是增强而不是颠覆,并不是所有 Web 技术都能与它结合,应用场景有限,这项技术在短期内很有可能会被高估;但是从长期来看,它拓宽了 Web 应用的边界,开辟了新的开发途径,对周边一系列技术潜移默化的影响力不容小觑。