WebAssembly入门分享

1,641 阅读7分钟

三角形是世界上最坚固的形状,除了爱情。关于web开发,html/css/js三剑客似乎坚不可摧,但我们似乎看见了强势入场的第四者 ——WebAssembly

1.什么是WebAssembly?

先看官网给的定义:

WebAssembly (abbreviated Wasm) is a binary instruction format for a stack-based virtual machine. Wasm is designed as a portable compilation target for programming languages, enabling deployment on the web for client and server applications.

翻译过来就是:WebAssembly是基于栈虚拟机的二进制指令集,可以作为编程语言的编译目标,能够部署在web客户端和服务端应用中。个人理解呢,首先 WebAssembly 是由 Web(网络) 和 Assembly(汇编)两个词构成,我们大致可以理解成,在浏览器中实现汇编语言的执行。汇编对应机器码,机器码对应指令集,我们下面可以稍微补充一下这方面的知识。

相关概念:指令集和操作系统

image.png 参考上图,我们一应应用和操作系统,最终的执行都需要通过CPU指令集来实现。

而指令集又是什么?我们这里举个最简单的例子

// C
int add_a_and_b(int a, int b) {
   return a + b;
}

int main() {
   return add_a_and_b(2, 3);
}
// Assembly language
_add_a_and_b:
   push   %ebx
   mov    %eax, [%esp+8] 
   mov    %ebx, [%esp+12]
   add    %eax, %ebx 
   pop    %ebx 
   ret  

_main:
   push   3
   push   2
   call   _add_a_and_b 
   add    %esp, 8
   ret

我们可以看到c语言通过gcc编译产出的汇编语言中,push/mov/pop等均为指令集中规定的指令,这里用汇编表示方便人类读写,进而汇编语言再被翻译为机器码。

通过规定的指令集来编写相关程序,最终CPU会逐一进行执行,不同的CPU的指令集也不相同。而WebAssembly也是一样,等于是规范了一套Web浏览器可读取的虚拟指令集,来实现更快运行速度。

2.我们为什么需要WebAssembly?

天下武功,唯快不破

众所周知,javascript是浏览器上的唯一真神,但是他真的很优秀么?作为一门解释型语言,他慢,作为一门动态类型语言,他乱。结合在一起,对于浏览器运行他时,要经历非常多的流程才能获得我们想要的结果。下面看一张从网上找的图,来说明一下V8引擎从js源码到执行的过程。

image.png

由于 js 的动态类型,解释器在执行代码的时候会在类型判断上带来一定的性能消耗,降低执行速度。所以 V8 引擎采用了 JIT(即时编译技术) 技术,监控一些经常执行的代码,将其编译成 CPU 直接执行的机器码,提高执行速度。但由于 js 动态类型,在某些情况下还得反优化,回到字节码进行执行。

WebAssembly在处理时可以理解为不依赖于特定机器结构的目标汇编语言。因此浏览器在处理 WebAssembly 文件时,可以迅速的将其转换成机器编码。

image.png 这样显然,我们在计算密集型场景中,使用wasm可以有效的提升代码的执行效率,给用户带来更好的使用体验。

举个例子:

1.微信里发送过的文件转发,可以秒发,这个很显然是通过MD5或近似的方案实现的。 但是大家如果使用过腾讯企业邮箱会发现,曾经发送过的文件,不论多大,在几日之内再次上传,都可以实现秒传,这里就是利用wasm对流文件做快速解析提取MD5与用户历史上传数据进行比对,避免重复上传。原本使用js进行解析,2G左右文件需要一分钟,使用wasm后时间可以降低到15s左右,性能提升4倍

3.WebAssembly初体验

我们先来体验一下下面这串代码

WebAssembly.instantiate(new Uint8Array(`
  00 61 73 6D 01 00 00 00 01 17 05 60 00 01 7F 60
00 00 60 01 7F 00 60 01 7F 01 7F 60 02 7F 7F 01
7F 03 07 06 01 04 00 02 03 00 04 05 01 70 01 02
02 05 06 01 01 80 02 80 02 06 0F 02 7F 01 41 90
88 C0 02 0B 7F 00 41 84 08 0B 07 82 01 09 06 6D
65 6D 6F 72 79 02 00 19 5F 5F 69 6E 64 69 72 65
63 74 5F 66 75 6E 63 74 69 6F 6E 5F 74 61 62 6C
65 01 00 03 61 64 64 00 01 0B 5F 69 6E 69 74 69
61 6C 69 7A 65 00 00 10 5F 5F 65 72 72 6E 6F 5F
6C 6F 63 61 74 69 6F 6E 00 05 09 73 74 61 63 6B
53 61 76 65 00 02 0C 73 74 61 63 6B 52 65 73 74
6F 72 65 00 03 0A 73 74 61 63 6B 41 6C 6C 6F 63
00 04 0A 5F 5F 64 61 74 61 5F 65 6E 64 03 01 09
07 01 00 41 01 0B 01 00 0A 30 06 03 00 01 0B 07
00 20 00 20 01 6A 0B 04 00 23 00 0B 06 00 20 00
24 00 0B 10 00 23 00 20 00 6B 41 70 71 22 00 24
00 20 00 0B 05 00 41 80 08 0B`.trim().split(/[\s\r\n]+/g).map(str => parseInt(str, 16))
)).then(({instance}) => {
  const { add } = instance.exports
  console.log('4 + 5 =', add(4, 5))
})

我们会惊奇的发现,这是什么玩意但是居然能执行

image.png

没错,这就是WebAssembly,上面那一串长长的字节码,翻译过来就是一个简单的add函数,然后通过浏览器内置的 WebAssembly.instantiate 进行编译和实例化。那么这个add函数又是怎么写出来的呢?

指令文本wat

(module
    (func $add(param $p i32) (param $q i32) (result i32)
    local.get $p
    local.get $q
    i32.add)
    (export "add" (func $add))
)

上面的是一个简单的add函数,也就是上面示例的S-expressions, Lisp 语言就是采用的这种表达式,每条语句都是先执行最里边括号的表达式然后依次展开。

下面我们简单的解释一下这段代码:

  1. module,表示声明了一个根结点模块,在此模块下,我们可以定义函数体积、导出函数等功能
  1. func,声明一个函数块,一般写法如下
(func <signature> <locals> <body>)
  • 签名:用于声明函数需要的函数及函数返回值
  • 局部变量:显式声明类型的变量
  • 函数体:低级指令的线性列表
  1. export,导出一个命名为add的内存空间,指向func $add

关于S表达式如何书写和详细的解释,后续会更新相关文档,也可以网上查询其他资料,这里不展开解释了

wat to wasm

在我们写了上面这段代码之后,如何转换为wasm的代码呢?

我们借助wabt这个包进行代码转换

const { readFileSync, writeFileSync } = require("fs");
const wabtInstance = require("wabt");
wabtInstance().then((wabt) => {
  const inputWat = "add.wat";
  const outputWasm = "add.wasm";

  const wasmModule = wabt.parseWat(inputWat, readFileSync(inputWat, "utf-8"));
  const { buffer } = wasmModule.toBinary({});

  writeFileSync(outputWasm, Buffer.from(buffer));
});

执行之后 用hexedit 来读取这个二进制文件

image.png

这样,我们就得到了这段可执行的wasm编码

同样,我们可以借助一些插件,来解析一段现有的wasm代码

C++ to wasm

但是当我们倒回去看上面的wat,我们发现用lisp去完成代码编写,非常痛苦,如果写代码都要这样去写,那我宁愿不写。因此,wasm社区开发了大量的工具来帮助我们将各种语言转换为wasm,这里以C++为例子。

#include <emscripten.h>
extern "C"
{
  EMSCRIPTEN_KEEPALIVE
  int fibonacci(int n)
  {
    if (n < 2)
    {
      return 1;
    }
    return fibonacci(n - 1) + fibonacci(n - 2);
  }
}

上面是一个非常拙劣的斐波那契数列计算的函数,然后我们需要安装一下emscripten(每次初始化都需要 source ./ emsdk_env . sh)

emcc fibonacci.cc -s WASM=1 -O3 --no-entry -o fibonacci.wasm

-s WASM=1 表明编译成 Webassembly 的程序,-O3 表明编译的优化程度,–no-entry 参数告诉编译器没有声明 main 函数,-o 指定生成的文件名。

这样我们就看见,我们编写的.cc文件被转换成了wasm文件

如何调用wasm

JS 调 WebAssembly 分为 3 大步:加载字节码 > 编译字节码 > 实例化,获取到 WebAssembly 实例后就可以通过 JS 去调用了,以上 3 步具体的操作是:

  1. 对于浏览器可以通过网络请求去加载字节码,对于 Nodejs 可以通过 fs 模块读取字节码文件;
  1. 在获取到字节码后都需要转换成 ArrayBuffer 后才能被编译,通过 WebAssembly 通过的 JS API WebAssembly.compile 编译后会通过 Promise resolve 一个 WebAssembly.Module,这个 module 不能直接被调用;
  1. 在获取到 module 后需要通过 WebAssembly.Instance API 去实例化 module,获取到 Instance 后就可以像使用 JS 模块一个调用了。

其中的第 2、3 步可以合并一步完成,前面提到的 WebAssembly.instantiate 就做了这两个事情。

下面走一个实例来看一下

const express = require("express");
const app = express();

app.listen(8888, () => {
  console.log("running http://localhost:8888");
});

app.get("/", function (req, res) {
  res.sendFile(__dirname + "/index.html");
});

app.get("/fibonacci.wasm", function (req, res) {
  res.sendFile(__dirname + "/fibonacci.wasm");
});
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>斐波纳切数字</title>
  </head>
  <script>
    function fibonacciJS(n) {
      if (n < 2) {
        return 1;
      }
      return fibonacciJS(n - 1) + fibonacciJS(n - 2);
    }
    const response = fetch("fibonacci.wasm");
    const num = [5, 15, 25, 35];
    WebAssembly.instantiateStreaming(response).then(
      ({ instance }) => {
        let { fibonacci } = instance.exports;
        for(let n of num) {
          console.log(`斐波纳切数字: ${n},运行 10 次`)
          let cTime = 0;
          let jsTime = 0;
          for(let time = 0; time < 10; time++) {
            let start = performance.now();
            fibonacci(n)
            cTime += (performance.now() - start)

            start = performance.now();
            fibonacciJS(n)
            jsTime += (performance.now() - start)
          }
          console.log(`wasm 模块平均调用时间:${cTime / 10}ms`)
          console.log(`js 模块平均调用时间:${jsTime / 10}ms`)
        }

      }
    )
  </script>
  <body>
  </body>
</html>

image.png 我们可以看到,示例代码中的算法是非常落后且浪费性能的算法,在这样的场景下,wasm较js的执行效率得到了极大提升。由此我们可以得出一个结论,wasm用于计算密集型的场景下非常有优势。

4.wasm前端场景之我见

根据我们2/3模块得到的结论,可以明显看出,wasm的优势在于性能,其原因是跳过了繁重的解析代码及编译过程,将代码接近一步到位的进行执行

image.png 因此我理解wasm有以下两种应用落地方案

  1. 前端运算密集型场景

    1. webGL加速

      1. Figma:我们知道webGL本质是浏览器调用计算机硬件加速,进行浏览器端的多边形渲染,最终通过Canvas渲染。而UI/UE常用的Figma,外部UI框架使用HTML+CSS+JS搭建,中间主区域不难看出是基于webGL+Canvas的实现。在这个过程中图像解析和渲染流程是通过WebAssembly进行加速,以提升用户的使用流畅感。
    2. 流文件解析

      1. bilibili视频上传,可以在视频未上传完成,解析视频并结合tensorflow.js智能推荐封面

        • webassembly 负责读取本地视频,生成图片;

        • tensorflow.js 负责加载AI训练过的model,读取图片并打分。

        • 从完全的服务端架构 => 前端架构 && 服务端兜底。

        image.png

      2. 腾讯企业邮箱,利用wasm加速解析MD5实现上传过的文件秒传

    3. 扫码、识图或配合tensorflow.js的前端识别场景

    4. ...

  1. 对其他语言平台库的直接迁移