悄悄掀起 WebAssembly 的神祕面紗

創宇前端發表於2018-08-17

前端開發人員想必對現代瀏覽器都已經非常熟悉了吧?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.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 使用 DataViewMemory 物件進行包裝後,就可以使用 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 格式並顯示出來的例子:developers.google.com/web/updates…

這個例子使用 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-08-08-…

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

悄悄掀起 WebAssembly 的神祕面紗

感謝您的閱讀。

相關文章