前端展望-WebAssembly(wasm)技术入门

9,046 阅读6分钟

亿点点JS性能历史

1995年,javaScript 诞生并迅速发展。2008年,浏览器JITs即时编译器的出现,让js的执行速度提升了10倍。网站上可以加入的设计越来越多。随着越来越多的框架和工具的诞生,前端项目的上限越来越高。

编组 6.png

那么如果我想在前端实现视频剪辑、图片处理、VR等计算量比较大的操作可行吗?答案当然是可行的,但是以当前js的计算速度,在执行复杂的计算任务时,你的客户还没等到你加载计算完成可能已经离开了当前页面了。那3D游戏呢?行是行,前提是客户可以接受ppt般的游戏体验。

而WebAssembly(wasm)的出现就是为了解决这方面的问题的。

更快的计算-WebAssembly

根据MDN的定义,WebAssembly是一种新的编码方式,可以在现代的网络浏览器中运行 - 它是一种低级的类汇编语言,具有紧凑的二进制格式,可以接近原生的性能运行,并为诸如C / C ++等语言提供一个编译目标,以便它们可以在Web上运行。它也被设计为可以与JavaScript共存,允许两者一起工作。

我们知道,高级语言由于靠近人类语言所以学习的成本更低,但是运行效率并不高,高级语言经过编译等一系列操作转换成低级语言——机器可以理解的二进制语言。当一门语言越靠近机器语言,中间各种转换操作可以省略而变得更快执行。你可以这样理解,先有机器语言,但为了方便人们开发使用,于是有了高级语言,为了让机器理解高级语言,高级语言需要进行转换。

WebAssembly是一种低级的类汇编语言,二进制格式和靠近机器码的实现使它在执行相同的操作时比 javaScript 要快得多。一些编译工具为 C / C++ 提供转换成wasm,Web可以运行这类文件,它们通常以.wasm结尾,看到这里,有些小伙伴已经有点眼熟了,在一些网站打开调试工具,我们可以在network栏里发现这类文件的存在,但当时笔者并不知道它们是什么 [doge]。

infografica-Web-Assembly-eng.jpeg

就当前我们公司的需求来说,用户传图进行人脸识别是我们其中一块重要的功能,人脸识别这类功能可以使用原本使用 C++ 开发的人脸识别库,只要这个库编译成WebAssembly开放使用。这样我们就把人脸识别的工作交给了wasm进行,甚至在一些不涉及dom的场景,我们可以通过worker计算而不占用主线程。如此一来,wasm结合js可以帮助我们更快的实现一些消耗性能的操作。

综上所述,因为省略了许多从高级语言转换成低级语言的操作,同时不需要对wasm不需要额外的类型优化与垃圾回收,所以WebAssembly可以的更快执行任务。

wasm使用指南🧭

导入

WebAssembly尚未与<script type='module'>ES2015 import语句集成,因此没有路径让浏览器通过导入来获取模块。

这项技术正在研究推进中,未来WebAssembly模块将可以使用<script type='module'>加载,这意味着JavaScript将能够像ES2015模块一样容易地获取,编译和导入WebAssembly模块。

目前我们可以通过使用fetch来提取网络资源。

WebAssembly.instantiateStreaming(fetch('simple.wasm'), importObject)
.then(results => {
  // Do something with the results!
});

需要注意的是,instantiateStreaming 方法在safari上并不支持,为了兼容更多的浏览器,我们可以更改使用instantiate,该方法唯一的区别就是需要执行一个额外的步骤,将获取的字节码转换为ArrayBuffer

fetch('module.wasm').then(response =>
  response.arrayBuffer()
).then(bytes =>
  WebAssembly.instantiate(bytes, importObject)
).then(results => {
  // Do something with the results!
});

instantiate返回wasm的Module与实例(Instance),Module与实例分别是什么东西?接下来认识几个wasm API中的几个关键概念。

相关概念

  • Module:表示一个 WebAssembly 二进制文件,该二进制文件已由浏览器编译为可执行的机器代码。 Module 是无状态的,因此,就像Blob一样,可以在 Windows 和 worker 之间(通过postMessage())共享。模块声明导入和导出就像 ES2015module 一样。
  • importObject:包含一些想要导入到新创建Instance中值的对象,例如 Table 对象或  Memory 对象。
  • Memory:一个 Memory 对象,该对象的 buffer属性是可调整大小的 ArrayBuffer ,其内存储的是 WebAssembly实例可访问内存里的原始字节码。
  • Table:构造函数根据给定的大小和元素类型(目前只有函数元素类型"anyfunc")创建一个Table对象。具有getgrowset三种方法,set用于设置对象里给定索引处的元素设置给定函数,get则用于获取,grow则用于增长表长度。
  • 实例:一个可执行实例对象,其与运行时使用的所有状态配对,包括MemoryTable和一组导入的值(importObject), 包含所有的WebAssembly导出函数。

Module 与 实例

Module可以在 Windows 和 worker 共享,这样我们就能把合适的工作交给worker,worker进行实例化并返回结果给 Windows。

// index.js

var worker = new Worker("wasm_worker.js");

WebAssembly.compileStreaming(fetch('simple.wasm'))
.then(mod =>
    worker.postMessage(mod)
);

compileStreaminginstantiateStreaming类似,区别在于compileStreaming并不返回实例,仅仅做了编译,返回一个 Module 。接下来,我们把 Module 传递给 worker 。

compileStreaming 同样存在Safari兼容问题,可以使用compile替代,同instantiate方法,该方法需要一个额外的步骤,将获取的字节码转换为ArrayBuffer

simple.wasm:

(module
  (func $imports.imported_func (;0;) (import "imports" "imported_func") (param i32))
  (func $exported_func (;1;) (export "exported_func")
    i32.const 42
    call $imports.imported_func
  )
)

wasm_worker.js:


var importObject = {
  imports: {
    imported_func: function(arg) {
      console.log(arg);
    }
  }
};

onmessage = function(e) {
  console.log('module received from main thread');
  var mod = e.data;

  WebAssembly.instantiate(mod, importObject).then(function(instance) {
    instance.exports.exported_func();
  });

  var exports = WebAssembly.Module.exports(mod);
  console.log(exports[0]);
};

worker 接收到实例后,使用instantiate进行实例化,得到实例。exported_func是 wasm 导出的其中一个函数方法,我们只需像调用 js 函数一样调用 wasm 导出的函数即可。除此之外我们可以通过静态方法WebAssembly.Module.exports获取wasm导出的对象数组。在上面这个例子中,export 导出的对象数组如下

[
    {
        "name": "[exported_func](url)",
        "kind": "function"
    }
]

WebAssembly.Module.imports可以获取wasm接受的导入对象数组,在上面这个例子中,import导入对象数组如下

[
    {
        "module": "imports",
        "name": "imported_func",
        "kind": "function"
    }
]

通过观察导入对象数组,我们可以编写包含 wasm 可执行的 javaScript 函数对象importObject并传入实例化函数。在上面这个例子中,wasm 的exported_func方法调用了importObject传入的imported_func方法,打印了 wasm 中的42常量。

尝试调试Module与worker结合使用实例以更好地理解以上例子。

内存

在 wasm 与 javaScript 的协作中,我们可以使用WebAssembly.Memory创建内存实例完成两者的信息传递。

var memory = new WebAssembly.Memory({initial:10, maximum:100});

以上代码创建了一个初始为10,最大可以为100的用于 wasm 存储数据的内存空间。memory.buffer返回原始二进制数据缓冲区ArrayBuffer,如果我们需要对memory进行赋值,根据mdn的定义,我们无法直接操作ArrayBuffer的内容,但可以创建其中一种类型化数组对象或使用DataView

var i32 = new Uint32Array(memory.buffer);

for (var i = 0; i < 10; i++) {
  i32[i] = i;
}

通过这种方式,我们把0 - 9赋值到32位无符号整数类型数组中。

接下来给出一个提供把数组中的值进行加法计算的wasm文件:

(module
  (memory $js.mem (;0;) (import "js" "mem") 1)
  (func $accumulate (;0;) (export "accumulate") (param $var0 i32) (param $var1 i32) (result i32)
    (local $var2 i32) (local $var3 i32)
    local.get $var0
    local.get $var1
    i32.const 4
    i32.mul
    i32.add
    local.set $var2
    block $label0
      loop $label1
        local.get $var0
        local.get $var2
        i32.eq
        br_if $label0
        local.get $var3
        local.get $var0
        i32.load
        i32.add
        local.set $var3
        local.get $var0
        i32.const 4
        i32.add
        local.set $var0
        br $label1
      end $label1
    end $label0
    local.get $var3
  )
)

在WebAssembly的低级内存模型中,内存表示为连续的无类型字节范围,称为线性内存。这里简单介绍下这种内存下的计算方式,我们选取其中一段wasm进行简单的解释

    local.get $var0
    local.get $var1
    i32.const 4
    i32.mul
    i32.add
    local.set $var2
  1. $var0推入栈顶
  2. $var1推入栈顶
  3. 4推入栈顶
  4. 推出栈顶两次,将两次获得的数据进行相,结果推入栈顶
  5. 推出栈顶两次,将两次获得的数据进行相,结果推入栈顶

image.png

在上述wasm文件中,$accumulate有两个参数,可以理解为相加的数组起点和数组的终点,这个函数中的本地变量$var2计算为数组的边界值,$var0则是在循环中增长的值,在每次循环中增加4,当两者相等时,循环结束。

总之,我们可以理解为每次循环中,每次按4个字节的增长偏移顺序读取了Memory——即按顺序读取了Uint32Array中的值进行了相加操作,并最终赋值给$var3并输出了结果,即Uint32Array中0-9位的值之和。

完整的js调用如下:

var memory = new WebAssembly.Memory({initial:10, maximum:100});

WebAssembly.instantiateStreaming(fetch('memory.wasm'), { js: { mem: memory } })
.then(obj => {
    var i32 = new Uint32Array(memory.buffer);
    for (var i = 0; i < 10; i++) {
      i32[i] = i;
    }
    var sum = obj.instance.exports.accumulate(0, 10);
    console.log(sum);
});

尝试调试内存实例以更好地理解以上例子。

Table

以下wasm,展示wasm导出了一个table,包含两个元素: $thirteen$fourtytwo方法。

(module
  (func $thirteen (result i32) (i32.const 13))
  (func $fourtytwo (result i32) (i32.const 42))
  (table (export "tbl") anyfunc (elem $thirteen $fourtytwo))
)

接下来是js的部分(省略了导入步骤):

var tbl = results.instance.exports.tbl;
console.log(tbl.get(0)());  // 13
console.log(tbl.get(1)());  // 42

请注意,通过Table.prototype.get()调用来检索每个函数引用,然后在最后添加一个额外的括号以调用该函数。

尝试调试table实例以更好地理解以上例子。

总结

  • WebAssembly提供了一种以近乎本机的速度在网络上运行,与 javaScript 赋予了Web极大的性能提升
  • 可以使用再视频编辑,图像编辑,3D游戏,VR等需要极大性能提升的场景
  • WebAssembly并非使用纯手工编写,而是旨在成为C,C ++,Rust等源语言的有效编译目标
  • 通过wasm实例/memory/table交换信息,达到运行wasm中的相关代码并得到结果的目的。