了解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(它是文本的,只是为了方便调试),可以看出它和汇编是很相似的,更易转化为机器码。
Screenshot-from-2018-01-10-10-42-32

执行和优化

在执行阶段,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) {
  ...
})

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

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

Screenshot-from-2018-01-09-16-43-39
Screenshot-from-2018-01-09-16-44-12

注意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

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

emcc --version

查看编译器版本。

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

简单demo

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

#include <stdio.h>

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

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

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

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

Screenshot-from-2018-01-09-15-37-35
这个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'));

如果需要从js获取执行结果,可以用EM_ASM_INTEM_ASM_DOUBLE两个版本分别获取intdouble类型的数值。

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

如果需要传递字符串给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);

标准输入输出

标准输出我们之前看过,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;
}

存储

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

下图,是FS对象下的函数。
Screenshot-from-2018-01-10-15-26-48

另外,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) {
});

接下来,所有,/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

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

展望

目前,webassembly已经完成MVP最小功能版本开发,有非常注目的性能。可以遇见,未来将有更多h5 app/游戏通过webassembly获得更好的体验。使用C/C++/rust进行webapp开发,混合编程,也会有很多不错的探索。

未来h5能否通过webassembly撼动原生的大门,让我们拭目以待。