大家好,我是前端西瓜哥,这次带大家来简单系统学习一下 wasm(WebAssembly)。
示例源码在这个 github 仓库,可自行下载运行:
https://github.com/F-star/wasm-demo。
wasm 文件本身并不能像 JavaScript 一样,下载完成后就立即执行。
它更类似于 webgl 编译着色器代码,需要调用 JavaScript 提供的 API 去编译执行。
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">),将来会支持,且在提案中,但不会很快。
先写一个 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)))
下面 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 的全局变量。
定义了两个方法:
(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 KBconst 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 是一个大小可变的引用数组,指向 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 是其他数据的打包,打包一起好做并行运行,提高计算速度。
这些数据可能是:
然后它们会使用类似 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 是前端的一个大方向,还是有一定学习投入的必要。
本文链接:http://www.28at.com/showinfo-26-38513-0.html理解 Wasm 基础概念,了解 Wasm 是如何被加载运行的?
声明:本网页内容旨在传播知识,不代表本站观点,若有侵权等问题请及时与本网联系,我们将在第一时间删除处理。