悄悄掀起 WebAssembly 的神秘面紗

悄悄掀起 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 的一個小遊戲:webassembly.org/demo/,可以去體驗體驗。

.wasm 文件 與 .wat 文件

WebAssembly 是通過 *.wasm 文件進行存儲的,這是編譯好的二進位文件,它的體積非常的小。

在瀏覽器中,提供了一個全局的 window.WebAssembly 對象,可以用於實例化 WASM 模塊。

window.WebAssembly

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 了,首先輸出的內容為 memoryinitial 的值。然後我們調用了 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 函數

這裡,使用 DataViewmemory 進行了一次包裝,這樣就可以方便地對內存對象進行讀寫操作了。

然後,這裡在 JavaScript 中實現了一個 log 函數,函數接受一個參數(這個參數在上面的 wat 中指定了是整數型)。下面的實現首先是確定輸出的字元串長度(字元串通常以 結尾),然後將字元串複製到一個長度合適的 ArrayBuffer 中,然後使用瀏覽器中的 TextDecoder 類對其進行字元串解碼,就得到了原始字元串。

最後,將 log 函數放入 importObject 的 js.log 中,實例化 WebAssembly 模塊,最後調用導出的 example 函數,就可以看到列印的 Hello World

Example - Hello World!

通過 WebAssembly,我們可以將很多其他語言編寫的類庫直接封裝到瀏覽器中運行,比如 Google Developers 就給了一個使用 WebAssembly 載入一個使用 C 語言編寫的 WebP 圖片編碼庫,將一張 jpg 格式的圖片轉換為 webp 格式並顯示出來的例子:developers.google.com/w

這個例子使用 Emscripten 工具對 C 語言代碼進行編譯,這個工具在安裝的時候需要到 GitHub、亞馬遜 S3 等伺服器下載文件,在國內這神奇的網路環境下速度異常緩慢,總共幾十兆的文件可能掛機一天都下不完。可以嘗試修改 emsdk 文件(Python),增加代理配置(但是效果不明顯),或是在下載的過程中會提示下載鏈接和存放路徑,使用其他工具下載後放到指定地方,重新安裝會自動跳過已經下載的文件。

WebAssembly 的現狀與未來

目前 WebAssembly 的二進位格式版本已經確定,未來的改進也都將以兼容的形式進行更新,這表示 WebAssembly 已經進入現代標準了。

瀏覽器兼容性

現在的 WebAssembly 還並不完美,雖說已經有使用 WebAssembly 開發的 Web 遊戲出現了,但是還有很多不完美的地方。

比如,現在的 WebAssembly 還必須配合「JavaScript glue code」來使用,也就是必須使用 JavaScript 來 fetch WebAssembly 的文件,然後調用 window.WebAssembly.instantiatewindow.WebAssembly.instantiateStreaming 等函數進行實例化。部分情況下還需要 JavaScript 來管理堆棧。官方推薦的編譯工具 Emscripten 雖然使用了各種黑科技來縮小編譯後生成的代碼的數量,但是最終生成的 JavaScript Glue Code 文件還是至少有 15K。

未來,WebAssembly 將可能直接通過 HTML 標籤進行引用,比如:<script src="./wa.wasm"></script>;或者可以通過 JavaScript ES6 模塊的方式引用,比如:import xxx from ./wa.wasm;

線程的支持,異常處理,垃圾收集,尾調用優化等,都已經加入 WebAssembly 的計劃列表中了。

小結

WebAssembly 的出現,使得前端不再只能使用 JavaScript 進行開發了,C、C++、Go 等等都可以為瀏覽器前端貢獻代碼。

這裡我使用 wat 文件來編寫的兩個例子僅供參考,實際上在生產環境不大可能直接使用 wat 來進行開發,而是會使用 C、C++、Go 等語言編寫模塊,然後發布為 WebAssembly。

WebAssembly 的出現不是要取代 JavaScript,而是與 JavaScript 相輔相成,為前端開發帶來一種新的選擇。將計算密集型的部分交給 WebAssembly 來處理,讓瀏覽器發揮出最大的性能!


文 / jinliming2

一條對新鮮事物充滿了好奇心的鹹魚

編 / 熒聲

本文已由作者授權發布,版權屬於創宇前端。歡迎註明出處轉載本文。本文鏈接:knownsec-fed.com/2018-0

想要看到更多來自知道創宇開發一線的分享,請搜索關注我們的微信公眾號:創宇前端(KnownsecFED)。歡迎留言討論,我們會儘可能回復。

感謝您的閱讀。

推薦閱讀:

TAG:WebAssembly | Web開發 | 前端開發 |