理解 Wasm 基礎(chǔ)概念,了解 Wasm 是如何被加載運行的?
大家好,我是前端西瓜哥,這次帶大家來簡單系統(tǒng)學習一下 wasm(WebAssembly)。
示例源碼在這個 github 倉庫,可自行下載運行:
https://github.com/F-star/wasm-demo。
wasm 是如何被加載運行的?
wasm 文件本身并不能像 JavaScript 一樣,下載完成后就立即執(zhí)行。
它更類似于 webgl 編譯著色器代碼,需要調(diào)用 JavaScript 提供的 API 去編譯執(zhí)行。
wasm 被加載并執(zhí)行的過程一般為:
- 請求 wasm 文件。
- 轉(zhuǎn)換為 ArrayBuffer 格式(也就是字節(jié)數(shù)組)。
- 編譯并返回 Module 對象(異步的,可使用阻塞寫法)。
- 基于 Module 創(chuàng)建一個 instance 實例(異步的,可使用阻塞寫法) 。instance 的 exports 對象下為 wasm 暴露出來的方法和屬性。創(chuàng)建 instance 有時需要提供一個額外的 importObject 對象,后文再細說。
- 執(zhí)行 JavaScript 代碼,調(diào)用 wasm 的方法,進行數(shù)據(jù)的交換。
代碼實例:
fetch('./add.wasm')
.then(rep => rep.arrayBuffer()) // 轉(zhuǎn) ArrayBuffer
.then(bytes => WebAssembly.compile(bytes)) // 編譯為 module 對象
.then(module => WebAssembly.instantiate(module)) // 創(chuàng)建 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 文件下載過程中就開始編譯了,并最后會一次性返回編譯和實例化產(chǎn)生的 module 和 instance 對象。
wasm 目前現(xiàn)在無法像 ES Module 一樣,通過 import 的方式直接被引入(<script type="module">),將來會支持,且在提案中,但不會很快。
wat:wasm 文本格式
先寫一個 wasm。
原來我打算用 C 寫的,然后用 Emscripten 編譯,但我發(fā)現(xiàn)編譯出來的 wasm 有很多和 C 有關(guān)的冗余的代碼,且需要配合生成好的代碼量巨多的膠水 JavaScript 文件,有不少雜音。
為了更簡單些,我選擇寫 wat,然后轉(zhuǎn)為 wasm。
wat 指的是 wasm 的文本格式(WebAssembly text format)。wat 是一種低級語言,使用的是基于 S-表達式 的文本寫法,可以直接映射為 WASM 的二進制指令,你可以把它類比為匯編語言。
因為用 wat 手寫復雜邏輯并不可行,最后還是會用 C 或 Rust 這些高級語言去寫業(yè)務。
所以這里我不會講太多 wat 語法,目光更聚焦在 探究 wasm 是怎么和 js 通信的。
要實現(xiàn) wat 轉(zhuǎn) wasm,通常需要安裝 WABT(The WebAssembly Binary Toolkit)工具集,用 wat2wasm 命令行工具進行轉(zhuǎn)換。
如果覺得安裝麻煩,可以用 WABT 提供的一個在線轉(zhuǎn)換工具,貼 wat 文本上去點 download 按鈕即可得到 wasm。
官方有提供 VSCode 插件,建議安裝,可以高亮 wat 語法。
另外可以選中文件右鍵菜單可進行 wat 和 wasm 互轉(zhuǎn),但有點問題,一些正確的 wat 也會轉(zhuǎn)換失敗。
每次修改完都要手動生成 wasm 可能有點繁瑣,可以考慮安裝 wabt 命令工具,并配合 nodemon 監(jiān)聽 wat 文件,當文件被修改時自動編譯 wasm。
數(shù)字類型
(module
;; 將兩個 i32 類型的參數(shù)相加返回
(func (export "add") (param i32 i32) (result i32)
local.get 0
local.get 1
i32.add
)
)
這里定義了一個 add 方法,接收兩個 i32 類型的參數(shù),相加并返回一個 i32 類型返回值。
wat 使用的棧式機器的方式執(zhí)行的,將兩個參數(shù)依次壓入棧,然后調(diào)用相加運算,這個運算會取出棧頂?shù)膬蓚€數(shù)進行相加,然后把結(jié)果壓入棧。
最后函數(shù)會取棧頂?shù)闹底鳛榉祷刂怠?/p>
另外,目前 wasm 支持返回多個值了,JavaScript 那邊會得到一個數(shù)組。
;; 是行注釋,另外 (;注釋內(nèi)容;) 是塊注釋。
wasm 的函數(shù)參數(shù)和返回值類型支持的數(shù)字類型有:i32、i64、f32、f64,分別代表 32 位和 64 位的整數(shù)和浮點數(shù)。(還有其他不常用的類型后面再講)
生成 add.wasm 文件,然后再寫一個 js 方法去加載調(diào)用 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)); // 浮點數(shù)被 add 轉(zhuǎn)成整數(shù)了
console.log(add(false, 34)); // true 被轉(zhuǎn)成 1,false 被轉(zhuǎn)成 0
// ...
});
查看控制臺輸出:
js 的數(shù)字只有一種類型:64 位浮點數(shù),調(diào)用 wasm 函數(shù)會進行類型轉(zhuǎn)換,在上面的例子中,add 方法會將其轉(zhuǎn)為 32 位整數(shù)。
此外 js 的非數(shù)值類型也會轉(zhuǎn)為數(shù)字,通常是 0 或 1,字符串的話會嘗試轉(zhuǎn)為數(shù)字(類似調(diào)用 Number())。
wasm 函數(shù)的返回值也會做類型轉(zhuǎn)換為 js 的數(shù)字類型。如果返回的是 i64,在 JavaScript 會轉(zhuǎn)換為 BigInt。
下面是另一種可讀性更好的 wat 寫法。這里給函數(shù)參數(shù)聲明了名字,并給函數(shù)設(shè)置為變量,后面再導出(類似 js 的 export { add })。
(module
;; 將兩個 i32 類型的參數(shù)相加返回
(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 ;; 這里把數(shù)字傳給了 importObject 的 a.b 方法
)
)
導入的 js 方法需要聲明名稱和函數(shù)簽名。
實例化 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();
});
調(diào)用 wasm 定義的 getNum 方法時,該方法會調(diào)用 js 聲明的 a.b() 方法,并傳入一個整數(shù)。
a 是模塊名,b 是這個模塊的一個屬性,模塊屬性除了可以是函數(shù),也可以是其他的類型,比如線性內(nèi)存 memory、表格 table。
我們寫 C 編譯成 wasm,其中的 printf 能夠在控制臺打印出來,就是調(diào)用了導入的 js 的膠水方法,把一些二進制數(shù)據(jù)轉(zhuǎn)換成 js 字符串,然后調(diào)用 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() 創(chuàng)建 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 的函數(shù)無法接收和返回一些復雜的高級類型,比如字符串、對象,這時候就需要用到 線性內(nèi)存(memory) 了。
線性內(nèi)存需要用到 WebAssembly.Memory 對象,這個對象是 ArrayBuffer。
js 和 wasm 共享這個 ArrayBuffer,作為傳輸媒介,然后雙方都在各自的作用域進行序列和反序列化。
這也是 wasm 讓人詬病的通信問題:
如果計算本身的 CPU 密集度不高,那瓶頸就落到數(shù)據(jù)序列化反序列化以及通信上了,別說提升性能了,降低性能都可能。
wat:
(module
(import "console" "log" (func $log (param i32 i32)))
;; 傳入的 memory 大小為 1 頁
(import "js" "mem" (memory 1))
;; 在 memory 的地址 0 處設(shè)置數(shù)據(jù) "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 無法直接返回字符串,但可以修改線性內(nèi)存
// 然后再指定線性內(nèi)存的區(qū)間讓 js 去截取需要的 ArrayBuffer
// 最后 ArrayBuffer 轉(zhuǎn) 字符串
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 這邊要做字符串轉(zhuǎn) ArrayBuffer 的操作。
下面是拼接兩個字符串返回新字符串示例。
wat:
(module
(import "console" "log" (func $log (param i32 i32)))
(import "js" "mem" (memory 1))
;; 函數(shù)接受兩個字符串并拼接它們
(func $concatStrings (param $offset1 i32) (param $length1 i32) (param $offset2 i32) (param $length2 i32) (result i32) (result i32)
;; 這里的代碼是將兩個字符串拼接到內(nèi)存中,并返回新字符串的偏移量和長度
;; 注意:為了簡單起見,這里假設(shè)你有足夠的內(nèi)存空間來拼接字符串
(local $newOffset i32)
;; 假設(shè)新的偏移量是在第一個字符串的結(jié)束處
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)
;; 調(diào)用上面的拼接函數(shù)
local.get $offset1
local.get $length1
local.get $offset2
local.get $length2
call $concatStrings
;; 使用結(jié)果來調(diào)用$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; // 添加這個變量來跟蹤當前可用的內(nèi)存偏移量
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
);
}
);
其他類型也一樣的思路,只要支持轉(zhuǎn)換成 ArrayBuffer,然后轉(zhuǎn)換回來就好了。
一個 wasm 模塊只能定義一個線性內(nèi)存 memory,這個是出于簡單的考量。
表格 table
table 是一個大小可變的引用數(shù)組,指向 wasm 的代碼地址。
前面的 wat 執(zhí)行代碼時,會使用 run 指令接一個 靜態(tài) 的函數(shù)索引。但有時候函數(shù)索引需要是動態(tài),一會指向函數(shù) a,過一段時間又指向 b。
這時候我們就可以使用 table 去維護。
(table 2 funcref)
anyfunc 類型,代表可以是任何簽名的函數(shù)引用。
因為安全問題函數(shù)引用不能保存在線性內(nèi)存(memory)中。因為線性內(nèi)存保存地址沒意義,而存真正的函數(shù)數(shù)據(jù)源有可能被惡意修改,有安全問題。
所以整出了這么一個抽象的 table 數(shù)組,這個 table 無法被讀取真正的內(nèi)容,只能更新一下數(shù)組的引用。
下面是一個示例,在 wat 創(chuàng)建了一個 table,然后讓 js 根據(jù)索引調(diào)用 table 中的動態(tài)引用的函數(shù)。
wat
(module
;; table 大小為 2,且為函數(shù)引用類型。
(table $t 2 funcref)
;; table 從 0 偏移值填充聲明的兩個函數(shù)
;; 0 指向 $f1,1 指向 $f2
(elem (i32.const 0) $f1 $f2)
;; 函數(shù)聲明可以在任何位置
(func $f1 (result i32)
i32.const 22
)
(func $f2 (result i32)
i32.const 33
)
;; 定義函數(shù)類型,一個返回 i32 的函數(shù)(類比 ts 的函數(shù)類型)
(type $return_i32 (func (result i32)))
;; 暴露一個 callByIndex 方法給 js
;; callByIndex(0) 表示調(diào)用 table 上索引為 0 的函數(shù)。
(func (export "callByIndex") (param $i i32) (result i32)
;; (間接)調(diào)用 $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,讓一些索引指向新的函數(shù)。
但需要注意,這個函數(shù)需要時 wasm 導出,而不是 js 函數(shù)。
下面是對應的示例。
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 函數(shù)
table.set(0, get666); // 更換 table[0] 的函數(shù)。
console.log(call()); // 666
});
在 wat 中,anyfunc 是舊寫法,現(xiàn)在換成了 funcref,來表示函數(shù)引用。
不過 js 中創(chuàng)建 table,element 參數(shù)還得傳 "anyfunc"。
table 的這個特性可以實現(xiàn)類似 dll 的動態(tài)鏈接能力,可以在程序運行時才動態(tài)鏈接需要的代碼和數(shù)據(jù)。
引用類型
wasm 的函數(shù)現(xiàn)在支持傳 引用類型(externref)。
(func (export "callJSFunction") (param externref)
...
)
你可以傳任何 js 變量作為 externref 類型傳入 wasm 函數(shù),但該變量在 wasm 不能被讀寫和執(zhí)行,但可以把作為返回值,或是它作為參數(shù)傳給 import 進來的 js 函數(shù)。
wasm 只能對 externref 做中轉(zhuǎn),傳入以及返回回去,無法做任何其他操作。
示例:
(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('被執(zhí)行的是來自 js 函數(shù)');
});
}
);
矢量類型
v128,一個 128 比特的矢量類型。
用于 SIMD(Single Instruction, Multiple Data),它是一種計算機并行處理技術(shù),允許一個單一的操作指令同時處理多個數(shù)據(jù)元素,使用用在大量數(shù)據(jù)執(zhí)行相同操作的場景,比如矩陣運算。
v128 是其他數(shù)據(jù)的打包,打包一起好做并行運行,提高計算速度。
這些數(shù)據(jù)可能是:
- 4 個 i32(或 f32)
- 2 個 i64(或 f64)
- 16 個 i8
- 8 個 i16
然后它們會使用類似 i32x4 的指令進行批量操作:
i32x4.add (local.get $a) (local.get $b)
雖然沒有 i8 和 i16 這種類型,但它們本質(zhì)是 ArrayBuffer(字節(jié)數(shù)組)的一種高層級,js 那邊可以用 ArrayBuffer 構(gòu)造出 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;
// 首先在內(nèi)存中分配兩個向量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);
// 調(diào)用add_vectors函數(shù),傳入向量a和b在內(nèi)存中的偏移量
add_vectors(0, 16);
// 讀取和打印結(jié)果
const result = new Int32Array(memory.buffer, 0, 4);
console.log('Result:', result); // [6, 8, 10, 12]
});
多線程
wasm 支持多線程。
我們可以使用多個 Web Worker 各自創(chuàng)建 wasm 實例,讓它們共享同一段內(nèi)存 SharedArrayBuffer。
因為多線程特有的競態(tài)條件問題,我們需要用到 Atomics 對象,它提供了一些原子操作,防止沖突。
最后是 wait/notify 進行線程的掛起和喚醒。
這個用的不多,就簡單介紹一下就好了。
結(jié)尾
wasm 是 js 的一個強有力的補充,未來可期,在一些領(lǐng)域比如圖像處理、音視頻處理大有可為。
但也不得不承認 wasm 并不能很簡單地就能給應用提高性能的,因為安全原因,相比原生是有一定性能損失的。
如果沒做正確的設(shè)計甚至因為通信成本導致負優(yōu)化,你需要考量性能的瓶頸在哪里,到底是代碼寫得爛呢還是 CPU 計算就是高。v8 的 JIT 過于優(yōu)秀,導致 wasm 的光芒不夠耀眼。
另外,wasm 有不小的學習成本的。
但不可否認,wasm 是前端的一個大方向,還是有一定學習投入的必要。