悄悄掀起 WebAssembly 的神祕面紗

前端開發人員想必對現代瀏覽器都已經很是熟悉了吧?HTML5,CSS4,JavaScript ES6,這些已經在現代瀏覽器中慢慢普及的技術爲前端開發帶來了極大的便利。

得益於 JIT(Just-in-time)技術,JavaScript 的運行速度比原來快了 10 倍,這也是 JavaScript 被運用得愈來愈普遍的緣由之一。可是,這是極限了嗎?javascript

隨着瀏覽器技術的發展,Web 遊戲眼看着又要「捲土重來」了,不過這一次不是基於 Flash 的遊戲,而是充分利用了現代 HTML5 技術實現。JavaScript 成爲了 Web 遊戲的開發語言,可是對於遊戲這樣須要大量運算的程序來講,即使是有 JIT 加持,JavaScript 的性能仍是不能知足人類貪婪的慾望。前端

JavaScript 在瀏覽器中是怎麼跑起來的?

對於如今的計算機來講,它們只能讀懂「機器語言」,而人類的大腦能力有限,直接編寫機器語言難度有點大,爲了能讓人更方便地編寫程序,人類發明了大量的「高級編程語言」,JavaScript 就屬於其中特殊的一種。java

爲何說是特殊的一種呢?因爲計算機並不認識「高級編程語言」寫出來的東西,因此大部分「高級編程語言」在寫好之後都須要通過一個叫作「編譯」的過程,將「高級編程語言」翻譯成「機器語言」,而後交給計算機來運行。可是,JavaScript 不同,它沒有「編譯」的過程,那麼機器是怎麼認識這種語言的呢?web

實際上,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)。歡迎留言討論,咱們會盡量回復。

歡迎點贊、收藏、留言評論、轉發分享和打賞支持咱們。打賞將被徹底轉交給文章做者。

感謝您的閱讀。

相關文章
相關標籤/搜索