悄悄掀起 www.2003.comWebAssembly 的神秘面纱

2019-05-02 作者:计算机教程   |   浏览(147)

走近 WebAssembly 之调试大法

2018/04/26 · JavaScript · webassembly

原文出处: 周志鹏博客   

悄悄掀起 WebAssembly 的神秘面纱

2018/09/05 · JavaScript · webassembly

原文出处: WebAssembly   

www.2003.com 1

前端开发人员想必对现代浏览器都已经非常熟悉了吧?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 or wasm is a new portable, size- and load-time-efficient format suitable for compilation to the web.

关键词:”format”,WebAssembly 是一种编码格式,适合编译到web上运行。

事实上,WebAssembly可以看做是对JavaScript的加强,弥补JavaScript在执行效率上的缺陷。

  • 它是一个新的语言,它定义了一种AST,并可以用字节码的格式表示。
  • 它是对浏览器的加强,浏览器能够直接理解WebAssembly并将其转化为机器码。
  • 它是一种目标语言,任何其他语言都可以编译成WebAssembly在浏览器上运行。

想象一下,在计算机视觉,游戏动画,视频编解码,数据加密等需要需要高计算量的领域,如果想在浏览器上实现,并跨浏览器支持,唯一能做的就是用JavaScript来运行,这是一件吃力不讨好的事情。而WebAssembly可以将现有的用C,C 编写的库直接编译成WebAssembly运行到浏览器上, 并且可以作为库被JavaScript引用。那就意味着我们可以将很多后端的工作转移到前端,减轻服务器的压力。这是WebAssembly最为吸引人的特性。并且WebAssembly是运行于沙箱中,保证了其安全性。

更多关于WebAssembly基础入门, 可以看下这篇文章:https://segmentfault.com/a/1190000012110615, 写得很详细。

(后文将主要使用wasm 名称表示 WebAssembly)

WebAssembly

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

WebAssembly 到底是什么呢?

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

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

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

怎么调试?

稍微了解javascript 的人应该知道,在chrome或者firefox的开发者面板中可以很方便对js代码加断点、查看变量、单步执行等等,非常方便!

既然wasm主要是运行在web浏览器上的(当然也可以在非web环境中运行,参见官方文档描述:http://webassembly.org/docs/non-web/),主要的调试方式无非是使用开发者控制台了!

但问题在于上文中说了 wasm 是一种二进制格式,虽然有可读的文本格式wast,但是调试起来依然比较费劲,最主要的问题是:你是不是更想能够调试被编译之前的c/c  源代码?

.wasm 文件 与 .wat 文件

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

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

www.2003.com 2

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)

1
2
3
4
5
;; 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 ..

1
2
3
4
5
6
$ 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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

环境&工具准备

说明:如果你想定制自己的wasm编译环境docker镜像,强烈建议在ubuntu中参考官方文档步骤搭建: http://webassembly.org/getting-started/developers-guide/

一个 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))))

1
2
3
4
5
6
7
8
9
10
;; 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 个字节空间中。

TL; DR; 简而言之,就是在内存的第 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

1
2
3
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....

1
2
3
4
5
6
7
8
$ 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 编写“胶水代码”:

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 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 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 中指定了是整数型)。下面的实现首先是确定输出的字符串长度(字符串通常以 '' 结尾),然后将字符串复制到一个长度合适的 ArrayBuffer 中,然后使用浏览器中的 TextDecoder 类对其进行字符串解码,就得到了原始字符串。

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

www.2003.com 3

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

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

本文由www.2003.com发布于计算机教程,转载请注明出处:悄悄掀起 www.2003.comWebAssembly 的神秘面纱

关键词: