WebAssembly 起步!

# 了解 WebAssembly 原理

WebAssembly 是一种可以在浏览器上运行的二进制可执行格式文件。它将成为浏览器进化史上又一次革命。

自从浏览器问世以来,javascript 就成为浏览器上执行程序的唯一标准,越来越多的应用程序通过 javascript 开发,并运行于浏览器上;而随着浏览器上 h5 程序功能的丰富,也对浏览器提出了更多的挑战。其中一条最为重要的就是性能问题。javascript 是一种弱类型,解释性的脚本语言。它天生运行速度慢,成为了很多 h5 应用的软肋。虽然 2008 年 google V8 引入了即时编译等技术使 js 的运行速度提升了一大截,但是一些大型应用程序,比如游戏,视频编辑,压缩,算法等依然不适合运行在浏览器上。

WebAssembly 的到来解决了这个问题,并给开发基于浏览器的应用程序提供了另外的编程语言选择。2017 年三大浏览器同时增加了 WebAssembly 支持,标志着 WebAssembly 已经达到生产实用标准。

# 为什么 WebAssembly 比 javascript 快

回答这个问题需要洞悉浏览器执行 javascript 代码的各个环节。 浏览器加载并执行 javascript 大概可分为如下几个环节: 下载,解析,执行和优化,垃圾回收。

# 下载

javascript 是以纯文本格式下载的。相比,webassembly 使用二进制格式存储,结构更精简,更小。

# 解析

javascript 下载后,需要 js 引擎经过 tokenize, parse 两个阶段转换成 AST(abstract syntax tree),然后再转换为浏览器需要的中间字节码。由于 js 是比较高级的语言,解析 js 也相对要做更多的事情。webassembly 的格式类似于汇编语言,本来就是中间字节码,和需要运行的机器码更相近,需要简单的转换工作即可转化为 CPU 可以直接执行的机器码。

下图是一个真实运行的 webassembly(它是文本的,只是为了方便调试),可以看出它和汇编是很相似的,更易转化为机器码。

# 执行和优化

在执行阶段,js 普遍采用解释执行策略,相当于每一次执行 javascript 指令都要通过 js 引擎中转给 cpu。现代的 js 引擎同时采用了即时编译的策略。这需要同时运行一个 profiler,关注每个函数的调用情况。当 profiler 发现一个函数调用的比较多的时候,会把这个函数抛给编译器,为它生成一个更快的编译版本。某些情况下,参数类型会发生变化。这时,需要删除之前的编译版本,对新参数类型编译新的版本。而 webassembly 由于类汇编的结构,只需简单的编译即可转换为可直接运行在 cpu 上的机器码,执行更快。

# 垃圾回收

javascript 运行期间需要同时间歇的运行一个垃圾回收器,扫描堆上的垃圾、释放内存。垃圾回收器的运行又和 js 引擎的执行是互斥的,导致 js 执行间歇性的被垃圾回收器打断。webassembly 不负责垃圾回收,只能编程语言自行解决。于是不同的编程语言又有所不同。C/C++ 是手动管理内存 (malloc/free, new/delete),rust 则是基于生命周期的自动内存管理。所有这些内存管理方法都不需要间歇的全局暂停。因此性能更好。

从以上各个角度看 WebAssembly 确实比 javascript 性能高。事实上,目前阶段 WebAssembly 执行时间大概等于原生程序执行时间 X1.2。

# WebAssembly 的加载与执行

wasm 是 WebAssembly 格式的浏览器可执行文件。它是二进制的,但是它并不像桌面 win32 程序一样,可以随便使用系统资源,调用操作系统 api。事实上,所有与外界相关的操作都必须由 javascript 传入。比如:要申请一段内存,必须由 javascript 申请了并传给他。 浏览器上,javascript 做不到的,它也做不到;javascript 能做到的,它能做的更快。 这个就是它的价值。

目前必须要 js 启动 WebAssembly 的加载和实例化(后面可能会有单独的加载机制)。

如下函数,使用 fetchAPI 加载 wasm 文件,并实例化 wasm 模块。

function fetchAndInstantiate(url, importObject) {
  return fetch(url)
    .then((response) => response.arrayBuffer())
    .then((bytes) => WebAssembly.instantiate(bytes, importObject))
    .then((results) => results.instance);
}

fetchAndInstantiate("module.wasm", importObject).then(function (instance) {
  // ...
});
1
2
3
4
5
6
7
8
9
10

importObject 即浏览器需要向 webassembly 注入的交互 api。

如下,是一个真实运行的 importObject 包括很多 js 函数。

注意 global.memory 就是 webassembly 程序执行用到的内存,是 js 申请的一个大的 ArrayBuffer。

# 学会 WebAssembly 开发

讲了这么多 WebAssembly 的优点,接下就讲下 WebAssembly 的开发。

开发 WebAssembly 并不意味着需要手写 WebAssembly 汇编程序。一个开源项目 emscripten 已经提供了 sdk 可以编译 C/C++,并输出 WebAssembly 的 wasm 文件。目前,rust 也已经支持编译到 wasm。未来所有支持编译到 LLVM 字节码的编程语言,理论上都可以输出 wasm。

# 安装 emscripten

下载 emscripten sdk 后,是个压缩文件,其实是 sdk 包管理器。 需要执行如下命令,完成 sdk 的安装。

./emsdk update
./emsdk install latest
./emsdk activate latest
source ./emsdk_env.sh
1
2
3
4

现在已经有个可用的 emcc 编译器了,输入:

emcc --version
1

查看编译器版本。

emsdk 安装后, emscripten 文件内是按版本号安装的 sdk 内容,里面有很多 C/C++ 用例,可以自行研究下。

# 简单 demo

这个简单的 C 程序可以直接编译为 wasm。

#include <stdio.h>

int main() {
  printf("hello, world!\n");
  return 0;
}
1
2
3
4
5
6
./emcc hello_world.c
node a.out.js
1
2

默认情况下,emcc 只输出了一个 js(asmjs)。asmjs 是 webassembly 的一个早期原型,可提供 webassembly 在旧版本浏览器上的兼容。按如下命令输出 webassembly 二进制 wasm。

./emcc hello_world.c -s WASM=1 -o index.html
1

这次编译输出了 index.html, index.js, index.wasm 三个文件。通过一个静态服务器打开 index.html,可以看到 console 里的输出。

这个 index.html 是一个调试页面。生产上加载 webassembly 一般都需要自己写 index.html,只保留 js 和 wasm 文件就够了。

以上的例子中,printf 的标准输出被定向到了浏览器的 console 里面。 系统 API 调用被换成了 js 实现。 事实上很多 libc 里面的函数被 emscripten 实现成了浏览器上的兼容方案,从而更好的和浏览器结合。

# 环境

所有编程语言都要和它的运行环境打交道,否则除了把 cpu 跑满,没什么实用价值。跑在浏览器上的 webassembly 则是通过和 js 相互调用发挥它的作用。

Emscripten sdk 提供了很多 API 与 js 运行环境/浏览器交互。定义在其中两个头文件中:

  • emscripten.h: 中定义了一些基础功能相关 API,包括调用 js,文件读写,网络请求等,这些 API 在 node 中也可以用。
  • html5.h 中定义了浏览器中与 DOM 相关的各种操作,包括 DOM,事件,设备相关等。

下面,抽出一些关键的 API 讲下 webassembly 是如何与浏览器协同工作的。

# 调用 js

EM_ASM 宏,让 webassembly 可以直接调用 js。

EM_ASM(alert('hai'); alert('bai'));
1

如果需要从 js 获取执行结果,可以用 EM_ASM_INT, EM_ASM_DOUBLE 两个版本分别获取 int 和 double 类型的数值。

int x = EM_ASM_INT({
  return $0 + 42;
}, 100);
1
2
3

如果需要传递字符串给 js,可以传递一个字符串起始的指针给 js。由于 js 可以访问整个 wasm 程序的内存区域,js 用这个指针就可以从内存读出字符串。Module 对象上的UTF8ToString(ptr), UTF16ToString(ptr), UTF32ToString(ptr), Pointer_stringify(ptr, length)这几个函数可获得指针处的字符串。

char* sample = "This is a string";
  EM_ASM_({
      console.log("js got string:", Module.UTF8ToString($0));
  }, sample);
1
2
3
4

# 标准输入输出

标准输出我们之前看过,printf 最终被转到 Module.print,默认是 console.log 实现。 标准错误输出最终会被转到 Module.printErr,默认是 console.error 实现。 对标准输入的读取在浏览器上变成了一个 prompt 框。体验不好,尽量不要读。

# 显示

Emscripten 支持两种 GUI 展示方法。

  • DOM: wasm 是可以调用 js 的,而 js 又可以操作 DOM。因此,wasm 可以通过 js 操作 DOM,创建程序的 GUI。
  • Webgl Canvas: 除了 DOM,emscripten 还可以提供了 opengl es 的浏览器实现。通过操作一个 Webgl Canvas,把显示内容画在 Canvas 上。

# 事件循环

C++ GUI 程序一般都有个事件循环,其实就是个死循环,反复获取并处理 GUI 层面上的各种事件。这样程序不会跑完 main 函数直接退出。webassembly 程序跑在浏览器上,而浏览器本来就是事件驱动,已经有了一个事件循环。假如不改动直接上浏览器,就会卡死浏览器的 GUI 进程。因此 webassembly 程序需要由浏览器控制事件循环。

emscripten_set_main_loop(em_callback_func func, int fps, int simulate_infinite_loop)函数接受一个函数的指针后,浏览器会根据 fps 按时调用传入的函数。

#include <stdio.h>
#include <emscripten.h>

int frame = 0;
void main_loop(void) {
  printf("frame: %d\n", frame);
  frame++;
}

int main(void) {
  emscripten_set_main_loop(main_loop, 0, 1);
  return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 存储

浏览器隔离了程序直接操作存储的权限,因而 webapp 是安全的,但很多 C 代码都有同步操作文件的 API,如 open, write, close。为了兼容,emscripten 实现了一个内存文件系统,可以通过全局对象 FS 访问。

下图,是 FS 对象下的函数。

另外,emcc 还提供了 --preload-file 参数,在 webassembly 程序加载的过程中,预加载文件放到虚拟文件系统中。

wasm 中的文件虽然是内存的,但是支持通过 indexDB 持久化。 如下 js,mount 一个 indexdb 的文件夹到 /data 目录,然后 FS.syncfs 把 indexdb 中的文件同步到内存。

FS.mkdir("/data");
FS.mount(IDBFS, {}, "/data");
FS.syncfs(true, function (err) {});
1
2
3

接下来,所有,/data 目录下的读写,都在内存中的同步读写。当程序关闭的时候,需要调用FS.syncfs(false, function(err){})把内存中的文件反方向同步回 indexdb。

#

emsdk 提供了一些常用的 C++ 库的 webassembly 兼容版本。用emcc --show-ports命令显示。如果要用 SDL2,需要给 emcc 加入选项-s USE_SDL=2,链接 SDL2 库。

目前,emcc 内置支持这些库。

$ emcc --show-ports
Available ports:
    zlib (USE_ZLIB=1; zlib license)
    libpng (USE_LIBPNG=1; zlib license)
    SDL2 (USE_SDL=2; zlib license)
    SDL2_image (USE_SDL_IMAGE=2; zlib license)
    ogg (USE_OGG=1; zlib license)
    vorbis (USE_VORBIS=1; zlib license)
    bullet (USE_BULLET=1; zlib license)
    freetype (USE_FREETYPE=1; freetype license)
    SDL2_ttf (USE_SDL_TTF=2; zlib license)
    SDL2_net (zlib license)
    Binaryen (Apache 2.0 license)
    cocos2d
1
2
3
4
5
6
7
8
9
10
11
12
13
14

如果所需要的库没在列表里,需要先用 emsdk 编译所需要的库(可能涉及到库的改动)。再编译并链接,输出最终目标。emcc 不支持动态链接。

# WebAssembly 示例(C/C++)

如果你的浏览器支持 WebAssembly,则可以直接运行下面代码:

WebAssembly.compile(
  new Uint8Array(
    `
  00 61 73 6d  01 00 00 00  01 0c 02 60  02 7f 7f 01
  7f 60 01 7f  01 7f 03 03  02 00 01 07  10 02 03 61
  64 64 00 00  06 73 71 75  61 72 65 00  01 0a 13 02
  08 00 20 00  20 01 6a 0f  0b 08 00 20  00 20 00 6c
  0f 0b`
      .trim()
      .split(/[\s\r\n]+/g)
      .map((str) => parseInt(str, 16))
  )
).then((module) => {
  const instance = new WebAssembly.Instance(module);
  const { add, square } = instance.exports;

  console.log("2 + 4 =", add(2, 4));
  console.log("3^2 =", square(3));
  console.log("(2 + 5)^2 =", square(add(2 + 5)));
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 编译器(C/C++)

有一个在线 C++ 转 wasm 的工具: WasmExplorer (opens new window)

Emscripten (opens new window),它基于 LLVM ,可以将 C/C++ 编译成 asm.js,使用 WASM 标志也可以直接生成 WebAssembly 二进制文件(后缀是 .wasm)。

注:emcc 在 1.37 以上版本才支持直接生成 wasm 文件。

当然还有其他语言的编译器,这里只说 C/C++

使用 SDK (opens new window) 方式安装更方便

解压下载的 zip, 进入 SDK 的解压目录。逐步执行如下命令

# Fetch the latest registry of available tools.
./emsdk update

# Download and install the latest SDK tools.
./emsdk install latest

# Make the "latest" SDK "active" for the current user. (writes ~/.emscripten file)
./emsdk activate latest
1
2
3
4
5
6
7
8

如果 activate 无效,可以手动设置环境变量(如果已经装了 Java、Node、Python,可以省略这 3 个):

PATH += D:\tools-dev\emsdk-portable-64bit\clang\e1.37.28_64bit
PATH += D:\tools-dev\emsdk-portable-64bit\node\4.1.1_64bit\bin
PATH += D:\tools-dev\emsdk-portable-64bit\python\2.7.5.3_64bit
PATH += D:\tools-dev\emsdk-portable-64bit\java\7.45_64bit\bin
PATH += D:\tools-dev\emsdk-portable-64bit\emscripten\1.37.28
1
2
3
4
5

# Python 版本不对?

Emscripten 需要 Py2,如果你装了 Py3,则需要同时安装 Py2。

Py2 和 Py3 在 Windows 下共存的方案,可以看这个:

http://blog.csdn.net/louishao/article/details/57075531 (opens new window)

# 编写 C 代码

首先新建一个 C 语言文件,假设叫 math.c 吧,在里边实现 add 和 square 方法:

// math.c
int add (int x, int y) {
  return x + y;
}

int square (int x) {
  return x * x;
}
1
2
3
4
5
6
7
8

然后执行 emcc math.c -Os -s WASM=1 -s SIDE_MODULE=1 -o math.wasm 就可以生成 wasm 文件了。

# 编写加载函数

function loadWebAssembly(path) {
  return fetch(path) // 加载文件
    .then((res) => res.arrayBuffer()) // 转成 ArrayBuffer
    .then(WebAssembly.instantiate) // 编译 + 实例化
    .then((mod) => mod.instance); // 提取生成都模块
}
1
2
3
4
5
6

代码其实很简单,使用了 Fetch API 来获取 wasm 文件,然后将其转换成 ArrayBuffer,然后使用 WebAssembly.instantiate 这个一步到位的方法来编译并初始化一个 WebAssembly 的实例。最后一步是从生成的模块中提取出真正的实例对象。

完成了上边的操作,就可以直接使用 loadWebAssembly 这个方法加载 wasm 文件了,它相当于是一个 wasm-loader ;返回值是一个 Promise,使用起来和普通的 js 函数没什么区别。从 instance.exports 中可以找到 wasm 文件输出的接口。

loadWebAssembly("path/to/math.wasm").then((instance) => {
  const { add, square } = instance.exports;
  // ...
});
1
2
3
4

# 更完整的加载函数

如果你直接使用上边那个 loadWebAssembly 函数,有可能会执行失败,因为在 wasm 文件里,可能还会引入一些环境变量,在实例化的同时还需要初始化内存空间和变量映射表,也就是 WebAssembly.Memory 和 WebAssembly.Table。

/**
 * @param {String} path wasm 文件路径
 * @param {Object} imports 传递到 wasm 代码中的变量
 */
function loadWebAssembly(path, imports = {}) {
  return fetch(path)
    .then((response) => response.arrayBuffer())
    .then((buffer) => WebAssembly.compile(buffer))
    .then((module) => {
      imports.env = imports.env || {};

      // 开辟内存空间
      imports.env.memoryBase = imports.env.memoryBase || 0;
      if (!imports.env.memory) {
        imports.env.memory = new WebAssembly.Memory({ initial: 256 });
      }

      // 创建变量映射表
      imports.env.tableBase = imports.env.tableBase || 0;
      if (!imports.env.table) {
        // 在 MVP 版本中 element 只能是 "anyfunc"
        imports.env.table = new WebAssembly.Table({
          initial: 0,
          element: "anyfunc",
        });
      }

      // 创建 WebAssembly 实例
      return new WebAssembly.Instance(module, imports);
    });
}
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

这个 loadWebAssembly 函数还接受第二个参数,表示要传递给 wasm 的变量,在初始化 WebAssembly 实例的时候,可以把一些接口传递给 wasm 代码。

# 调用 WebAssembly 导出的接口

loadWebAssembly("./math.wasm").then((instance) => {
  const add = instance.exports._add;
  const square = instance.exports._square;

  console.log("2 + 4 =", add(2, 4));
  console.log("3^2 =", square(3));
  console.log("(2 + 5)^2 =", square(add(2 + 5)));
});
1
2
3
4
5
6
7
8

比较奇怪的一点是,用 C/C++ 导出的模块,属性名上默认都带了 _ 前缀,asm.js 转成了 wasm 模块就不带。

# 其他教程

http://www.ruanyifeng.com/blog/2017/09/asmjs_emscripten.html (opens new window)

http://webassembly.org/docs/high-level-goals/ (opens new window)

https://developer.mozilla.org/zh-CN/docs/WebAssembly (opens new window)

# WebAssembly 示例(AssemblyScript)

之前介绍了 WebAssembly 可以用 C/C++ 和 Rust 来编写。但是如果你是熟悉 JS 的前端,不想换语言怎么办呢?

于是 AssemblyScript (opens new window) 诞生了

A TypeScript to WebAssembly compiler. http://assemblyscript.org (opens new window)

# 安装

git clone https://github.com/AssemblyScript/assemblyscript.git
cd assemblyscript
npm install
npm link # npm link 命令可以将一个任意位置的 npm 包链接到全局执行环境,从而在任意位置使用命令行都可以直接运行该 npm 包。
1
2
3
4

# 编译器的使用

asc yourModule.ts -b yourModule.wasm
1