悄悄掀起 WebAssembly 的神秘面紗
99 人贊了文章
前端開發人員想必對現代瀏覽器都已經非常熟悉了吧?HTML5,CSS4,JavaScript ES6,這些已經在現代瀏覽器中慢慢普及的技術為前端開發帶來了極大的便利。
得益於 JIT(Just-in-time)技術,JavaScript 的運行速度比原來快了 10 倍,這也是 JavaScript 被運用得越來越廣泛的原因之一。但是,這是極限了嗎? 隨著瀏覽器技術的發展,Web 遊戲眼看著又要「捲土重來」了,不過這一次不是基於 Flash 的遊戲,而是充分利用了現代 HTML5 技術實現。JavaScript 成為了 Web 遊戲的開發語言,但是對於遊戲這樣需要大量運算的程序來說,即便是有 JIT 加持,JavaScript 的性能還是不能滿足人類貪婪的慾望。
JavaScript 在瀏覽器中是怎麼跑起來的?
對於現在的計算機來說,它們只能讀懂「機器語言」,而人類的大腦能力有限,直接編寫機器語言難度有點大,為了能讓人更方便地編寫程序,人類發明了大量的「高級編程語言」,JavaScript 就屬於其中特殊的一種。
為什麼說是特殊的一種呢?由於計算機並不認識「高級編程語言」寫出來的東西,所以大部分「高級編程語言」在寫好以後都需要經過一個叫做「編譯」的過程,將「高級編程語言」翻譯成「機器語言」,然後交給計算機來運行。但是,JavaScript 不一樣,它沒有「編譯」的過程,那麼機器是怎麼認識這種語言的呢?
實際上,JavaScript 與其他一部分腳本語言採用的是一種「邊解釋邊運行」的姿勢來運行的,將代碼一點一點地翻譯給計算機。
那麼,JavaScript 的「解釋」與其他語言的「編譯」有什麼區別呢?不都是翻譯成「機器語言」嗎?簡單來講,「編譯」類似於「全文翻譯」,就是代碼編寫好後,一次性將所有代碼全部編譯成「機器語言」,然後直接交給計算機;而「解釋」則類似於「實時翻譯」,代碼寫好後不會翻譯,運行到哪,翻譯到哪。
「解釋」和「編譯」兩種方法各有利弊。使用「解釋」的方法,程序編寫好後就可以直接運行了,而使用「編譯」的方法,則需要先花費一段時間等待整個代碼編譯完成後才可以執行。這樣一看似乎是「解釋」的方法更快,但是如果一段代碼要執行多次,使用「解釋」的方法,程序每次運行時都需要重新「解釋」一遍,而「編譯」的方法則不需要了。這樣一看,「編譯」的整體效率似乎更高,因為它永遠只翻譯一次,而「解釋」是運行一次翻譯一次。並且,「編譯」由於是一開始就對整個代碼進行的,所以可以對代碼進行針對性的優化。
JavaScript 是使用「解釋」的方案來運行的,這就造成了它的效率低下,因為代碼每運行一次都要翻譯一次,如果一個函數被循環調用了 10 次、100 次,這個執行效率可想而知。
好在聰明的人類發明了 JIT(Just-in-time)技術,它綜合了「解釋」與「編譯」的優點,它的原理實際上就是在「解釋」運行的同時進行跟蹤,如果某一段代碼執行了多次,就會對這一段代碼進行編譯優化,這樣,如果後續再運行到這一段代碼,則不用再解釋了。
JIT 似乎是一個好東西,但是,對於 JavaScript 這種動態數據類型的語言來說,要實現一個完美的 JIT 非常難。為什麼呢?因為 JavaScript 中的很多東西都是在運行的時候才能確定的。比如我寫了一行代碼:const sum = (a, b, c) => a + b + c;
,這是一個使用 ES6 語法編寫的 JavaScript 箭頭函數,可以直接放在瀏覽器的控制台下運行,這將聲明一個叫做 sum 的函數。然後我們可以直接調用它,比如:console.log(sum(1, 2, 3))
,任何一個合格的前端開發人員都能很快得口算出答案,這將輸出一個數字 6
。但是,如果我們這樣調用呢:console.log(sum(1, 2, 3))
,第一個參數變成了一個字元串,這在 JavaScript 中是完全允許的,但是這時得到的結果就完全不同了,這會導致一個字元串和兩個數字進行連接,得到 "123"
。這樣一來,針對這一個函數的優化就變得非常困難了。
雖說 JavaScript 自身的「特性」為 JIT 的實現帶來了一些困難,但是不得不說 JIT 還是為 JavaScript 帶來了非常可觀的性能提升。
WebAssembly
為了能讓代碼跑得更快,WebAssembly 出現了(並且現在主流瀏覽器也都開始支持了),它能夠允許你預先使用「編譯」的方法將代碼編譯好後,直接放在瀏覽器中運行,這一步就做得比較徹底了,不再需要 JIT 來動態得進行優化了,所有優化都可以在編譯的時候直接確定。
WebAssembly 到底是什麼呢?
首先,它不是直接的機器語言,因為世界上的機器太多了,它們都說著不同的語言(架構不同),所以很多情況下都是為各種不同的機器架構專門生成對應的機器代碼。但是要為各種機器都生成的話,太複雜了,每種語言都要為每種架構編寫一個編譯器。為了簡化這個過程,就有了「中間代碼(Intermediate representation,IR)」,只要將所有代碼都翻譯成 IR,再由 IR 來統一應對各種機器架構。
實際上,WebAssembly 和 IR 差不多,就是用於充當各種機器架構翻譯官的角色。WebAssembly 並不是直接的物理機器語言,而是抽象出來的一種虛擬的機器語言。從 WebAssembly 到機器語言雖說也需要一個「翻譯」過程,但是在這裡的「翻譯」就沒有太多的套路了,屬於機器語言到機器語言的翻譯,所以速度上已經非常接近純機器語言了。
這裡有一個 WebAssembly 官網上提供的 Demo,是使用 Unity 開發並發布為 WebAssembly 的一個小遊戲:https://webassembly.org/demo/,可以去體驗體驗。
.wasm 文件 與 .wat 文件
WebAssembly 是通過 *.wasm 文件進行存儲的,這是編譯好的二進位文件,它的體積非常的小。
在瀏覽器中,提供了一個全局的 window.WebAssembly
對象,可以用於實例化 WASM 模塊。
WebAssembly 是一種「虛擬機器語言」,所以它也有對應的「彙編語言」版本,也就是 *.wat 文件,這是 WebAssembly 模塊的文本表示方法,採用「S-表達式(S-Expressions)」進行描述,可以直接通過工具將 *.wat 文件編譯為 *.wasm 文件。熟悉 LISP 的同學可能對這種表達式語法比較熟悉。
一個非常簡單的例子
我們來看一個非常簡單的例子,這個已經在 Chrome 69 Canary 和 Chrome 70 Canary 中測試通過,理論上可以在所有已經支持 WebAssembly 的瀏覽器中運行。(在後文中有瀏覽器的支持情況)
首先,我們先使用 S-表達式 編寫一個十分簡單的程序:
;; test.wat(module (import "env" "mem" (memory 1)) ;; 這裡指定了從 env.mem 中導入一個內存對象 (func (export "get") (result i32) ;; 定義並導出一個叫做「get」的函數,這個函數擁有一個 int32 類型的返回值,沒有參數 memory.size)) ;; 最終返回 memory 對象的「尺寸」(單位為「頁」,目前規定 1 頁 = 64 KiB = 65536 Bytes)
可以使用 wabt 中的 wasm2wat 工具將 wasm 文件轉為使用「S-表達式」進行描述的 wat 文件。同時也可以使用 wat2wasm 工具將 wat 轉為 wasm。
在 wat 文件中,雙分號 ;;
開頭的內容都是注釋。
上面這個 wat 文件定義了一個 module,並導入了一個內存對象,然後導出了一個叫做「get」的函數,這個函數返回當前內存的「尺寸」。
在 WebAssembly 中,線性內存可以在內部直接定義然後導出,也可以從外面導入,但是最多只能擁有一個內存。這個內存的大小並不是固定的,只需要給一個初始大小 initial
,後期還可以根據需要調用 grow
函數進行擴展,也可以指定最大大小 maximum
。(這裡所有內存大小的單位都是「頁」,目前規定的是 1 頁 = 64 KiB = 65536 Bytes。)
上面這個 wat 文件使用 wat2wasm 編譯為 wasm 後生成的文件體積非常小,只有 50 Bytes:
$ wat2wasm test.wat$ xxd test.wasm00000000: 0061 736d 0100 0000 0105 0160 0001 7f02 .asm.......`....00000010: 0c01 0365 6e76 036d 656d 0200 0103 0201 ...env.mem......00000020: 0007 0701 0367 6574 0000 0a06 0104 003f .....get.......?00000030: 000b ..
為了讓這個程序能在瀏覽器中運行,我們還必須使用 JavaScript 編寫一段「膠水代碼(glue code)」,以便這個程序能被載入到瀏覽器中並執行:
// main.jsconst file = await fetch(./test.wasm);const memory = new window.WebAssembly.Memory({ initial: 1 });const mod = await window.WebAssembly.instantiateStreaming(file, { env: { mem: memory, },});let result;result = mod.instance.exports.get(); // 調用 WebAssembly 模塊導出的 get 函數console.log(result); // 1memory.grow(2);result = mod.instance.exports.get(); // 調用 WebAssembly 模塊導出的 get 函數console.log(result); // 3
這裡我使用了現代瀏覽器都已經支持的 ES6 語法,首先,使用瀏覽器原生提供的 fetch
函數載入我們編譯好的 test.wasm
文件。注意,這裡根據規範,HTTP 響應的 Content-Type 中指定的 MIME 類型必須為 application/wasm
。
接下來,我們 new 了一個 WebAssembly.Memory
對象,通過這個對象,可以實現 JavaScript 與 WebAssembly 之間互通數據。
再接下來,我們使用了 WebAssembly.instantiateStreaming
來實例化載入的 WebAssembly 模塊,這裡第一個參數是一個 Readable Stream,第二個參數是 importObject,用於指定導入 WebAssembly 的結構。因為上面的 wat 代碼中指定了要從 env.mem
導入一個內存對象,所以這裡就得要將我們 new 出來的內存對象放到 env.mem
中。
WebAssembly 還提供了一個
instantiate
函數,這個函數的第一個參數可以提供一個 ArrayBuffer 或是 TypedArray。但是這個函數是不推薦使用的,具體原因做過流量代理轉發的同學可能會比較清楚,這裡就不具體解釋了。
最後,我們就可以調用 WebAssembly 導出的函數 get
了,首先輸出的內容為 memory
的 initial
的值。然後我們調用了 memory.grow
方法來增長 memory
的尺寸,最後輸出的內容就是增長後內存的大小 1 + 2 = 3
。
一個 WebAssembly 與 JavaScript 數據互通交互的例子
在 WebAssembly 中有一塊內存,這塊內存可以是內部定義的,也可以是從外面導入的,如果是內部定義的,則可以通過 export
進行導出。JavaScript 在拿到這塊「內存」後,是擁有完全操作的權利的。JavaScript 使用 DataView 對 Memory
對象進行包裝後,就可以使用 DataView 下面的函數對內存對象進行讀取或寫入操作。
這裡是一個簡單的例子:
;; example.wat(module (import "env" "mem" (memory 1)) (import "js" "log" (func $log (param i32))) (func (export "example") i32.const 0 i64.const 8022916924116329800 i64.store (i32.store (i32.const 8) (i32.const 560229490)) (call $log (i32.const 0))))
這個代碼首先從 env.mem
導入一個內存對象作為默認內存,這和前面的例子是一樣的。
然後從 js.log
導入一個函數,這個函數擁有一個 32 位整型的參數,不需要返回值,在 wat 內部被命名為「$log」,這個名字只存在於 wat 文件中,在編譯為 wasm 後就不存在了,只存儲一個偏移地址。
後面定義了一個函數,並導出為「example」函數。在 WebAssembly 中,函數里的內容都是在棧上的。
首先,使用
i32.const 0
在棧內壓入一個 32 位整型常數0
,然後使用i64.const 8022916924116329800
在棧內壓入一個 64 位整型常數8022916924116329800
,之後調用i64.store
指令,這個指令將會將棧頂部第一個位置的一個 64 位整數存儲到棧頂部第二個位置指定的「內存地址」開始的連續 8 個位元組空間中。
簡而言之,就是在內存的第 0 個位置開始的連續 8 個位元組的空間里,存入一個 64 位整型數字 8022916924116329800
。這個數字轉為 16 進位表示為:0x 6f 57 20 6f 6c 6c 65 48
,但是由於 WebAssembly 中規定的位元組序是使用「小端序(Little-Endian Byte Order)」來存儲數據,所以,在內存中第 0 個位置存儲的是 0x48
,第 1 個位置存儲的是 0x65
……所以,最終存儲的實際上是 0x 48 65 6c 6c 6f 20 57 6f
,對應著 ASCII 碼為:"Hello Wo"。
然後,後面的一句指令
實際上這一句指令的寫法寫成上面三句的語法是完全等效的:(i32.store (i32.const 8) (i32.const 560229490))
的格式是上面三條指令的「S-表達式」形式,只不過這裡換成了i32.store
來存儲一個 32 位整型常數560229490
到 8 號「內存地址」開始的連續 4 個位元組空間中。wat i32.const 8 i32.const 560229490 i32.store
類似的,這裡是在內存的第 8 個位置開始的連續 4 個位元組的空間里,存入一個 32 位整型數字 560229490
。這個數字轉為 16 進位表示位:0x 21 64 6c 72
,同樣採用「小端序」來存儲,所以存儲的實際上是 0x 72 6c 64 21
,對應著 ASCII 碼為:"rld!"。
所以,最終,內存中前 12 個位元組中的數據為 0x 48 65 6c 6c 6f 20 57 6f 72 6c 64 21
,連起來就是對應著 ASCII 碼:"Hello World!"。
將這個 wat 編譯為 wasm 後,文件大小為 95 Bytes:
$ wat2wasm example.wat$ xxd example.wasm00000000: 0061 736d 0100 0000 0108 0260 017f 0060 .asm.......`...`00000010: 0000 0215 0203 656e 7603 6d65 6d02 0001 ......env.mem...00000020: 026a 7303 6c6f 6700 0003 0201 0107 0b01 .js.log.........00000030: 0765 7861 6d70 6c65 0001 0a23 0121 0041 .example...#.!.A00000040: 0042 c8ca b1e3 f68d c8ab ef00 3703 0041 .B..........7..A00000050: 0841 f2d8 918b 0236 0200 4100 1000 0b .A.....6..A....
接下來,還是使用 JavaScript 編寫「膠水代碼」:
// example.jsconst file = await fetch(./example.wasm);const memory = new window.WebAssembly.Memory({ initial: 1 });const dv = new DataView(memory);const log = offset => { let length = 0; let end = offset; while(end < dv.byteLength && dv.getUint8(end) > 0) { ++length; ++end; } if (length === 0) { console.log(); return; } const buf = new ArrayBuffer(length); const bufDv = new DataView(buf); for (let i = 0, p = offset; p < end; ++i, ++p) { bufDv.setUint8(i, dv.getUint8(p)); } const result = new TextDecoder(utf-8).decode(buf); console.log(result);};const mod = await window.WebAssembly.instantiateStreaming(file, { env: { mem: memory, }, js: { log },});mod.instance.exports.example(); // 調用 WebAssembly 模塊導出的 example 函數
這裡,使用 DataView
對 memory
進行了一次包裝,這樣就可以方便地對內存對象進行讀寫操作了。
然後,這裡在 JavaScript 中實現了一個 log
函數,函數接受一個參數(這個參數在上面的 wat 中指定了是整數型)。下面的實現首先是確定輸出的字元串長度(字元串通常以