悄悄掀起 WebAssembly 的神祕面紗

前端開發人員想必對現代瀏覽器都已經非常熟悉了吧?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 模塊。

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.wasm
00000000: 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.js

const 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);  // 1
memory.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 個字節空間中。

實際上這一句指令的寫法寫成上面三句的語法是完全等效的:

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.wasm
00000000: 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...#.!.A
00000040: 0042 c8ca b1e3 f68d c8ab ef00 3703 0041  .B..........7..A
00000050: 0841 f2d8 918b 0236 0200 4100 1000 0b    .A.....6..A....

接下來,還是使用 JavaScript 編寫“膠水代碼”:

// example.js

const 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 中指定了是整數型)。下面的實現首先是確定輸出的字符串長度(字符串通常以 '\0' 結尾),然後將字符串複製到一個長度合適的 ArrayBuffer 中,然後使用瀏覽器中的 TextDecoder 類對其進行字符串解碼,就得到了原始字符串。

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

Example - Hello World!

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

這個例子使用 [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
一條對新鮮事物充滿了好奇心的鹹魚

編 / 熒聲

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

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

歡迎點贊、收藏、留言評論、轉發分享和打賞支持我們。打賞將被完全轉交給文章作者。

感謝您的閱讀。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章