為什么說 Wasm 是 Web 的未來(lái)?
本文轉(zhuǎn)載自微信公眾號(hào)「程序員巴士」,作者一只圖雀。轉(zhuǎn)載本文請(qǐng)聯(lián)系程序員巴士公眾號(hào)。
大家好,我是皮湯。過去兩個(gè)月,我主要在研究 WebAssembly(WASM) 相關(guān)的內(nèi)容,了解到 WASM 填補(bǔ)了 Web 一直以來(lái)缺失的部分:媲美原生的性能。對(duì)這方面有了一點(diǎn)心得,分享給大家。
這篇文章打算講什么?
了解 WebAssembly 的前世今生,這一致力于讓 Web 更廣泛使用的偉大創(chuàng)造是如何在整個(gè) Web/Node.js 的生命周期起作用的,探討為什么 WASM 是 Web 的未來(lái)?
在整篇文章的講解過程中,你可以了解到 WebAssembly 原生、AssemblyScript、Emscripten 編譯器。
最后還對(duì) WebAssembly 的未來(lái)進(jìn)行了展望,列舉了一些令人興奮的技術(shù)的發(fā)展方向。
為什么需要 WebAssembly ?
動(dòng)態(tài)語(yǔ)言之踵
首先先來(lái)看一下 JS 代碼的執(zhí)行過程:
上述是 Microsoft Edge 之前的 ChakraCore 引擎結(jié)構(gòu),目前 Microsoft Edge 的 JS 引擎已經(jīng)切換為 V8 。
整體的流程就是:
- 拿到了 JS 源代碼,交給 Parser,生成 AST
- ByteCode Compiler 將 AST 編譯為字節(jié)碼(ByteCode)
- ByteCode 進(jìn)入翻譯器,翻譯器將字節(jié)碼一行一行翻譯(Interpreter)為機(jī)器碼(Machine Code),然后執(zhí)行
但其實(shí)我們平時(shí)寫的代碼有很多可以優(yōu)化的地方,如多次執(zhí)行同一個(gè)函數(shù),那么可以將這個(gè)函數(shù)生成的 Machine Code 標(biāo)記可優(yōu)化,然后打包送到 JIT Compiler(Just-In-Time),下次再執(zhí)行這個(gè)函數(shù)的時(shí)候,就不需要經(jīng)過 Parser-Compiler-Interpreter 這個(gè)過程,可以直接執(zhí)行這份準(zhǔn)備好的 Machine Code,大大提高的代碼的執(zhí)行效率。
但是上述的 JIT 優(yōu)化只能針對(duì)靜態(tài)類型的變量,如我們要優(yōu)化的函數(shù),它只有兩個(gè)參數(shù),每個(gè)參數(shù)的類型是確定的,而 JavaScript 卻是一門動(dòng)態(tài)類型的語(yǔ)言,這也意味著,函數(shù)在執(zhí)行過程中,可能類型會(huì)動(dòng)態(tài)變化,參數(shù)可能變成三個(gè),第一個(gè)參數(shù)的類型可能從對(duì)象變?yōu)閿?shù)組,這就會(huì)導(dǎo)致 JIT 失效,需要重新進(jìn)行 Parser-Compiler-Interpreter-Execuation,而 Parser-Compiler 這兩步是整個(gè)代碼執(zhí)行過程中最耗費(fèi)時(shí)間的兩步,這也是為什么 JavaScript 語(yǔ)言背景下,Web 無(wú)法執(zhí)行一些高性能應(yīng)用,如大型游戲、視頻剪輯等。
靜態(tài)語(yǔ)言優(yōu)化
通過上面的說明了解到,其實(shí) JS 執(zhí)行慢的一個(gè)主要原因是因?yàn)槠鋭?dòng)態(tài)語(yǔ)言的特性,導(dǎo)致 JIT 失效,所以如果我們能夠?yàn)?JS 引入靜態(tài)特性,那么可以保持有效的 JIT,勢(shì)必會(huì)加快 JS 的執(zhí)行速度,這個(gè)時(shí)候 asm.js 出現(xiàn)了。
asm.js 只提供兩種數(shù)據(jù)類型:
- 32 位帶符號(hào)整數(shù)
- 64 位帶符號(hào)浮點(diǎn)數(shù)
其他類似如字符串、布爾值或?qū)ο蠖际且詳?shù)值的形式保存在內(nèi)存中,通過 TypedArray 調(diào)用。整數(shù)和浮點(diǎn)數(shù)表示如下:
ArrayBuffer對(duì)象、TypedArray視圖和DataView 視圖是 JavaScript 操作二進(jìn)制數(shù)據(jù)的一個(gè)接口,以數(shù)組的語(yǔ)法處理二進(jìn)制數(shù)據(jù),統(tǒng)稱為二進(jìn)制數(shù)組。參考 ArrayBuffer 。
- var a = 1;
- var x = a | 0; // x 是32位整數(shù)
- var y = +a; // y 是64位浮點(diǎn)數(shù)
而函數(shù)的寫法如下:
- function add(x, y) {
- x = x | 0;
- y = y | 0;
- return (x + y) | 0;
- }
上述的函數(shù)參數(shù)及返回值都需要聲明類型,這里都是 32 位整數(shù)。
而且 asm.js 也不提供垃圾回收機(jī)制,內(nèi)存操作都是由開發(fā)者自己控制,通過 TypedArray 直接讀寫內(nèi)存:
- var buffer = new ArrayBuffer(32768); // 申請(qǐng) 32 MB 內(nèi)存
- var HEAP8 = new Int8Array(buffer); // 每次讀 1 個(gè)字節(jié)的視圖 HEAP8
- function compiledCode(ptr) {
- HEAP[ptr] = 12;
- return HEAP[ptr + 4];
- }
從上可見,asm.js 是一個(gè)嚴(yán)格的 JavaScript 子集要求變量的類型在運(yùn)行時(shí)確定且不可改變,且去除了 JavaScript 擁有的垃圾回收機(jī)制,需要開發(fā)者手動(dòng)管理內(nèi)存。這樣 JS 引擎就可以基于 asm.js 的代碼進(jìn)行大量的 JIT 優(yōu)化,據(jù)統(tǒng)計(jì) asm.js 在瀏覽器里面的運(yùn)行速度,大約是原生代碼(機(jī)器碼)的 50% 左右。
推陳出新
但是不管 asm.js 再怎么靜態(tài)化,干掉一些需要耗時(shí)的上層抽象(垃圾收集等),也還是屬于 JavaScript 的范疇,代碼執(zhí)行也需要 Parser-Compiler 這兩個(gè)過程,而這兩個(gè)過程也是代碼執(zhí)行中最耗時(shí)的。
為了極致的性能,Web 的前沿開發(fā)者們拋棄 JavaScript,創(chuàng)造了一門可以直接和 Machine Code 打交道的匯編語(yǔ)言 WebAssembly,直接干掉 Parser-Compiler,同時(shí) WebAssembly 是一門強(qiáng)類型的靜態(tài)語(yǔ)言,能夠進(jìn)行最大限度的 JIT 優(yōu)化,使得 WebAssembly 的速度能夠無(wú)限逼近 C/C++ 等原生代碼。
相當(dāng)于下面的過程:
無(wú)需 Parser-Compiler,直接就可以執(zhí)行,同時(shí)干掉了垃圾回收機(jī)制,而且 WASM 的靜態(tài)強(qiáng)類型語(yǔ)言的特性可以進(jìn)行最大程度的 JIT 優(yōu)化。
WebAssembly 初探
我們可以通過一張圖來(lái)直觀了解 WebAssembly 在 Web 中的位置:
WebAssembly(也稱為 WASM),是一種可在 Web 中運(yùn)行的全新語(yǔ)言格式,同時(shí)兼具體積小、性能高、可移植性強(qiáng)等特點(diǎn),在底層上類似 Web 中的 JavaScript,同時(shí)也是 W3C 承認(rèn)的 Web 中的第 4 門語(yǔ)言。
為什么說在底層上類似 JavaScript,主要有以下幾個(gè)理由:
- 和 JavaScript 在同一個(gè)層次執(zhí)行:JS Engine,如 Chrome 的 V8
- 和 JavaScript 一樣可以操作各種 Web API
同時(shí) WASM 也可以運(yùn)行在 Node.js 或其他 WASM Runtime 中。
WebAssembly 文本格式
實(shí)際上 WASM 是一堆可以直接執(zhí)行二進(jìn)制格式,但是為了易于在文本編輯器或開發(fā)者工具里面展示,WASM 也設(shè)計(jì)了一種 “中間態(tài)” 的文本格式,以 .``wat 或 .wast 為擴(kuò)展命名,然后通過 wabt 等工具,將文本格式下的 WASM 轉(zhuǎn)為二進(jìn)制格式的可執(zhí)行代碼,以 .wasm 為擴(kuò)展的格式。
來(lái)看一段 WASM 文本格式下的模塊代碼:
- (module
- (func $i (import "imports" "imported_func") (param i32))
- (func (export "exported_func")
- i32.const 42
- call $i
- )
- )
上述代碼邏輯如下:
- 首先定義了一個(gè) WASM 模塊,然后從一個(gè) imports JS 模塊導(dǎo)入了一個(gè)函數(shù) imported_func ,將其命名為 $i ,接收參數(shù) i32
- 然后導(dǎo)出一個(gè)名為 exported_func 的函數(shù),可以從 Web App,如 JS 中導(dǎo)入這個(gè)函數(shù)使用
- 接著為參數(shù) i32 傳入 42,然后調(diào)用函數(shù) $i
我們通過 wabt 將上述文本格式轉(zhuǎn)為二進(jìn)制代碼:
- 將上述代碼復(fù)制到一個(gè)新建的,名為 simple.wat 的文件中保存
- 使用 wabt 進(jìn)行編譯轉(zhuǎn)換
當(dāng)你安裝好 wabt 之后,運(yùn)行如下命令進(jìn)行編譯:
- wat2wasm simple.wat -o simple.wasm
雖然轉(zhuǎn)換成了二進(jìn)制,但是無(wú)法在文本編輯器中查看其內(nèi)容,為了查看二進(jìn)制的內(nèi)容,我們可以在編譯時(shí)加上 -v 選項(xiàng),讓內(nèi)容在命令行輸出:
- wat2wasm simple.wat -v
輸出結(jié)果如下:
可以看到,WebAssembly 其實(shí)是二進(jìn)制格式的代碼,即使其提供了稍為易讀的文本格式,也很難真正用于實(shí)際的編碼,更別提開發(fā)效率了。
將 WebAssembly 作為編程語(yǔ)言的一種嘗試
因?yàn)樯鲜龅亩M(jìn)制和文本格式都不適合編碼,所以不適合將 WASM 作為一門可正常開發(fā)的語(yǔ)言。
為了突破這個(gè)限制,AssemblyScript 走到臺(tái)前,AssemblyScript 是 TypeScript 的一種變體,為 JavaScript 添加了 WebAssembly 類型 , 可以使用 Binaryen 將其編譯成 WebAssembly。
WebAssembly 類型大致如下:
- i32、u32、i64、v128 等
- 小整數(shù)類型:i8、u8 等
- 變量整數(shù)類型:isize、usize 等
Binaryen 會(huì)前置將 AssemblyScript 靜態(tài)編譯成強(qiáng)類型的 WebAssembly 二進(jìn)制,然后才會(huì)交給 JS 引擎去執(zhí)行,所以說雖然 AssemblyScript 帶來(lái)了一層抽象,但是實(shí)際用于生產(chǎn)的代碼依然是 WebAssembly,保有 WebAssembly 的性能優(yōu)勢(shì)。AssemblyScript 被設(shè)計(jì)的和 TypeScript 非常相似,提供了一組內(nèi)建的函數(shù)可以直接操作 WebAssembly 以及編譯器的特性.
內(nèi)建函數(shù):
- 靜態(tài)類型檢查:
- function isInteger
(value?: T): ``bool 等
- function isInteger
- 實(shí)用函數(shù):
- function sizeof
(): usize 等
- function sizeof
- 操作 WebAssembly:
- function select
(ifTrue: T, ifFalse: T, condition: ``bool``): T 等 - function load
(ptr: usize, immOffset?: usize): T 等 - function clz
(value: T): T 等 - 數(shù)學(xué)操作
- 內(nèi)存操作
- 控制流
- SIMD
- Atomics
- Inline instructions
- function select
然后基于這套內(nèi)建的函數(shù)向上構(gòu)建一套標(biāo)準(zhǔn)庫(kù)。
標(biāo)準(zhǔn)庫(kù):
- Globals
- Array
- ArrayBuffer
- DataView
- Date
- Error
- Map
- Math
- Number
- Set
- String
- Symbol
- TypedArray
如一個(gè)典型的 Array 的使用如下:
- var arr = new Array<string>(10)
- // arr[0]; // 會(huì)出錯(cuò) 😢
- // 進(jìn)行初始化
- for (let i = 0; i < arr.length; ++i) {
- arr[i] = ""
- }
- arr[0]; // 可以正確工作 😊
可以看到 AssemblyScript 在為 JavaScript 添加類似 TypeScript 那樣的語(yǔ)法,然后在使用上需要保持和 C/C++ 等靜態(tài)強(qiáng)類型的要求,如不初始化,進(jìn)行內(nèi)存分配就訪問就會(huì)報(bào)錯(cuò)。
還有一些擴(kuò)展庫(kù),如 Node.js 的 process、crypto 等,JS 的 console,還有一些和內(nèi)存相關(guān)的 StaticArray、heap 等。
可以看到通過上面基礎(chǔ)的類型、內(nèi)建庫(kù)、標(biāo)準(zhǔn)庫(kù)和擴(kuò)展庫(kù),AssemblyScript 基本上構(gòu)造了 JavaScript 所擁有的的全部特性,同時(shí) AssemblyScript 提供了類似 TypeScript 的語(yǔ)法,在寫法上嚴(yán)格遵循強(qiáng)類型靜態(tài)語(yǔ)言的規(guī)范。
值得一提的是,因?yàn)楫?dāng)前 WebAssembly 的 ES 模塊規(guī)范依然在草案中,AssemblyScript 自行進(jìn)行了模塊的實(shí)現(xiàn),例如導(dǎo)出一個(gè)模塊:
- // env.ts
- export declare function doSomething(foo: i32): void { /* ... 函數(shù)體 */ }
導(dǎo)入一個(gè)模塊:
- import { doSomething } from "./env";
一個(gè)大段代碼、使用類的例子:
- class Animal<T> {
- static ONE: i32 = 1;
- static add(a: i32, b: i32): i32 { return a + b + Animal.ONE; }
- two: i16 = 2; // 6 instanceSub<T>(a: T, b: T): T { return a - b + <T>Animal.ONE; } // tsc does not allow this }
- export function staticOne(): i32 {
- return Animal.ONE;
- }
- export function staticAdd(a: i32, b: i32): i32 {
- return Animal.add(a, b);
- }
- export function instanceTwo(): i32 {
- let animal = new Animal<i32>();
- return animal.two;
- }
- export function instanceSub(a: f32, b: f32): f32 {
- let animal = new Animal<f32>();
- return animal.instanceSub<f32>(a, b);
- }
AssemblyScript 為我們打開了一扇新的大門,可以以 TS 形式的語(yǔ)法,遵循靜態(tài)強(qiáng)類型的規(guī)范進(jìn)行高效編碼,同時(shí)又能夠便捷的操作 WebAssembly/編譯器相關(guān)的 API,代碼寫完之后,通過 Binaryen 編譯器將其編譯為 WASM 二進(jìn)制,然后獲取到 WASM 的執(zhí)行性能。
得益于 AssemblyScript 兼具靈活性與性能,目前使用 AssemblyScript 構(gòu)建的應(yīng)用生態(tài)已經(jīng)初具繁榮,目前在區(qū)塊鏈、構(gòu)建工具、編輯器、模擬器、游戲、圖形編輯工具、庫(kù)、IoT、測(cè)試工具等方面都有大量使用 AssemblyScript 構(gòu)建的產(chǎn)物:https://www.assemblyscript.org/built-with-assemblyscript.html#games
上面是使用 AssemblyScript 構(gòu)建的一個(gè)五子棋游戲。
一種鬼才哲學(xué):將 C/C++ 代碼跑在瀏覽器
雖然 AssemblyScript 的出現(xiàn)極大的改善了 WebAssembly 在高效率編碼方面的缺陷,但是作為一門新的編程語(yǔ)言,其最大的劣勢(shì)就是生態(tài)、開發(fā)者與積累。
WebAssembly 的設(shè)計(jì)者顯然在設(shè)計(jì)上同時(shí)考慮到了各種完善的情況,既然 WebAssembly 是一種二進(jìn)制格式,那么其就可以作為其他語(yǔ)言的編譯目標(biāo),如果能夠構(gòu)建一種編譯器,能夠?qū)⒁延械摹⒊墒斓?、且兼具海量的開發(fā)者和強(qiáng)大的生態(tài)的語(yǔ)言編譯到 WebAssembly 使用,那么相當(dāng)于可以直接復(fù)用這個(gè)語(yǔ)言多年的積累,并用它們來(lái)完善 WebAssembly 生態(tài),將它們運(yùn)行在 Web、Node.js 中。
幸運(yùn)的是,針對(duì) C/C++ 已經(jīng)有 Emscripten 這樣優(yōu)秀的編譯器存在了。
可以通過下面這張圖直觀的闡述 Emscripten 在開發(fā)鏈路中的地位:
即將 C/C++ 的代碼(或者 Rust/Go 等)編譯成 WASM,然后通過 JS 膠水代碼將 WASM 跑在瀏覽器中(或 Node.js)的 runtime,如 ffmpeg 這個(gè)使用 C 編寫音視頻轉(zhuǎn)碼工具,通過 Emscripten 編譯器編譯到 Web 中使用,可直接在瀏覽器前端轉(zhuǎn)碼音視頻。
上述的 JS “Gule” 代碼是必須的,因?yàn)槿绻枰獙?C/C++ 編譯到 WASM,還能在瀏覽器中執(zhí)行,就得實(shí)現(xiàn)映射到 C/C++ 相關(guān)操作的 Web API,這樣才能保證執(zhí)行有效,這些膠水代碼目前包含一些比較流行的 C/C++ 庫(kù),如 SDL、OpenGL、OpenAL、以及 POSIX 的一部分 API。
目前使用 WebAssembly 最大的場(chǎng)景也是這種將 C/C++ 模塊編譯到 WASM 的方式,比較有名的例子有 Unreal Engine 4、Unity 之類的大型庫(kù)或應(yīng)用。
WebAssembly 會(huì)取代 JavaScript 嗎?
答案是不會(huì)。
根據(jù)上面的層層闡述,實(shí)際上 WASM 的設(shè)計(jì)初衷就可以梳理為以下幾點(diǎn):
- 最大程度的復(fù)用現(xiàn)有的底層語(yǔ)言生態(tài),如 C/C++ 在游戲開發(fā)、編譯器設(shè)計(jì)等方面的積淀
- 在 Web、Node.js 或其他 WASM runtime 獲得近乎于原生的性能,也就是可以讓瀏覽器也能跑大型游戲、圖像剪輯等應(yīng)用
- 還有最大程度的兼容 Web、保證安全
- 同時(shí)在開發(fā)上(如果需要開發(fā))易于讀寫和可調(diào)試,這一點(diǎn) AssemblyScript 走得更遠(yuǎn)
所以從初衷出發(fā),WebAssembly 的作用更適合下面這張圖:
WASM 橋接各種系統(tǒng)編程語(yǔ)言的生態(tài),近一步補(bǔ)齊了 Web 開發(fā)生態(tài)之外,還為 JS 提供性能的補(bǔ)充,正是 Web 發(fā)展至今所缺失的重要的一塊版圖。
Rust Web Framework:https://github.com/yewstack/yew
深入探索 Emscripten
地址:https://github.com/emscripten-core/emscripten
下面所有的 demo 都可以在倉(cāng)庫(kù):https://code.byted.org/huangwei.fps/webassembly-demos/tree/master 找到
Star:21.4K
維護(hù):活躍
Emscripten 是一個(gè)開源的,跨平臺(tái)的,用于將 C/C++ 編譯為 WebAssembly 的編譯器工具鏈,由 LLVM、Binaryen、Closure Compiler 和其他工具等組成。
Emscripten 的核心工具為 Emscripten Compiler Frontend(emcc),emcc 是用于替代一些原生的編譯器如 gcc 或 clang,對(duì) C/C++ 代碼進(jìn)行編譯。
實(shí)際上為了能讓幾乎所有的可移植的 C/C++ 代碼庫(kù)能夠編譯為 WebAssembly,并在 Web 或 Node.js 執(zhí)行,Emscripten Runtime 其實(shí)還提供了兼容 C/C++ 標(biāo)準(zhǔn)庫(kù)、相關(guān) API 到 Web/Node.js API 的映射,這份映射存在于編譯之后的 JS 膠水代碼中。
再看下面這張圖,紅色部分為 Emscripten 編譯后的產(chǎn)物,綠色部分為 Emscripten 為保證 C/C++ 代碼能夠運(yùn)行的一些 runtime 支持:
簡(jiǎn)單體驗(yàn)一下 “Hello World”
值得一提的是,WebAssembly 相關(guān)工具鏈的安裝幾乎都是以源碼的形式提供,這可能和 C/C++ 生態(tài)的習(xí)慣不無(wú)關(guān)系。
為了完成簡(jiǎn)單的 C/C++ 程序運(yùn)行在 Web,我們首先需要安裝 Emscripten 的 SDK:
- # Clone 代碼倉(cāng)庫(kù)
- git clone https: // github . com / emscripten-core / emsdk . git
- # 進(jìn)入倉(cāng)庫(kù)
- cd emsdk
- # 獲取最新代碼,如果是新 clone 的這一步可以不需要
- git pull
- # 安裝 SDK 工具,我們安裝 1.39.18,方便測(cè)試
- ./emsdk install 1.39.18
- # 激活 SDK
- ./emsdk activate 1.39.18
- # 將相應(yīng)的環(huán)境變量加入到系統(tǒng) PATH
- source ./emsdk_env.sh
- # 運(yùn)行命令測(cè)試是否安裝成功
- emcc -v #
如果安裝成功,上述的命令運(yùn)行之后會(huì)輸出如下結(jié)果:
- emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 1.39.18
- clang version 11.0.0 (/b/s/w/ir/cache/git/chromium.googlesource.com-external-github.com-llvm-llvm--project 613c4a87ba9bb39d1927402f4dd4c1ef1f9a02f7)
- Target: x86_64-apple-darwin21.1.0
- Thread model: posix
讓我們準(zhǔn)備初始代碼:
- mkdir -r webassembly/hello_world
- cd webassembly/hello_world && touch main.c
在 main.c 中加入如下代碼:
- #include <stdio.h>
- int main() {
- printf("hello, world!\n");
- return 0;
- }
然后使用 emcc 來(lái)編譯這段 C 代碼,在命令行切換到 webassembly/hello_world 目錄,運(yùn)行:
- emcc main.c
上述命令會(huì)輸出兩個(gè)文件:a.out.js 和 a.out.wasm ,后者為編譯之后的 wasm 代碼,前者為 JS 膠水代碼,提供了 WASM 運(yùn)行的 runtime。
可以使用 Node.js 進(jìn)行快速測(cè)試:
- node a.out.js
會(huì)輸出 "hello, world!" ,我們成功將 C/C++ 代碼運(yùn)行在了 Node.js 環(huán)境。
接下來(lái)我們嘗試一下將代碼運(yùn)行在 Web 環(huán)境,修改編譯代碼如下:
- emcc main.c -o main.html
上述命令會(huì)生成三個(gè)文件:
- main.js 膠水代碼
- main.wasm WASM 代碼
- main.html 加載膠水代碼,執(zhí)行 WASM 的一些邏輯
Emscripten 生成代碼有一定的規(guī)則,具體可以參考:https://emscripten.org/docs/compiling/Building-Projects.html#emscripten-linker-output-files
如果要在瀏覽器打開這個(gè) HTML,需要在本地起一個(gè)服務(wù)器,因?yàn)閱渭兊拇蜷_通過 file:// 協(xié)議訪問時(shí),主流瀏覽器不支持 XHR 請(qǐng)求,只有在 HTTP 服務(wù)器下,才能進(jìn)行 XHR 請(qǐng)求,所以我們運(yùn)行如下命令來(lái)打開網(wǎng)站:
- npx serve .
打開網(wǎng)頁(yè),訪問 localhost:3000/main.html,可以看到如下結(jié)果:
同時(shí)開發(fā)者工具里面也會(huì)有相應(yīng)的打印輸出:
我們成功的將 C 代碼跑在了 Node.js 和瀏覽器!