悄悄掀起 WebAssembly 的神秘面纱

7,282 阅读15分钟

前端开发人员想必对现代浏览器都已经非常熟悉了吧?HTML5,CSS4,JavaScript ES6,这些已经在现代浏览器中慢慢普及的技术为前端开发带来了极大的便利。

得益于 JIT(Just-in-time)技术,JavaScript 的运行速度比原来快了 10 倍,这也是 JavaScript 被运用得越来越广泛的原因之一。但是,这是极限了吗?

随着浏览器技术的发展,Web 游戏眼看着又要“卷土重来”了,不过这一次不是基于 Flash 的游戏,而是充分利用了现代 HTML5 技术实现。JavaScript 成为了 Web 游戏的开发语言,但是对于游戏这样需要大量运算的程序来说,即便是有 JIT 加持,JavaScript 的性能还是不能满足人类贪婪的欲望。

JavaScript 在浏览器中是怎么跑起来的?

对于现在的计算机来说,它们只能读懂“机器语言”,而人类的大脑能力有限,直接编写机器语言难度有点大,为了能让人更方便地编写程序,人类发明了大量的“高级编程语言”,JavaScript 就属于其中特殊的一种。

为什么说是特殊的一种呢?由于计算机并不认识“高级编程语言”写出来的东西,所以大部分“高级编程语言”在写好以后都需要经过一个叫做“编译”的过程,将“高级编程语言”翻译成“机器语言”,然后交给计算机来运行。但是,JavaScript 不一样,它没有“编译”的过程,那么机器是怎么认识这种语言的呢?

实际上,JavaScript 与其他一部分脚本语言采用的是一种“边解释边运行”的姿势来运行的,将代码一点一点地翻译给计算机。

那么,JavaScript 的“解释”与其他语言的“编译”有什么区别呢?不都是翻译成“机器语言”吗?简单来讲,“编译”类似于“全文翻译”,就是代码编写好后,一次性将所有代码全部编译成“机器语言”,然后直接交给计算机;而“解释”则类似于“实时翻译”,代码写好后不会翻译,运行到哪,翻译到哪。

“解释”和“编译”两种方法各有利弊。使用“解释”的方法,程序编写好后就可以直接运行了,而使用“编译”的方法,则需要先花费一段时间等待整个代码编译完成后才可以执行。这样一看似乎是“解释”的方法更快,但是如果一段代码要执行多次,使用“解释”的方法,程序每次运行时都需要重新“解释”一遍,而“编译”的方法则不需要了。这样一看,“编译”的整体效率似乎更高,因为它永远只翻译一次,而“解释”是运行一次翻译一次。并且,“编译”由于是一开始就对整个代码进行的,所以可以对代码进行针对性的优化。

JavaScript 是使用“解释”的方案来运行的,这就造成了它的效率低下,因为代码每运行一次都要翻译一次,如果一个函数被循环调用了 10 次、100 次,这个执行效率可想而知。

好在聪明的人类发明了 JIT(Just-in-time)技术,它综合了“解释”与“编译”的优点,它的原理实际上就是在“解释”运行的同时进行跟踪,如果某一段代码执行了多次,就会对这一段代码进行编译优化,这样,如果后续再运行到这一段代码,则不用再解释了。

JIT 似乎是一个好东西,但是,对于 JavaScript 这种动态数据类型的语言来说,要实现一个完美的 JIT 非常难。为什么呢?因为 JavaScript 中的很多东西都是在运行的时候才能确定的。比如我写了一行代码:const sum = (a, b, c) => a + b + c;,这是一个使用 ES6 语法编写的 JavaScript 箭头函数,可以直接放在浏览器的控制台下运行,这将声明一个叫做 sum 的函数。然后我们可以直接调用它,比如:console.log(sum(1, 2, 3)),任何一个合格的前端开发人员都能很快得口算出答案,这将输出一个数字 6。但是,如果我们这样调用呢:console.log(sum('1', 2, 3)),第一个参数变成了一个字符串,这在 JavaScript 中是完全允许的,但是这时得到的结果就完全不同了,这会导致一个字符串和两个数字进行连接,得到 "123"。这样一来,针对这一个函数的优化就变得非常困难了。

虽说 JavaScript 自身的“特性”为 JIT 的实现带来了一些困难,但是不得不说 JIT 还是为 JavaScript 带来了非常可观的性能提升。

WebAssembly

为了能让代码跑得更快,WebAssembly 出现了(并且现在主流浏览器也都开始支持了),它能够允许你预先使用“编译”的方法将代码编译好后,直接放在浏览器中运行,这一步就做得比较彻底了,不再需要 JIT 来动态得进行优化了,所有优化都可以在编译的时候直接确定。

WebAssembly 到底是什么呢?

首先,它不是直接的机器语言,因为世界上的机器太多了,它们都说着不同的语言(架构不同),所以很多情况下都是为各种不同的机器架构专门生成对应的机器代码。但是要为各种机器都生成的话,太复杂了,每种语言都要为每种架构编写一个编译器。为了简化这个过程,就有了“中间代码(Intermediate representation,IR)”,只要将所有代码都翻译成 IR,再由 IR 来统一应对各种机器架构。

实际上,WebAssembly 和 IR 差不多,就是用于充当各种机器架构翻译官的角色。WebAssembly 并不是直接的物理机器语言,而是抽象出来的一种虚拟的机器语言。从 WebAssembly 到机器语言虽说也需要一个“翻译”过程,但是在这里的“翻译”就没有太多的套路了,属于机器语言到机器语言的翻译,所以速度上已经非常接近纯机器语言了。

这里有一个 WebAssembly 官网上提供的 Demo,是使用 Unity 开发并发布为 WebAssembly 的一个小游戏:webassembly.org/demo/,可以去体验体验。

.wasm 文件 与 .wat 文件

WebAssembly 是通过 *.wasm 文件进行存储的,这是编译好的二进制文件,它的体积非常的小。

在浏览器中,提供了一个全局的 window.WebAssembly 对象,可以用于实例化 WASM 模块。

window.WebAssembly

WebAssembly 是一种“虚拟机器语言”,所以它也有对应的“汇编语言”版本,也就是 *.wat 文件,这是 WebAssembly 模块的文本表示方法,采用“S-表达式(S-Expressions)”进行描述,可以直接通过工具将 *.wat 文件编译为 *.wasm 文件。熟悉 LISP 的同学可能对这种表达式语法比较熟悉。

一个非常简单的例子

我们来看一个非常简单的例子,这个已经在 Chrome 69 Canary 和 Chrome 70 Canary 中测试通过,理论上可以在所有已经支持 WebAssembly 的浏览器中运行。(在后文中有浏览器的支持情况)

首先,我们先使用 S-表达式 编写一个十分简单的程序:

;; test.wat
(module
  (import "env" "mem" (memory 1)) ;; 这里指定了从 env.mem 中导入一个内存对象
  (func (export "get") (result i32)  ;; 定义并导出一个叫做“get”的函数,这个函数拥有一个 int32 类型的返回值,没有参数
    memory.size))  ;; 最终返回 memory 对象的“尺寸”(单位为“页”,目前规定 1 页 = 64 KiB = 65536 Bytes)

可以使用 wabt 中的 wasm2wat 工具将 wasm 文件转为使用“S-表达式”进行描述的 wat 文件。同时也可以使用 wat2wasm 工具将 wat 转为 wasm。

在 wat 文件中,双分号 ;; 开头的内容都是注释。

上面这个 wat 文件定义了一个 module,并导入了一个内存对象,然后导出了一个叫做“get”的函数,这个函数返回当前内存的“尺寸”。

在 WebAssembly 中,线性内存可以在内部直接定义然后导出,也可以从外面导入,但是最多只能拥有一个内存。这个内存的大小并不是固定的,只需要给一个初始大小 initial,后期还可以根据需要调用 grow 函数进行扩展,也可以指定最大大小 maximum(这里所有内存大小的单位都是“页”,目前规定的是 1 页 = 64 KiB = 65536 Bytes。)

上面这个 wat 文件使用 wat2wasm 编译为 wasm 后生成的文件体积非常小,只有 50 Bytes:

$ wat2wasm test.wat
$ xxd test.wasm
00000000: 0061 736d 0100 0000 0105 0160 0001 7f02  .asm.......`....
00000010: 0c01 0365 6e76 036d 656d 0200 0103 0201  ...env.mem......
00000020: 0007 0701 0367 6574 0000 0a06 0104 003f  .....get.......?
00000030: 000b                                     ..

为了让这个程序能在浏览器中运行,我们还必须使用 JavaScript 编写一段“胶水代码(glue code)”,以便这个程序能被加载到浏览器中并执行:

// main.js

const file = await fetch('./test.wasm');
const memory = new window.WebAssembly.Memory({ initial: 1 });
const mod = await window.WebAssembly.instantiateStreaming(file, {
  env: {
    mem: memory,
  },
});
let result;
result = mod.instance.exports.get();  // 调用 WebAssembly 模块导出的 get 函数
console.log(result);  // 1
memory.grow(2);
result = mod.instance.exports.get();  // 调用 WebAssembly 模块导出的 get 函数
console.log(result);  // 3

这里我使用了现代浏览器都已经支持的 ES6 语法,首先,使用浏览器原生提供的 fetch 函数加载我们编译好的 test.wasm 文件。注意,这里根据规范,HTTP 响应的 Content-Type 中指定的 MIME 类型必须为 application/wasm

接下来,我们 new 了一个 WebAssembly.Memory 对象,通过这个对象,可以实现 JavaScript 与 WebAssembly 之间互通数据。

再接下来,我们使用了 WebAssembly.instantiateStreaming 来实例化加载的 WebAssembly 模块,这里第一个参数是一个 Readable Stream,第二个参数是 importObject,用于指定导入 WebAssembly 的结构。因为上面的 wat 代码中指定了要从 env.mem 导入一个内存对象,所以这里就得要将我们 new 出来的内存对象放到 env.mem 中。

WebAssembly 还提供了一个 instantiate 函数,这个函数的第一个参数可以提供一个 ArrayBuffer 或是 TypedArray。但是这个函数是不推荐使用的,具体原因做过流量代理转发的同学可能会比较清楚,这里就不具体解释了。

最后,我们就可以调用 WebAssembly 导出的函数 get 了,首先输出的内容为 memoryinitial 的值。然后我们调用了 memory.grow 方法来增长 memory 的尺寸,最后输出的内容就是增长后内存的大小 1 + 2 = 3

一个 WebAssembly 与 JavaScript 数据互通交互的例子

在 WebAssembly 中有一块内存,这块内存可以是内部定义的,也可以是从外面导入的,如果是内部定义的,则可以通过 export 进行导出。JavaScript 在拿到这块“内存”后,是拥有完全操作的权利的。JavaScript 使用 DataViewMemory 对象进行包装后,就可以使用 DataView 下面的函数对内存对象进行读取或写入操作。

这里是一个简单的例子:

;; example.wat
(module
  (import "env" "mem" (memory 1))
  (import "js" "log" (func $log (param i32)))
  (func (export "example")
    i32.const 0
    i64.const 8022916924116329800
    i64.store
    (i32.store (i32.const 8) (i32.const 560229490))
    (call $log (i32.const 0))))

这个代码首先从 env.mem 导入一个内存对象作为默认内存,这和前面的例子是一样的。

然后从 js.log 导入一个函数,这个函数拥有一个 32 位整型的参数,不需要返回值,在 wat 内部被命名为“$log”,这个名字只存在于 wat 文件中,在编译为 wasm 后就不存在了,只存储一个偏移地址。

后面定义了一个函数,并导出为“example”函数。在 WebAssembly 中,函数里的内容都是在栈上的。

首先,使用 i32.const 0 在栈内压入一个 32 位整型常数 0,然后使用 i64.const 8022916924116329800 在栈内压入一个 64 位整型常数 8022916924116329800,之后调用 i64.store 指令,这个指令将会将栈顶部第一个位置的一个 64 位整数存储到栈顶部第二个位置指定的“内存地址”开始的连续 8 个字节空间中。

简而言之,就是在内存的第 0 个位置开始的连续 8 个字节的空间里,存入一个 64 位整型数字 8022916924116329800。这个数字转为 16 进制表示为:0x 6f 57 20 6f 6c 6c 65 48,但是由于 WebAssembly 中规定字节序是使用“小端序(Little-Endian Byte Order)”来存储数据,所以,在内存中第 0 个位置存储的是 0x48,第 1 个位置存储的是 0x65……所以,最终存储的实际上是 0x 48 65 6c 6c 6f 20 57 6f,对应着 ASCII 码为:"Hello Wo"。

然后,后面的一句指令 (i32.store (i32.const 8) (i32.const 560229490)) 的格式是上面三条指令的“S-表达式”形式,只不过这里换成了 i32.store 来存储一个 32 位整型常数 560229490 到 8 号“内存地址”开始的连续 4 个字节空间中。

实际上这一句指令的写法写成上面三句的语法是完全等效的:

i32.const 8
i32.const 560229490
i32.store

类似的,这里是在内存的第 8 个位置开始的连续 4 个字节的空间里,存入一个 32 位整型数字 560229490。这个数字转为 16 进制表示位:0x 21 64 6c 72,同样采用“小端序”来存储,所以存储的实际上是 0x 72 6c 64 21,对应着 ASCII 码为:"rld!"。

所以,最终,内存中前 12 个字节中的数据为 0x 48 65 6c 6c 6f 20 57 6f 72 6c 64 21,连起来就是对应着 ASCII 码:"Hello World!"。

将这个 wat 编译为 wasm 后,文件大小为 95 Bytes:

$ wat2wasm example.wat
$ xxd example.wasm
00000000: 0061 736d 0100 0000 0108 0260 017f 0060  .asm.......`...`
00000010: 0000 0215 0203 656e 7603 6d65 6d02 0001  ......env.mem...
00000020: 026a 7303 6c6f 6700 0003 0201 0107 0b01  .js.log.........
00000030: 0765 7861 6d70 6c65 0001 0a23 0121 0041  .example...#.!.A
00000040: 0042 c8ca b1e3 f68d c8ab ef00 3703 0041  .B..........7..A
00000050: 0841 f2d8 918b 0236 0200 4100 1000 0b    .A.....6..A....

接下来,还是使用 JavaScript 编写“胶水代码”:

// example.js

const file = await fetch('./example.wasm');
const memory = new window.WebAssembly.Memory({ initial: 1 });
const dv = new DataView(memory);
const log = offset => {
  let length = 0;
  let end = offset;
  while(end < dv.byteLength && dv.getUint8(end) > 0) {
    ++length;
    ++end;
  }
  if (length === 0) {
    console.log('');
    return;
  }
  const buf = new ArrayBuffer(length);
  const bufDv = new DataView(buf);
  for (let i = 0, p = offset; p < end; ++i, ++p) {
    bufDv.setUint8(i, dv.getUint8(p));
  }
  const result = new TextDecoder('utf-8').decode(buf);
  console.log(result);
};
const mod = await window.WebAssembly.instantiateStreaming(file, {
  env: {
    mem: memory,
  },
  js: { log },
});
mod.instance.exports.example();  // 调用 WebAssembly 模块导出的 example 函数

这里,使用 DataViewmemory 进行了一次包装,这样就可以方便地对内存对象进行读写操作了。

然后,这里在 JavaScript 中实现了一个 log 函数,函数接受一个参数(这个参数在上面的 wat 中指定了是整数型)。下面的实现首先是确定输出的字符串长度(字符串通常以 '\0' 结尾),然后将字符串复制到一个长度合适的 ArrayBuffer 中,然后使用浏览器中的 TextDecoder 类对其进行字符串解码,就得到了原始字符串。

最后,将 log 函数放入 importObject 的 js.log 中,实例化 WebAssembly 模块,最后调用导出的 example 函数,就可以看到打印的 Hello World

Example - Hello World!

通过 WebAssembly,我们可以将很多其他语言编写的类库直接封装到浏览器中运行,比如 Google Developers 就给了一个使用 WebAssembly 加载一个使用 C 语言编写的 WebP 图片编码库,将一张 jpg 格式的图片转换为 webp 格式并显示出来的例子:developers.google.com/web/updates…

这个例子使用 Emscripten 工具对 C 语言代码进行编译,这个工具在安装的时候需要到 GitHub、亚马逊 S3 等服务器下载文件,在国内这神奇的网络环境下速度异常缓慢,总共几十兆的文件可能挂机一天都下不完。可以尝试修改 emsdk 文件(Python),增加代理配置(但是效果不明显),或是在下载的过程中会提示下载链接和存放路径,使用其他工具下载后放到指定地方,重新安装会自动跳过已经下载的文件。

WebAssembly 的现状与未来

目前 WebAssembly 的二进制格式版本已经确定,未来的改进也都将以兼容的形式进行更新,这表示 WebAssembly 已经进入现代标准了。

浏览器兼容性

现在的 WebAssembly 还并不完美,虽说已经有使用 WebAssembly 开发的 Web 游戏出现了,但是还有很多不完美的地方。

比如,现在的 WebAssembly 还必须配合“JavaScript glue code”来使用,也就是必须使用 JavaScript 来 fetch WebAssembly 的文件,然后调用 window.WebAssembly.instantiatewindow.WebAssembly.instantiateStreaming 等函数进行实例化。部分情况下还需要 JavaScript 来管理堆栈。官方推荐的编译工具 Emscripten 虽然使用了各种黑科技来缩小编译后生成的代码的数量,但是最终生成的 JavaScript Glue Code 文件还是至少有 15K。

未来,WebAssembly 将可能直接通过 HTML 标签进行引用,比如:<script src="./wa.wasm"></script>;或者可以通过 JavaScript ES6 模块的方式引用,比如:import xxx from './wa.wasm';

线程的支持,异常处理,垃圾收集,尾调用优化等,都已经加入 WebAssembly 的计划列表中了。

小结

WebAssembly 的出现,使得前端不再只能使用 JavaScript 进行开发了,C、C++、Go 等等都可以为浏览器前端贡献代码。

这里我使用 wat 文件来编写的两个例子仅供参考,实际上在生产环境不大可能直接使用 wat 来进行开发,而是会使用 C、C++、Go 等语言编写模块,然后发布为 WebAssembly。

WebAssembly 的出现不是要取代 JavaScript,而是与 JavaScript 相辅相成,为前端开发带来一种新的选择。将计算密集型的部分交给 WebAssembly 来处理,让浏览器发挥出最大的性能!


文 / jinliming2

一条对新鲜事物充满了好奇心的咸鱼

编 / 荧声

本文已由作者授权发布,版权属于创宇前端。欢迎注明出处转载本文。本文链接:knownsec-fed.com/2018-08-08-…

想要看到更多来自知道创宇开发一线的分享,请搜索关注我们的微信公众号:创宇前端(KnownsecFED)。欢迎留言讨论,我们会尽可能回复。

感谢您的阅读。