作者:胡子大哈
上一篇文章《WebAssembly是个什么鬼?》让大家基本了解了 WebAssembly ,接下来我们继续介绍WebAssembly 工作原理以及为什么 WebAssembly 运行的更快。
一、WebAssembly 工作原理
WebAssembly 是除了 JavaScript 以外,另一种可以在网页中运行的编程语言。过去如果你想在浏览器中运行代码来对网页中各种元素进行控制,只有 JavaScript 这一种选择。
所以当人们谈论 WebAssembly 的时候,往往会拿 JavaScript 来进行比较。但是它们其实并不是“二选一”的关系——并不是只能用 WebAssembly 或者 JavaScript。
实际上,我们鼓励开发者将这两种语言一起使用,即使你不亲自实现 WebAssembly 模块,你也可以学习它现有的模块,并它的优势来实现你的功能。
WebAssembly 模块定义的一些功能可以通过 JavaScript 来调用。所以就像你通过 npm 下载 lodash 模块并通过 API 使用它一样,未来你也可以下载 WebAssembly 模块并且使用其提供的功能。
那么,就让我们来看一下如何开发 WebAssembly 模块,以及如何通过 JavaScript 使用他们。
1. WebAssembly 处于哪个环节?
在上一篇关于WebAssembly 背景知识的文章中,我介绍了编译器是如何从高级语言翻译到机器码的。
那么在上图中,WebAssembly 在什么位置呢?实际上,你可以把它看成另一种“目标汇编语言”。
每一种目标汇编语言(x86、ARM)都依赖于特定的机器结构。当你想要把你的代码放到用户的机器上执行的时候,你并不知道目标机器结构是什么样的。
而 WebAssembly 与其他的汇编语言不一样,它不依赖于具体的物理机器。可以抽象地理解成它是概念机器的机器语言,而不是实际的物理机器的机器语言。
正因为如此,WebAssembly 指令有时也被称为虚拟指令。它比 JavaScript 代码更直接地映射到机器码,它也代表了“如何能在通用的硬件上更有效地执行代码”的一种理念。所以它并不直接映射成特定硬件的机器码。
浏览器把 WebAssembly 下载下来后,可以迅速地将其转换成机器汇编代码。
2. 编译到 .wasm 文件
目前对于 WebAssembly 支持情况最好的编译器工具链是 LLVM。有很多不同的前端和后端插件可以用在 LLVM 上。
提示:很多 WebAssembly 开发者用 C 语言或者 Rust 开发,再编译成 WebAssembly。其实还有其他的方式来开发 WebAssembly 模块。例如利用 TypeScript 开发 WebAssembly 模块,或者直接用文本格式的 WebAssembly 也可以。
假设想从 C 语言到 WebAssembly,我们就需要 clang 前端来把 C 代码变成 LLVM 中间代码。当变换成了 LLVM IR 时,说明 LLVM 已经理解了代码,它会对代码自动地做一些优化。
为了从 LLVM IR 生成 WebAssembly,还需要后端编译器。在 LLVM 的工程中有正在开发中的后端,而且应该很快就开发完成了,现在这个时间节点,暂时还看不到它是如何起作用的。
还有一个易用的工具,叫做 Emscripten。它通过自己的后端先把代码转换成自己的中间代码(叫做 asm.js),然后再转化成 WebAssembly。实际上它背后也是使用的 LLVM。
Emscripten 还包含了许多额外的工具和库来包容整个 C/C++ 代码库,所以它更像是一个软件开发者工具包(SDK)而不是编译器。例如系统开发者需要文件系统以对文件进行读写,Emscripten 就有一个 IndexedDB 来模拟文件系统。
不考虑太多的这些工具链,只要知道最终生成了 .wasm 文件就可以了。后面我会介绍 .wasm 文件的结构,在这之前先一起了解一下在 JS 中如何使用它。
3. 加载一个 .wasm 模块到 JavaScript
.wasm 文件是 WebAssembly 模块,它可以加载到 JavaScript 中使用,现阶段加载的过程稍微有点复杂。
- function fetchAndInstantiate(url, importObject) {
- return fetch(url).then(response =>
- response.arrayBuffer()
- ).then(bytes =>
- WebAssembly.instantiate(bytes, importObject)
- ).then(results =>
- results.instance
- );
- }
如果想深入了解,可以在 MDN 文档中了解更多。
我们一直在致力于把这一过程变得简单,对工具链进行优化。希望能够把它整合到现有的模块打包工具中,比如 webpack 中,或者整合到加载器中,比如 SystemJS 中。我们相信加载 WebAssembly 模块也可以像加载 JavaScript 一样简单。
这里介绍 WebAssembly 模块和 JavaScript 模块的主要区别。当前的 WebAssembly 只能使用数字(整型或者浮点型)作为参数或者返回值。
对于任何其他的复杂类型,比如 string,就必须得用 WebAssembly 模块的内存操作了。如果是经常使用 JavaScript,对直接操作内存不是很熟悉的话,可以回想一下 C、C++ 和 Rust 这些语言,它们都是手动操作内存。WebAssembly 的内存操作和这些语言的内存操作很像。
为了实现这个功能,它使用了 JavaScript 中称为 ArrayBuffer 的数据结构。ArrayBuffer 是一个字节数组,所以它的索引(index)就相当于内存地址了。
如果你想在 JavaScript 和 WebAssembly 之间传递字符串,可以利用 ArrayBuffer 将其写入内存中,这时候 ArrayBuffer 的索引就是整型了,可以把它传递给 WebAssembly 函数。此时,第一个字符的索引就可以当做指针来使用。
这就好像一个 web 开发者在开发 WebAssembly 模块时,把这个模块包装了一层外衣。这样其他使用者在使用这个模块的时候,就不用关心内存管理的细节。
如果你想了解更多的内存管理,看一下我们写的 WebAssembly 的内存操作。
4. .wasm 文件结构
如果你是写高级语言的开发者,并且通过编译器编译成 WebAssembly,那你不用关心 WebAssembly 模块的结构。但是了解它的结构有助于你理解一些基本问题。
如果你对编译器还不了解,建议先读一下“系列三之编译器如何生成汇编这篇文章。
这段代码是即将生成 WebAssembly 的 C 代码:
- int add42(int num) {
- return num + 42;
- }
你可以使用 WASM Explorer 来编译这个函数。
打开 .wasm 文件(假设你的编辑器支持的话),可以看到下面代码:
- 00 61 73 6D 0D 00 00 00 01 86 80 80 80 00 01 60
- 01 7F 01 7F 03 82 80 80 80 00 01 00 04 84 80 80
- 80 00 01 70 00 00 05 83 80 80 80 00 01 00 01 06
- 81 80 80 80 00 00 07 96 80 80 80 00 02 06 6D 65
- 6D 6F 72 79 02 00 09 5F 5A 35 61 64 64 34 32 69
- 00 00 0A 8D 80 80 80 00 01 87 80 80 80 00 00 20
- 00 41 2A 6A 0B
这是模块的“二进制”表示。之所以用引号把“二进制”引起来,是因为上面其实是用十六进制表示的,不过把它变成二进制或者人们能看懂的十进制表示也很容易。
例如,下面是 num + 42 的各种表示方法。
5. 代码是如何工作的:基于栈的虚拟机
如果你对具体的操作过程很好奇,那么这幅图可以告诉你指令都做了什么。
从图中我们可以注意到 加 操作并没有指定哪两个数字进行加。这是因为 WebAssembly 是采用“基于栈的虚拟机”的机制。即一个操作符所需要的所有值,在操作进行之前都已经存放在堆栈中。
所有的操作符,比如加法,都知道自己需要多少个值。加需要两个值,所以它从堆栈顶部取两个值就可以了。那么加指令就可以变的更短(单字节),因为指令不需要指定源寄存器和目的寄存器。这也使得 .wasm 文件变得更小,进而使得加载 .wasm 文件更快。
尽管 WebAssembly 使用基于栈的虚拟机,但是并不是说在实际的物理机器上它就是这么生效的。当浏览器翻译 WebAssembly 到机器码时,浏览器会使用寄存器,而 WebAssembly 代码并不指定用哪些寄存器,这样做的好处是给浏览器最大的自由度,让其自己来进行寄存器的最佳分配。
6. WebAssembly 模块的组成部分
除了上面介绍的,.wasm 文件还有其他部分。一些组成部分对于模块来讲是必须的,一些是可选的。
必须部分:
- Type。在模块中定义的函数的函数声明和所有引入函数的函数声明。
- Function。给出模块中每个函数一个索引。
- Code。模块中每个函数的实际函数体。
可选部分:
- Export。使函数、内存、表(tables)、全局变量等对其他 WebAssembly 或 JavaScript 可见,允许动态链接一些分开编译的组件,即 .dll 的WebAssembly 版本。
- Import。允许从其他 WebAssembly 或者 JavaScript 中导入指定的函数、内存、表或者全局变量。
- Start。当 WebAssembly 模块加载进来的时候,可以自动运行的函数(类似于 main 函数)。
- Global。声明模块的全局变量。
- Memory。定义模块用到的内存。
- Table。使得可以映射到 WebAssembly 模块以外的值,如映射到 JavaScript 的对象。这在间接函数调用时很有用。
- Data。初始化导入的或者局部内存。
- Element。初始化导入的或者局部的表。
如果你想了解关于这些组成部分的更深入的内容,可以阅读这些组成部分的工作原理。
二、为什么 WebAssembly 更快?
上面我介绍了如何编写 WebAssembly 程序,也表达了我希望看到更多的开发者在自己的工程中同时使用 WebAssembly 和 JavaScript 的期许。
开发者们不必纠结于到底选择 WebAssembly 还是 JavaScript,已经有了 JavaScript 工程的开发者们,希望能把部分 JavaScript 替换成 WebAssembly 来尝试使用。
例如,正在开发 React 程序的团队可以把协调性代码(即虚拟 DOM)替换成 WebAssembly 的版本。而对于你的 web 应用的用户来说,他们就跟以前一样使用,不会发生任何变化,同时他们还能享受到 WebAssembly 所带来的好处——快。
而开发者们选择替换为 WebAssembly 的原因正是因为 WebAssembly 比较快。
1. 当前的 JavaScript 性能如何?
在我们了解 JavaScript 和 WebAssembly 的性能区别之前,需要先理解 JS 引擎的工作原理。
下面这张图片介绍了性能使用的大概分布情况。
JS 引擎在图中各个部分所花的时间取决于页面所用的 JavaScript 代码。图表中的比例并不代表真实情况下的确切比例情况。
图中的每一个颜色条都代表了不同的任务:
- Parsing——表示把源代码变成解释器可以运行的代码所花的时间;
- Compiling + optimizing——表示基线编译器和优化编译器花的时间。一些优化编译器的工作并不在主线程运行,不包含在这里。
- Re-optimizing——当 JIT 发现优化假设错误,丢弃优化代码所花的时间。包括重优化的时间、抛弃并返回到基线编译器的时间。
- Execution——执行代码的时间
- Garbage collection——垃圾回收,清理内存的时间
这里注意:这些任务并不是离散执行的,或者按固定顺序依次执行的。而是交叉执行,比如正在进行解析过程时,其他一些代码正在运行,而另一些正在编译。
这样的交叉执行给早期 JavaScript 带来了很大的效率提升,早期的 JavaScript 执行类似于下图,各个过程顺序进行:
早期时,JavaScript 只有解释器,执行起来非常慢。当引入了 JIT 后,大大提升了执行效率,缩短了执行时间。
JIT 所付出的开销是对代码的监视和编译时间。JavaScript 开发者可以像以前那样开发 JavaScript 程序,而同样的程序,解析和编译的时间也大大缩短。这就使得开发者们更加倾向于开发更复杂的 JavaScript 应用。
同时,这也说明了执行效率上还有很大的提升空间。
2. WebAssembly 对比
下面是 WebAssembly 和典型的 web 应用的近似对比图:
各种浏览器处理上图中不同的过程,有着细微的差别,拿 SpiderMonkey 作为例子。
3. 文件获取
这一步并没有显示在图表中,但是这看似简单地从服务器获取文件这个步骤,却会花费很长时间。
WebAssembly 比 JavaScript 的压缩率更高,所以文件获取也更快。即便通过压缩算法可以显著地减小 JavaScript 的包大小,但是压缩后的 WebAssembly 的二进制代码依然更小。
这就是说在服务器和客户端之间传输文件更快,尤其在网络不好的情况下。
4. 解析
当到达浏览器时,JavaScript 源代码就被解析成了抽象语法树。
浏览器采用懒加载的方式进行,只解析真正需要的部分,而对于浏览器暂时不需要的函数只保留它的桩(stub,译者注:关于桩的解释可以在之前的文章中有提及)。
解析过后 AST (抽象语法树)就变成了中间代码(叫做字节码),提供给 JS 引擎编译。
而 WebAssembly 则不需要这种转换,因为它本身就是中间代码。它要做的只是解码并且检查确认代码没有错误就可以了。
5. 编译和优化
在关于 JIT 的文章中,我有介绍过,JavaScript 是在代码的执行阶段编译的。因为它是弱类型语言,当变量类型发生变化时,同样的代码会被编译成不同版本。
不同浏览器处理 WebAssembly 的编译过程也不同,有些浏览器只对 WebAssembly 做基线编译,而另一些浏览器用 JIT 来编译。
不论哪种方式,WebAssembly 都更贴近机器码,所以它更快,使它更快的原因有几个:
在编译优化代码之前,它不需要提前运行代码以知道变量都是什么类型。
编译器不需要对同样的代码做不同版本的编译。
很多优化在 LLVM 阶段就已经做完了,所以在编译和优化的时候没有太多的优化需要做。
6. 重优化
有些情况下,JIT 会反复地进行“抛弃优化代码<->重优化”过程。
当 JIT 在优化假设阶段做的假设,执行阶段发现是不正确的时候,就会发生这种情况。比如当循环中发现本次循环所使用的变量类型和上次循环的类型不一样,或者原型链中插入了新的函数,都会使 JIT 抛弃已优化的代码。
反优化过程有两部分开销。第一,需要花时间丢掉已优化的代码并且回到基线版本。第二,如果函数依旧频繁被调用,JIT 可能会再次把它发送到优化编译器,又做一次优化编译,这是在做无用功。
在 WebAssembly 中,类型都是确定了的,所以 JIT 不需要根据变量的类型做优化假设。也就是说 WebAssembly 没有重优化阶段。
7. 执行
自己也可以写出执行效率很高的 JavaScript 代码。你需要了解 JIT 的优化机制,例如你要知道什么样的代码编译器会对其进行特殊处理(JIT 文章里面有提到过)。
然而大多数的开发者是不知道 JIT 内部的实现机制的。即使开发者知道 JIT 的内部机制,也很难写出符合 JIT 标准的代码,因为人们通常为了代码可读性更好而使用的编码模式,恰恰不合适编译器对代码的优化。
加之 JIT 会针对不同的浏览器做不同的优化,所以对于一个浏览器优化的比较好,很可能在另外一个浏览器上执行效率就比较差。
正是因为这样,执行 WebAssembly 通常会比较快,很多 JIT 为 JavaScript 所做的优化在 WebAssembly 并不需要。另外,WebAssembly 就是为了编译器而设计的,开发人员不直接对其进行编程,这样就使得 WebAssembly 专注于提供更加理想的指令(执行效率更高的指令)给机器就好了。
执行效率方面,不同的代码功能有不同的效果,一般来讲执行效率会提高 10% – 800%。
8. 垃圾回收
JavaScript 中,开发者不需要手动清理内存中不用的变量。JS 引擎会自动地做这件事情,这个过程叫做垃圾回收。
可是,当你想要实现性能可控,垃圾回收可能就是个问题了。垃圾回收器会自动开始,这是不受你控制的,所以很有可能它会在一个不合适的时机启动。目前的大多数浏览器已经能给垃圾回收安排一个合理的启动时间,不过这还是会增加代码执行的开销。
目前为止,WebAssembly 不支持垃圾回收。内存操作都是手动控制的(像 C、C++一样)。这对于开发者来讲确实增加了些开发成本,不过这也使代码的执行效率更高。
9. 总结
WebAssembly 比 JavaScript 执行更快是因为:
- 文件抓取阶段,WebAssembly 比 JavaScript 抓取文件更快。即使 JavaScript 进行了压缩,WebAssembly 文件的体积也比 JavaScript 更小;
- 解析阶段,WebAssembly 的解码时间比 JavaScript 的解析时间更短;
- 编译和优化阶段,WebAssembly 更具优势,因为 WebAssembly 的代码更接近机器码,而 JavaScript 要先通过服务器端进行代码优化。
- 重优化阶段,WebAssembly 不会发生重优化现象。而 JS 引擎的优化假设则可能会发生“抛弃优化代码<->重优化”现象。
- 执行阶段,WebAssembly 更快是因为开发人员不需要懂太多的编译器技巧,而这在 JavaScript 中是需要的。WebAssembly 代码也更适合生成机器执行效率更高的指令。
- 垃圾回收阶段,WebAssembly 垃圾回收都是手动控制的,效率比自动回收更高。
这就是为什么在大多数情况下,同一个任务 WebAssembly 比 JavaScript 表现更好的原因。
但是,还有一些情况 WebAssembly 表现的会不如预期;同时 WebAssembly 的未来也会朝着使 WebAssembly 执行效率更高的方向发展。