理解 Wasm 基础概念,了解 Wasm 是如何被加载运行的?

2023年 12月 7日 96.5k 0

大家好,我是前端西瓜哥,这次带大家来简单系统学习一下 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 的方式直接被引入(),将来会支持,且在提案中,但不会很快。

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 是前端的一个大方向,还是有一定学习投入的必要。

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论