大家好,我是前端西瓜哥,这次带大家来简单系统学习一下 wasm(WebAssembly)。
示例源码在这个 github 仓库,可自行下载运行:
https://github.com/F-star/wasm-demo。
wasm 是如何被加载运行的?
wasm 文件本身并不能像 JavaScript 一样,下载完成后就立即执行。
它更类似于 webgl 编译着色器代码,需要调用 JavaScript 提供的 API 去编译执行。
wasm 被加载并执行的过程一般为:
- 请求 wasm 文件。
- 转换为 ArrayBuffer 格式(也就是字节数组)。
- 编译并返回 Module 对象(异步的,可使用阻塞写法)。
- 基于 Module 创建一个 instance 实例(异步的,可使用阻塞写法) 。instance 的 exports 对象下为 wasm 暴露出来的方法和属性。创建 instance 有时需要提供一个额外的 importObject 对象,后文再细说。
- 执行 JavaScript 代码,调用 wasm 的方法,进行数据的交换。
代码实例:
fetch('./add.wasm')
.then(rep => rep.arrayBuffer()) // 转 ArrayBuffer
.then(bytes => WebAssembly.compile(bytes)) // 编译为 module 对象
.then(module => WebAssembly.instantiate(module)) // 创建 instance 对象
.then(instance => {
// 拿到 wasm 文件暴露的 add 方法
const { add } = instance.exports;
console.log(add(12, 34));
});
上面是为了让大家理解所有步骤,所以写得很繁琐。
我们有简单写法,用一个 API 把步骤 1、2、3、4 组合在一起:
WebAssembly.instantiateStreaming(fetch('./add.wasm')).then(res => {
const { module, instance } = res;
const { add } = instance.exports;
console.log(add(12, 34));
});
WebAssembly.instantiateStreaming 支持流式编译,在 wasm 文件下载过程中就开始编译了,并最后会一次性返回编译和实例化产生的 module 和 instance 对象。
wasm 目前现在无法像 ES Module 一样,通过 import 的方式直接被引入(<script type="module">),将来会支持,且在提案中,但不会很快。
wat:wasm 文本格式
先写一个 wasm。
原来我打算用 C 写的,然后用 Emscripten 编译,但我发现编译出来的 wasm 有很多和 C 有关的冗余的代码,且需要配合生成好的代码量巨多的胶水 JavaScript 文件,有不少杂音。
为了更简单些,我选择写 wat,然后转为 wasm。
wat 指的是 wasm 的文本格式(WebAssembly text format)。wat 是一种低级语言,使用的是基于 S-表达式 的文本写法,可以直接映射为 WASM 的二进制指令,你可以把它类比为汇编语言。
因为用 wat 手写复杂逻辑并不可行,最后还是会用 C 或 Rust 这些高级语言去写业务。
所以这里我不会讲太多 wat 语法,目光更聚焦在 探究 wasm 是怎么和 js 通信的。
要实现 wat 转 wasm,通常需要安装 WABT(The WebAssembly Binary Toolkit)工具集,用 wat2wasm 命令行工具进行转换。
如果觉得安装麻烦,可以用 WABT 提供的一个在线转换工具,贴 wat 文本上去点 download 按钮即可得到 wasm。
官方有提供 VSCode 插件,建议安装,可以高亮 wat 语法。
另外可以选中文件右键菜单可进行 wat 和 wasm 互转,但有点问题,一些正确的 wat 也会转换失败。
每次修改完都要手动生成 wasm 可能有点繁琐,可以考虑安装 wabt 命令工具,并配合 nodemon 监听 wat 文件,当文件被修改时自动编译 wasm。
数字类型
(module
;; 将两个 i32 类型的参数相加返回
(func (export "add") (param i32 i32) (result i32)
local.get 0
local.get 1
i32.add
)
)
这里定义了一个 add 方法,接收两个 i32 类型的参数,相加并返回一个 i32 类型返回值。
wat 使用的栈式机器的方式执行的,将两个参数依次压入栈,然后调用相加运算,这个运算会取出栈顶的两个数进行相加,然后把结果压入栈。
最后函数会取栈顶的值作为返回值。
另外,目前 wasm 支持返回多个值了,JavaScript 那边会得到一个数组。
;; 是行注释,另外 (;注释内容;) 是块注释。
wasm 的函数参数和返回值类型支持的数字类型有:i32、i64、f32、f64,分别代表 32 位和 64 位的整数和浮点数。(还有其他不常用的类型后面再讲)
生成 add.wasm 文件,然后再写一个 js 方法去加载调用 wasm 的方法:
WebAssembly.instantiateStreaming(fetch('./add.wasm')).then(res => {
const { instance } = res;
const { add } = instance.exports;
console.log(add(100, 34));
console.log(add(100.233, 34)); // 浮点数被 add 转成整数了
console.log(add(false, 34)); // true 被转成 1,false 被转成 0
// ...
});
查看控制台输出:
js 的数字只有一种类型:64 位浮点数,调用 wasm 函数会进行类型转换,在上面的例子中,add 方法会将其转为 32 位整数。
此外 js 的非数值类型也会转为数字,通常是 0 或 1,字符串的话会尝试转为数字(类似调用 Number())。
wasm 函数的返回值也会做类型转换为 js 的数字类型。如果返回的是 i64,在 JavaScript 会转换为 BigInt。
下面是另一种可读性更好的 wat 写法。这里给函数参数声明了名字,并给函数设置为变量,后面再导出(类似 js 的 export { add })。
(module
;; 将两个 i32 类型的参数相加返回
(func $add (param $a i32) (param $b i32) (result i32)
local.get $a
local.get $b
i32.add)
(export "add" (func $add))
)
导入 JavaScript 方法
下面 wat 声明了需要导入的 JavaScript 方法 a.b()。
(module
;; wasm 会拿到 importObject 的 a.b 方法
(import "a" "b" (func $getNum (param i32)))
(func (export "getNum")
i32.const 114514
call $getNum ;; 这里把数字传给了 importObject 的 a.b 方法
)
)
导入的 js 方法需要声明名称和函数签名。
实例化 module 时提供前面提到的 importObject,去指定这个方法。
const importObject = {
a: {
b: (num) => {
console.log('a.b', num) // 控制台输出:“a.b 114514”
}
}
}
WebAssembly.instantiateStreaming(fetch('./import.wasm'), importObject).then(res => {
const { getNum } = res.instance.exports;
getNum();
});
调用 wasm 定义的 getNum 方法时,该方法会调用 js 声明的 a.b() 方法,并传入一个整数。
a 是模块名,b 是这个模块的一个属性,模块属性除了可以是函数,也可以是其他的类型,比如线性内存 memory、表格 table。
我们写 C 编译成 wasm,其中的 printf 能够在控制台打印出来,就是调用了导入的 js 的胶水方法,把一些二进制数据转换成 js 字符串,然后调用 console.log() 输出。
全局变量
将从 importObject.js.global 传过来的变量作为 wasm 的全局变量。
定义了两个方法:
- getGlobal:返回这个全局变量;
- incGlobal:给全局变量 + 1。
(module
(global $g (import "js" "global") (mut i32))
(func (export "getGlobal") (result i32)
(global.get $g)
)
(func (export "incGlobal")
(global.set $g
(
i32.add
(global.get $g)
(i32.const 1)
)
)
)
)
js 中用 new WebAssembly.Global() 创建 global 对象然后导入。
const importObject = {
js: {
// 一个初始值为 233 的 i32 变量
global: new WebAssembly.Global(
{
value: 'i32',
mutable: true,
},
233
),
},
};
WebAssembly.instantiateStreaming(fetch('./global.wasm'), importObject).then(
(res) => {
const { instance } = res;
console.log(instance);
const { getGlobal, incGlobal } = res.instance.exports;
console.log('全局变量');
console.log(getGlobal()); // 输出:233
incGlobal();
incGlobal();
console.log(getGlobal()); // 输出:235
}
);
也可以在 js 中直接用 importObject.js.global.value 拿到全局变量的值。
也可以在 wasm 中定义 global 变量,global 变量可以定义多个。
(global $g2 (mut i32) (i32.const 99))
复杂变量类型
wasm 的函数无法接收和返回一些复杂的高级类型,比如字符串、对象,这时候就需要用到 线性内存(memory) 了。
线性内存需要用到 WebAssembly.Memory 对象,这个对象是 ArrayBuffer。
js 和 wasm 共享这个 ArrayBuffer,作为传输媒介,然后双方都在各自的作用域进行序列和反序列化。
这也是 wasm 让人诟病的通信问题:
如果计算本身的 CPU 密集度不高,那瓶颈就落到数据序列化反序列化以及通信上了,别说提升性能了,降低性能都可能。
wat:
(module
(import "console" "log" (func $log (param i32 i32)))
;; 传入的 memory 大小为 1 页
(import "js" "mem" (memory 1))
;; 在 memory 的地址 0 处设置数据 "Hi"
(data (i32.const 0) "Hi")
(func (export "writeHi")
i32.const 0 ;; 字符串起始位置
i32.const 2 ;; 字符串长度
call $log
)
)
js:
// memory 对象,大小为 1 页(page),1 页为 64 KB
const memory = new WebAssembly.Memory({ initial: 1 });
// wasm 无法直接返回字符串,但可以修改线性内存
// 然后再指定线性内存的区间让 js 去截取需要的 ArrayBuffer
// 最后 ArrayBuffer 转 字符串
function consoleLogString(offset, length) {
const bytes = new Uint8Array(memory.buffer, offset, length);
const string = new TextDecoder('utf-8').decode(bytes);
console.log(string);
}
const importObject = {
console: { log: consoleLogString },
js: { mem: memory },
};
WebAssembly.instantiateStreaming(fetch('./memory.wasm'), importObject).then(
(res) => {
res.instance.exports.writeHi();
}
);
也可以在 js 传字符串给 wasm,但 js 这边要做字符串转 ArrayBuffer 的操作。
下面是拼接两个字符串返回新字符串示例。
wat:
(module
(import "console" "log" (func $log (param i32 i32)))
(import "js" "mem" (memory 1))
;; 函数接受两个字符串并拼接它们
(func $concatStrings (param $offset1 i32) (param $length1 i32) (param $offset2 i32) (param $length2 i32) (result i32) (result i32)
;; 这里的代码是将两个字符串拼接到内存中,并返回新字符串的偏移量和长度
;; 注意:为了简单起见,这里假设你有足够的内存空间来拼接字符串
(local $newOffset i32)
;; 假设新的偏移量是在第一个字符串的结束处
local.get $offset1
local.get $length1
i32.add
local.set $newOffset
;; 将第二个字符串拷贝到新的偏移量处
local.get $newOffset
local.get $offset2
local.get $length2
memory.copy
;; 返回新的偏移量和长度
local.get $offset1
local.get $length1
local.get $length2
i32.add
)
(func (export "concatAndLog") (param $offset1 i32) (param $length1 i32) (param $offset2 i32) (param $length2 i32)
;; 调用上面的拼接函数
local.get $offset1
local.get $length1
local.get $offset2
local.get $length2
call $concatStrings
;; 使用结果来调用$log
call $log
)
)
js:
const memory = new WebAssembly.Memory({ initial: 1 });
function consoleLogString(offset, length) {
// console.log(offset, length);
const bytes = new Uint8Array(memory.buffer, offset, length);
const string = new TextDecoder('utf-8').decode(bytes);
console.log(string); // 输出 Hello, WebAssembly!
}
let currentOffset = 0; // 添加这个变量来跟踪当前可用的内存偏移量
function stringToMemory(str, mem) {
const encoder = new TextEncoder();
const bytes = encoder.encode(str);
new Uint8Array(mem.buffer, currentOffset, bytes.length).set(bytes);
const returnOffset = currentOffset;
currentOffset += bytes.length; // 更新偏移量
return { offset: returnOffset, length: bytes.length };
}
const importObject = {
console: { log: consoleLogString },
js: { mem: memory },
};
WebAssembly.instantiateStreaming(fetch('./concat.wasm'), importObject).then(
(res) => {
const str1 = 'Hello, ';
const str2 = 'WebAssembly!';
const mem1 = stringToMemory(str1, memory);
const mem2 = stringToMemory(str2, memory);
res.instance.exports.concatAndLog(
mem1.offset,
mem1.length,
mem2.offset,
mem2.length
);
}
);
其他类型也一样的思路,只要支持转换成 ArrayBuffer,然后转换回来就好了。
一个 wasm 模块只能定义一个线性内存 memory,这个是出于简单的考量。
表格 table
table 是一个大小可变的引用数组,指向 wasm 的代码地址。
前面的 wat 执行代码时,会使用 run 指令接一个 静态 的函数索引。但有时候函数索引需要是动态,一会指向函数 a,过一段时间又指向 b。
这时候我们就可以使用 table 去维护。
(table 2 funcref)
anyfunc 类型,代表可以是任何签名的函数引用。
因为安全问题函数引用不能保存在线性内存(memory)中。因为线性内存保存地址没意义,而存真正的函数数据源有可能被恶意修改,有安全问题。
所以整出了这么一个抽象的 table 数组,这个 table 无法被读取真正的内容,只能更新一下数组的引用。
下面是一个示例,在 wat 创建了一个 table,然后让 js 根据索引调用 table 中的动态引用的函数。
wat
(module
;; table 大小为 2,且为函数引用类型。
(table $t 2 funcref)
;; table 从 0 偏移值填充声明的两个函数
;; 0 指向 $f1,1 指向 $f2
(elem (i32.const 0) $f1 $f2)
;; 函数声明可以在任何位置
(func $f1 (result i32)
i32.const 22
)
(func $f2 (result i32)
i32.const 33
)
;; 定义函数类型,一个返回 i32 的函数(类比 ts 的函数类型)
(type $return_i32 (func (result i32)))
;; 暴露一个 callByIndex 方法给 js
;; callByIndex(0) 表示调用 table 上索引为 0 的函数。
(func (export "callByIndex") (param $i i32) (result i32)
;; (间接)调用 $i 索引值在 table 中指向的方法
call_indirect (type $return_i32)
)
)
js:
WebAssembly.instantiateStreaming(fetch('./table.wasm')).then((res) => {
const { callByIndex } = res.instance.exports;
console.log(callByIndex(0)); // 22
console.log(callByIndex(1)); // 33
});
也可以在 js 中更新 table,让一些索引指向新的函数。
但需要注意,这个函数需要时 wasm 导出,而不是 js 函数。
下面是对应的示例。
wat:
(module
;; 导入 table
(import "js" "table" (table 1 funcref))
(elem (i32.const 0) $f1)
(func $f1 (result i32)
i32.const 22
)
(type $return_i32 (func (result i32)))
(func (export "call") (result i32)
i32.const 0
call_indirect (type $return_i32)
)
(func (export "get666") (result i32)
i32.const 666
)
)
js:
const table = new WebAssembly.Table({ initial: 1, element: 'anyfunc' });
const importObject = {
js: { table },
};
WebAssembly.instantiateStreaming(
fetch('./outer-table.wasm'),
importObject
).then((res) => {
const { call, get666 } = res.instance.exports;
console.log(call()); // 22
console.log(table.get(0)); // 获取 wasm 函数
table.set(0, get666); // 更换 table[0] 的函数。
console.log(call()); // 666
});
在 wat 中,anyfunc 是旧写法,现在换成了 funcref,来表示函数引用。
不过 js 中创建 table,element 参数还得传 "anyfunc"。
table 的这个特性可以实现类似 dll 的动态链接能力,可以在程序运行时才动态链接需要的代码和数据。
引用类型
wasm 的函数现在支持传 引用类型(externref)。
(func (export "callJSFunction") (param externref)
...
)
你可以传任何 js 变量作为 externref 类型传入 wasm 函数,但该变量在 wasm 不能被读写和执行,但可以把作为返回值,或是它作为参数传给 import 进来的 js 函数。
wasm 只能对 externref 做中转,传入以及返回回去,无法做任何其他操作。
示例:
(module
(type $jsFunc (func (param externref)))
(func $invoke (import "js" "invokeFunction") (type $jsFunc))
(func (export "callJSFunction") (param externref)
local.get 0
call $invoke
)
)
const importObject = {
js: {
invokeFunction: (fn) => {
fn();
},
},
};
WebAssembly.instantiateStreaming(fetch('./type.wasm'), importObject).then(
(res) => {
const { instance } = res;
const { callJSFunction } = instance.exports;
callJSFunction(() => {
console.log('被执行的是来自 js 函数');
});
}
);
矢量类型
v128,一个 128 比特的矢量类型。
用于 SIMD(Single Instruction, Multiple Data),它是一种计算机并行处理技术,允许一个单一的操作指令同时处理多个数据元素,使用用在大量数据执行相同操作的场景,比如矩阵运算。
v128 是其他数据的打包,打包一起好做并行运行,提高计算速度。
这些数据可能是:
- 4 个 i32(或 f32)
- 2 个 i64(或 f64)
- 16 个 i8
- 8 个 i16
然后它们会使用类似 i32x4 的指令进行批量操作:
i32x4.add (local.get $a) (local.get $b)
虽然没有 i8 和 i16 这种类型,但它们本质是 ArrayBuffer(字节数组)的一种高层级,js 那边可以用 ArrayBuffer 构造出 Int8Array 对象。
所以 wat 提供了对应的指令,比如 i8x16.add。
示例:
(module
(memory 1)
(export "memory" (memory 0))
(func (export "add_vectors")
(param $aOffset i32) (param $bOffset i32)
(local $a v128) (local $b v128)
(local.set $a (v128.load (local.get $aOffset)))
(local.set $b (v128.load (local.get $bOffset)))
(v128.store (i32.const 0) (i32x4.add (local.get $a) (local.get $b)))
)
)
WebAssembly.instantiateStreaming(fetch('./v128.wasm')).then((res) => {
const { add_vectors, memory } = res.instance.exports;
// 首先在内存中分配两个向量a和b
const a = new Int32Array(memory.buffer, 0, 4);
const b = new Int32Array(memory.buffer, 16, 4);
// 初始化向量a和b的值
a.set([1, 2, 3, 4]);
b.set([5, 6, 7, 8]);
console.log('Vector A:', a);
console.log('Vector B:', b);
// 调用add_vectors函数,传入向量a和b在内存中的偏移量
add_vectors(0, 16);
// 读取和打印结果
const result = new Int32Array(memory.buffer, 0, 4);
console.log('Result:', result); // [6, 8, 10, 12]
});
多线程
wasm 支持多线程。
我们可以使用多个 Web Worker 各自创建 wasm 实例,让它们共享同一段内存 SharedArrayBuffer。
因为多线程特有的竞态条件问题,我们需要用到 Atomics 对象,它提供了一些原子操作,防止冲突。
最后是 wait/notify 进行线程的挂起和唤醒。
这个用的不多,就简单介绍一下就好了。
结尾
wasm 是 js 的一个强有力的补充,未来可期,在一些领域比如图像处理、音视频处理大有可为。
但也不得不承认 wasm 并不能很简单地就能给应用提高性能的,因为安全原因,相比原生是有一定性能损失的。
如果没做正确的设计甚至因为通信成本导致负优化,你需要考量性能的瓶颈在哪里,到底是代码写得烂呢还是 CPU 计算就是高。v8 的 JIT 过于优秀,导致 wasm 的光芒不够耀眼。
另外,wasm 有不小的学习成本的。
但不可否认,wasm 是前端的一个大方向,还是有一定学习投入的必要。