webassembly 基础

1. 什么是 webassembly?

从历史角度讲,虚拟机过去只能加载 JavaScript。这对我们而言足够了,因为 JavaScript 足够强大从而能够解决人们在当今网络上遇到的绝大部分问题。尽管如此,当试图把 JavaScript 应用到诸如 3D 游戏、虚拟现实、增强现实、计算机视觉、图像/视频编辑以及大量的要求原生性能的其他领域的时候,我们将会遇到性能问题(上面抄自 mdn)。

webassembly 我们从语意上分析是 web + assembly,就是在 web 上应用的汇编语言,它的的出现并不是代替 javascript,我认为他们两个是互补的关系,在强计算的部分用 webassembly,而与页面的交互可以利用 javascript 的简单和易用性。

webassembly 是继 css,html,javascript 之外的第四个 w3c 标准,webassembly 的出现并不是希望开发者直接使用 webassembly 来编写代码,而是希望开发者能够将其他的高级语言例如 C/C++/Rust 编译成 webassembly。 从目前的发展来看,所有的浏览器和 node.js(甚至 v8)中都已经拥有 webassembly 的虚拟机了,也就是在这些环境下都支持 webassembly 运行。

2. 为什么用 webassembly

1.使 “原生” 模块不那么复杂

运行时 (例如 Node 或 Python 的 CPython) 通常允许你使用低级语言 (例如 C++) 编写模块。这是因为这些低级语言的运行速度通常要快得多。因此,你可以在 Node 中使用本地模块,或者在 Python 中使用扩展模块。但是这些模块通常很难用,因为它们需要在用户设备上进行编译。借助 WebAssembly 的 “原生” 模块,你可以获得差不多的速度而规避复杂化。

2.更容易的沙箱化运行原生代码

另一方面,类似于 Rust 这样的低级语言不会指望 WebAssembly 来提升运行速度。但他们会为了安全性使用 WebAssembly。正如我们在 WASI 公告中所讨论的那样,WebAssembly 默认为你提供轻量级沙箱。因此,像 Rust 这样的语音可以通过 WebAssembly 来沙箱化原生代码模块。

3.跨平台共享原生代码

如果开发人员可以跨不同平台 (例如,在 Web 和桌面应用程序) 共享同一代码库,则可以节省开发时间并降低维护成本。脚本语言和低级语言都是如此。WebAssembly 为你提供了一种在不降低这些平台性能的前提下实现此目标的方法。

3. webassembly vs javascript

js 运行速度的发展如下图,在 2008 年的时候,引入了 JIT 技术,这使得 javascript 的速度有将近 10 倍的提升,这使得我们可以用 js 来写 server 端的内容。而在 2017 年,webassembly 出现了,这将又是一个转折点

3.1 v8 中的 JIT(just in time)

要说到 webassembly 为什么快,要先说下 javascript 的运行原理和 JIT 技术,众所周知的是 javascript 是一门高级语言,如果想要让机器看懂这门语言,你需有一个“翻译器”----Interpreter 或者是 Compiler 来将高级语言翻译成机器语言,简单理解 interpreter 是一个实时翻译机,像是同声传译,而 compiler 是将高级语言提前全部翻译好,再交给机器,javascript 是一门 interpreted language,意味着这个翻译过程是“on the fly”的,一行一行翻译,一行一行执行。而像 c++这种语言是利用 compiler 来提前翻译好,再运行。

Interpreter 优点:那么这两种翻译模式肯定是各有好坏,“实时翻译”的好处就是启动快,因为你不需要提前编译它,可以边走边唠(可能这就是大部分开发者觉得 js 简单的原因之一吧,你在浏览器打个 1 + 1 就能快速得出结果)

Interpreter 缺点:但是坏处就是比方说你有一个 for 循环,意味着你要一遍一遍的翻译同一行代码,做同样的事,而无法做一些优化。

Compiler 优点: “提前翻译”的好处当然就是因为我们提前翻译好了,在运行之前可以将代码做下优化,这样就加快了运行时候的速度。

Compiler 缺点: 坏处当然就是启动太慢了,要先编译。

Compiler optimization

那么 JIT 技术就是在原来 JS 只有 Interpreter 技术之上加入了 Compiler,作为成年人我不选择,我两个都要!在用 interpreter 实时翻译的过程当中加入了 compiler 的一些特性,比方说有那么一行代码在被执行了很多次的时候,js 引擎会将其设置为“Warm”,进行一系列超级优化,再被执行的时候就设置为“Hot”,进行究极优化(听说数码宝贝出新一代了?)。

Type specialization

再比如 js 中因为类型都是动态的,就是一个 array 中每个元素的类型都是不一定的,可能是 object 可能是 string 可能是 number,意味着当你 iterate 这个 array 的每个元素都要进行一系列的类型检查啥的。当 JIT 技术引进以后,我们的 js 引擎可能会在跑到前十个元素都是 number 的情况下,大胆预测你后面也都是 number(当然之后会进行检查)。

3.2 为什么 webassembly 要更快

Ok,说完 js 的 JIT 之后,我们来总结下执行 js 和 webassembly 的过程:

  1. 首先看 fetching 部分,webassembly 代码更加 compact,因为 js 要更 human-readable,所以代码提及相较于汇编语言体积会更大
  2. parse/decode 过程中,js 需要先生成 ast,然后再通过 ast 生成 IR(intermediate representation),IR 再生成机器码(x86 或者 arm)。而 webassembly 则不需要这个转化过程,这里盗张图,看下图。可知 webassembly 能够直接生成机器密码。
  3. compile + optimize 的过程在 JIT 部分说过,js 引擎要边运行边进行优化,比方说 watch 数据的类型变化啦之类的,而 webassembly 更接近底层机器码,数据类型什么的是固定的
  4. 此外 JS 中还有 reoptimization 的过程,在 JIT 的 type specialization 部分说到了 js 引擎为了提升性能的类型预测部分,肯定有 failed 的时候,所以 js 执行中还需要有 reoptimization 的部分。
  5. 至于 execution 的部分,因为 webassembly 更底层(webassembly 的两种格式看下面一小节,wasm 和 wat 格式)相比于 js 即使最终都生成机器码,webassembly 开发者写的代码所能优化的程度一定是大于 js 所能做的优化。
  6. GC 过程容易理解,能够转换成 webassembly 的高级语言中 gc 都需要开发者手动处理,而 js 中是自动处理。

3.3 webassembly 长什么样?(wasm 格式和 wat 格式)

讲了这么多,webassembly 到底长什么样子呢?
先写一个简单的 c++方法如下:

// test.c
    int addAll(int times) {
        int n = 0;
        for (int i = 0; i < times; ++i) {
           n += i;
        };
        return n;
    }
  1. wasm 后缀的文件:直接放到 webassembly 虚拟机上的可执行文件格式,我们利用Emscripten工具将上面的 c++文件生成一个 wasm 文件,命令为:emcc -O3 -s "EXPORTED_FUNCTIONS=['_addAll']" -o test.wasm test.c --no-entry ,就是在 wasm 文件中导出 addAll 方法,生成的 wasm 就是一个二进制(实际上是 16 进制)文件。打开 text.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
  1. wat 后缀的文件:方便人类可读的文件,并且可以在此格式上进行 coding,利用wasm2wat工具将生成的 wasm 转成 wat 格式,wasm2wat test.wasm生成的 wat 的内容如下:可以看出更 human-readable。简单介绍下,webassembly 中只支持四种数据类型
  • i32: 32-bit 整型
  • i64: 64-bit 整型
  • f32: 32-bit 浮点型
  • f64: 64-bit 浮点型
    像是i32.sub这样是运算指令,前面两个推入栈中的变量为运算指令的两个参数,看的出这个指令是够精简的。如果对手写 wasm 感兴趣的同学可以看这篇内容
    (module
  (type (;0;) (func (result i32)))
  (type (;1;) (func (param i32) (result i32)))
  (type (;2;) (func))
  (type (;3;) (func (param i32)))
  (func (;0;) (type 2)
    nop)
  (func (;1;) (type 1) (param i32) (result i32)
    local.get 0
    i32.const 1
    i32.lt_s
    if  ;; label = @1
      i32.const 0
      return
    end
    local.get 0
    i32.const 1
    i32.sub
    i64.extend_i32_u
    local.get 0
    i32.const 2
    i32.sub
    ...
    // 篇幅原因,只展示部分

当然 wasm 和 wat 格式之间是可以相互转换的,看你是要读它还是用它。

3.4 webassembly 和 js 中的通讯

上一个小结我们说了在 webassembly 中只有四个数据类型,并且都是数字类型,我们的 c++累加函数中只传递了数字 n,代表累加的次数,那么我们如何在 webassembly 和 js 之间传递复杂的数据结构比方说 string 或者 object 呢?答案就是传递内存的 buffer,有点像是在两者这件传递一个指针,这个指针是数字类型,然后把要传递的内容放到这片共享的内存中。这里详情可以看 mdn 的WebAssembly.Memory api,这里的内容复制自这里

//创建一个6.4MiB大的内存
const wasmMemory = new WebAssembly.Memory({ initial: 10, maximum: 100 });
WebAssembly.instantiate(wasmBinary, {
  env: {
    // 告诉webassembly这片内存咱俩一起用
    memory: wasmMemory,
  },
});

在 js 中,可以通过 typedArray 来操作二进制数据 buffer

// 把想要传递的数据转成 ArrayBuffer (假设是 Uint8Array)
const dataBuffer = encodeDataByJS({
  /* my data */
});
// 向 wasm 申请一段内存,由 wasm 代码负责实现并返回内存内存起始地址
const offset = applyMemoryFormWasm(dataBuffer.length);
// 以 unit8 的格式操作 wasm 的内存 (格式应该与 dataBuffer 的格式相同)
const heapUint8 = new Uint8Array(wasmMemory.buffer, offset, dataBuffer.length);
// 把数据写入 wasm 内存
heapUint8.set(dataBuffer);

4. 写一个 webassembly 的 demo

4.1 c++实现一个累加函数

我们用上面的 demo 实现一个 1 + 2 + 3 + ... + n 的 c++函数

4.2 用 emscripten 生成.wasm 文件

上部分提到利用emscripten生成 test.wasm 文件

4.3 如何在 js 中调用生成的.wasm 文件

我们尝试在 node 中使用这个 wasm 文件导出的addAll方法如下:

fs.readFile("./test.wasm", (err, data) => {
  if (err) throw err;
  WebAssembly.instantiate(data).then((module) => {
    console.log("In the native WebAssembly function");
    console.time("performance1");
    console.log(module.instance.exports.addAll(50000));
    console.timeEnd("performance1");
  });
});

因为现在 webassembly 已经是 web 标准,各种 js 引擎中都有相应的 api(WebAssembly)调用 webassembly。使用方法很简单,有兴趣的童鞋去 mdn 搜一下相关的其他信息。

4.4 粗略比较下计算性能

function addAll(n) {
  var result = 0;
  console.log("In the Javascript");
  console.time("performance2");
  for (let i = 0; i < n; i++) {
    result += i;
  }
  console.log(result);
  console.timeEnd("performance2");
}

js 和 webassembly 执行0 + 1 + 2 + ... + 50000的耗时在我的电脑上为 webassembly:0.07ms;js:4.886ms;看的出这么一个简单的计算就能看出来其性能差别了。

5. webassembly 的 runtime

wasmer

除了浏览器和 node 之外,我们说两个 webassembly 的 runtime,第一个是 wasmer,官网说这个 runtime 可以跑在任意的设备之上,看了下它更像是一个 docker 容器,能够运行 wasm。用它来运行我们的累加 wasm:

此外 wasmr 还提供了一个工具叫做 wapm,它有点像是 npm,在其社区上一些用户会上传自己编译好的 wasm 工具包,你只需要下载下来就能够直接在 wasmer 跑起来

在 wapm 的项目之下甚至会有一个wapm_packages和一个.lock的文件,内容也和我们的 package.lock 类似。

wasm-micro-runtime

这个 runtime 是由 intel 的中国团队开发,其目的就是运行在 iot 设备之上,其支持的平台架构有:

  1. X86-64, X86-32
  2. ARM, THUMB (ARMV7 Cortex-M7 and Cortex-A15 are tested)
  3. AArch64 (Cortex-A57 and Cortex-A53 are tested)
  4. MIPS
  5. XTENSA
    按照教程本地编译出了 runtime 并且能够成功运行我们的累加 wasm 方法:

    生成的 runtime 命令行文件iwasm只有 212k

6. webassembly 现在的应用场景:

在 w3c 2020 年 8 月 29 日的线上会议中,几家技术大厂分别介绍了他们用 webassembly 的场景:

  1. 典型场景一:B 站,在 up 主上传视频的时候利用 webassembly 进行视频内容的检查,根据视频生成推荐封面,这些操作都是在用户的浏览器(前端)实现的。
  2. intel 在嵌入式设备上的应用,用 Webassembly 实现了一套应用框架:
  3. Unity 游戏引擎也有 webassembly 的实现
  4. Emulator(仿真器)例如 game boy emulator
  5. 一些媒体处理网站,squoosh、ogv.js、Photon 等。

reference:

  1. https://hacks.mozilla.org/2017/02/a-cartoon-intro-to-webassembly/
  2. https://mp.weixin.qq.com/s/yZmci4krPkxA8uEfaJh5XQ
  3. https://developer.aliyun.com/article/740902