三角形是世界上最坚固的形状,除了爱情。关于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(汇编)两个词构成,我们大致可以理解成,在浏览器中实现汇编语言的执行。汇编对应机器码,机器码对应指令集,我们下面可以稍微补充一下这方面的知识。
相关概念:指令集和操作系统
参考上图,我们一应应用和操作系统,最终的执行都需要通过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源码到执行的过程。
由于 js 的动态类型,解释器在执行代码的时候会在类型判断上带来一定的性能消耗,降低执行速度。所以 V8 引擎采用了 JIT(即时编译技术) 技术,监控一些经常执行的代码,将其编译成 CPU 直接执行的机器码,提高执行速度。但由于 js 动态类型,在某些情况下还得反优化,回到字节码进行执行。
而WebAssembly在处理时可以理解为不依赖于特定机器结构的目标汇编语言。因此浏览器在处理 WebAssembly 文件时,可以迅速的将其转换成机器编码。
这样显然,我们在计算密集型场景中,使用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))
})
我们会惊奇的发现,这是什么玩意但是居然能执行
没错,这就是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 语言就是采用的这种表达式,每条语句都是先执行最里边括号的表达式然后依次展开。
下面我们简单的解释一下这段代码:
- module,表示声明了一个根结点模块,在此模块下,我们可以定义函数体积、导出函数等功能
- func,声明一个函数块,一般写法如下
(func <signature> <locals> <body>)
- 签名:用于声明函数需要的函数及函数返回值
- 局部变量:显式声明类型的变量
- 函数体:低级指令的线性列表
- 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 来读取这个二进制文件
这样,我们就得到了这段可执行的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 步具体的操作是:
- 对于浏览器可以通过网络请求去加载字节码,对于 Nodejs 可以通过 fs 模块读取字节码文件;
- 在获取到字节码后都需要转换成 ArrayBuffer 后才能被编译,通过 WebAssembly 通过的 JS API WebAssembly.compile 编译后会通过 Promise resolve 一个 WebAssembly.Module,这个 module 不能直接被调用;
- 在获取到 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>
我们可以看到,示例代码中的算法是非常落后且浪费性能的算法,在这样的场景下,wasm较js的执行效率得到了极大提升。由此我们可以得出一个结论,wasm用于计算密集型的场景下非常有优势。
4.wasm前端场景之我见
根据我们2/3模块得到的结论,可以明显看出,wasm的优势在于性能,其原因是跳过了繁重的解析代码及编译过程,将代码接近一步到位的进行执行
因此我理解wasm有以下两种应用落地方案
-
前端运算密集型场景
-
webGL加速
- Figma:我们知道webGL本质是浏览器调用计算机硬件加速,进行浏览器端的多边形渲染,最终通过Canvas渲染。而UI/UE常用的Figma,外部UI框架使用HTML+CSS+JS搭建,中间主区域不难看出是基于webGL+Canvas的实现。在这个过程中图像解析和渲染流程是通过WebAssembly进行加速,以提升用户的使用流畅感。
-
流文件解析
-
bilibili视频上传,可以在视频未上传完成,解析视频并结合tensorflow.js智能推荐封面
-
webassembly 负责读取本地视频,生成图片;
-
tensorflow.js 负责加载AI训练过的model,读取图片并打分。
-
从完全的服务端架构 => 前端架构 && 服务端兜底。
-
-
腾讯企业邮箱,利用wasm加速解析MD5实现上传过的文件秒传
-
-
扫码、识图或配合tensorflow.js的前端识别场景
-
...
-
- 对其他语言平台库的直接迁移