WebAssembly

Webassembly(WASM)和CSS的Grid布局一樣都是一個新東西,Chrome從57開始支持。在講wasm之前我們先看代碼是怎麼編譯的成機器碼,因為計算機只認識機器碼。

1. 機器碼

計算機只能運行機器碼,機器碼是一串二進位的數字,如下面的可執行文件a.out:

上面顯示成16進位,是為了節省空間。

例如我用C寫一個函數,如下:

int main(){ int a = 5; int b = 6; int c = a + b; return 0;}

然後把它編譯成一個可執行文件,就變成了上面的a.out。a.out是一條條的指令組成的,如下圖所示,研究一下為了做一個加法是怎麼進行的:

第一個位元組表示它是哪條指令,每條指令的長度可能不一樣。上面總共有四條指令,第一條指令的意思是把0x5即5這個數放到內存內置為[rbp - 0x8]的位置,第二條指令的意思是把6放到內存地址為[rbp - 0xc]的位置,為什麼內存的位置是這樣呢,因為我們定義了兩個局部變數a和b,局部變數是放在棧裡面的,而new出來的是放在內存堆裡面的。上面main函數的內存棧空間如下所示:

rbp是一個base pointer,即當前棧的基地址,這裡應該為main函數入口地地址,然後又定義了兩個局部變數,它們依次入棧,棧由下往上增長,向內存的低位增長,在我的這個Linux操作系統上是這樣的。最後return返回的時候這個棧就會一直pop到入口地址位置,回到調它的那個函數的地址,這樣你就知道函數棧調用是怎麼回事了。

一個棧最大的空間為多少呢?可以執行ulimit -s或者ulimit -a命令,它會列印出當前操作系統的內存棧最大值:

> ulimit -a

stack size (kbytes, -s) 8192

這裡為8Mb,相對於一些OS默認的64Kb,已經是一個比較大的值了。一旦超出這個值,就會發生棧溢出stack overflow.

理解了第一條指令和第二條指令的意思後就不難理解第三條和第四條了。第三條是把內存地址為[rbp - 8]放到ecx寄存器裡面,第四條做一個加法,把[rbp - 12]加到ecx寄存器。就樣就完成了c = a + b的加法。

更多彙編和機器碼的運算讀者有興趣可以自行去查資料繼續擴展,這裡我提了一下,幫助讀者理解這種比較較陌生的機器碼是怎麼回事,也是為了下面講解WASM.

2. 編譯和解釋

我們知道編程語言分為兩種,一種是編譯型的如C/C++,另一種是解釋型如Java/Python/JS等。

在編譯型語言裡面,代碼需經過以下步驟轉成機器碼:

先把代碼文本進行詞法分析、語法分析、語義分析,轉成彙編語言,其實解釋型語言也是需要經過這些步驟。通過詞法分析識別單詞,例如知道了var是一個關鍵詞,people這個單詞是自定義的變數名字;語法分析把單片語成了短句,例如知道了定義了一個變數,寫了一個賦值表達式,還有一個for循環;而語義分析是看邏輯合不合法,例如如果賦值給了this常量將會報錯。

再把彙編再翻譯成機器碼,彙編和機器碼是兩個比較接近的語言,只是彙編不需要去記住哪個數字代表哪個指令。

編譯型語言需要在運行之前生成機器碼,所以它的執行速度比較快,比解釋型的要快若干倍,缺點是由於它生成的機器碼是依賴於那個平台的,所以可執行的二進位文件無法在另一個平台運行,需要再重新編譯。

相反,解釋型為了達到一次書寫,處處運行(write once, run evrywhere)的目的,它不能先編譯好,只能在運行的時候,根據不同的平台再一行行解釋成機器碼,導致運行速度要明顯低於編譯型語言。

如果你看Chrome源碼的話,你會發現V8的解釋器是一個很複雜的工程,有200多個文件:

最後終於可以來講WebAssembly了。

3. WebAssembly介紹

WASM的意義在於它不需要JS解釋器,可直接轉成彙編代碼(assembly code),所以運行速度明顯提升,速度比較如下:

通過一些實驗的數據,JS大概比C++慢了7倍,ASM.js官網認為它們的代碼運行效率是用clang編譯的代碼的1/2,所以就得到了上面比較粗糙的對比。

Mozilla公司最開始開發asm.js,後來受到Chrome等瀏覽器公司的支持,慢慢發展成WASM,W3C還有一個專門的社區,叫WebAssembly Community Group。

WASM是JS的一個子集,它必須是強類型的,並且只支持整數、浮點數、函數調用、數組、算術計算,如下使用asm規範寫的代碼做兩數的加法:

function () { "use asm"; function add(x, y) { x = x | 0; y = y | 0; return x | 0 + y | 0; } return {add: add};}

正如asm.js官網提到的:

An extremely restricted subset of JavaScript that provides only strictly-typed integers, floats, arithmetic, function calls, and heap accesses

WASM的兼容性,如caniuse所示:

最新的主流瀏覽器基本上已經支持。

4. WASM Demo

(1)準備

Mac電腦需要安裝以下工具:

cmake make Clang/XCode

Windows需要安裝:

cmake make VS2015 以上

然後再裝一個

WebAssembly binaryen (asm2Wasm)

(2)開始

寫一個add.asm.js,按照asm規範,如下圖所示:

然後再運行剛剛裝的工具asm2Wasm,就可以得到生成的wasm格式的文本,如下圖所示

可以看到WASM比較接近彙編格式,可以比較方便地轉成彙編。

如果不是在控制台輸出,而是輸出到一個文件,那麼它是二進位的。運行以下命令:

> ../bin/asm2wasm add.asm.js -o add.wasm

打開生成的add.wasm,可以看到它是一個二進位的:

有了這個文件之後怎麼在瀏覽器上面使用呢,如下代碼所示,使用Promise,與WebAssembly相關的對象本身就是Promise對象:

fetch("add.wasm").then(response => response.arrayBuffer()).then(buffer => WebAssembly.compile(buffer)).then(module => { var imports = {env: {}}; Object.assign(imports.env, { memoryBase: 0, tableBase: 0, memory: new WebAssembly.Memory({ initial: 256, maximum: 256 }), table: new WebAssembly.Table({ initial: 0, maximum: 0, element: "anyfunc" }) }) var instance = new WebAssembly.Instance(module, imports) var add = instance.exports.add; console.log(add, add(5, 6));})

先去載入add.wasm文件,接著把它編譯成機器碼,再new一個實例,然後就可以用exports的add函數了,如下控制台的輸出:

可以看到add函數已經變成機器碼了。

現在來寫一個比較有用的函數,斐波那契函數,先寫一個asm.js格式的,如下所示:

function fibonacci(fn, fn1, fn2, i, num) { num = num | 0; fn2 = fn2 | 0; fn = fn | 0; fn1 = fn1 | 0; i = i | 0; if(num < 0) return 0; else if(num == 1) return 1; else if(num == 2) return 1; while(i <= num){ fn = fn1; fn1 = fn2; fn2 = fn + fn1; i = i + 1; } return fn2 | 0;}

這裡筆者最到一個問題,就是定義的局部變數無法使用,它的值始終是0,所以先用傳參的方式。

然後再把剛剛那個載入編譯的函數封裝成一個函數,如下所示:

loadWebAssembly("fibonacci.wasm").then(instance => { var fibonacci = instance.exports.fibonacci; var i = 4, fn = 1, fn1 = 1, fn2 = 2; console.log(i, fn, fn1, fn2, "f(5) = " + fibonacci(5));});

最後觀察控制台的輸出:

可以看到在f(47)的時候發生了溢出,在《JS與多線程》這一篇提到JS溢出了會自動轉成浮點數,但是WASM就不會了,所以可以看到WASM/ASM其實和JS沒有直接的關係,只是說你可以用JS寫WASM,雖然官網的說法是ASM是JS的一個子集,但其實兩者沒有血肉關係,用JS寫ASM你會發現非常地笨拙和不靈活,編譯成WASM會有各種報錯,提示信息非常簡陋,總之很難寫。但是不用沮喪,因為下面我們會提到還可以用C寫。

然後我們可以做一個兼容,如果支持WASM就去載入wasm格式的,否則載入JS格式,如下所示:

5. JS和WASM的速度比較

(1)運行速度的比較

如下代碼所示,計算1到46的斐波那契值,然後重複一百萬次,分別比較wasm和JS的時間:

//wasm運行時間loadWebAssembly("fib.wasm").then(instance => { var fibonacci = instance.exports._fibonacci; var num = 46; var count = 1000000; console.time("wasm fibonacci"); for(var k = 0; k < count; k++){ for(var j = 0; j < num; j++){ var i = 4, fn = 1, fn1 = 1, fn2 = 2; fibonacci(fn, fn1, fn2, i, j); } } console.timeEnd("wasm fibonacci");});//js運行時間loadWebAssembly("fibonacci.js", {}, "js").then(instance => { var fibonacci = instance.exports.fibonacci; var num = 46; var count = 1000000; console.time("js fibonacci"); for(var k = 0; k < count; k++){ for(var j = 0; j < num; j++){ var i = 4, fn = 1, fn1 = 1, fn2 = 2; fibonacci(fn, fn1, fn2, i, j); } } console.timeEnd("js fibonacci");});

運行四次,比較如下:

可以看到,在這個例子裡面WASM要比JS快了一倍。

然後再比較解析的時間

(2)解析時間比較

如下代碼所示:

console.time("wasm big content parse");loadWebAssembly("big.wasm").then(instance => { var fibonacci = instance.exports._fibonacci; console.timeEnd("wasm big content parse"); console.time("js big content parse"); loadJs();});function loadJs(){ loadWebAssembly("big.js", {}, "js").then(instance => { var fibonacci = instance.exports.fibonacci; console.timeEnd("js big content parse"); });}

分別比較解析100、2000、20000行代碼的時間,統計結果如下:

WASM的編譯時間要高於JS,因為JS定義的函數只有被執行的時候才去解析,而WASM需要一口氣把它們都解析了。

上面表格的時間是一個什麼概念呢,可以比較一下常用庫的解析時間,如下圖所示:

(3)文件大小比較

20000行代碼,wasm格式只有3.4k,而壓縮後的js還有165K,如下圖所示:

所以wasm文件小,它的載入時間就會少,可以一定程度上彌補解析上的時間缺陷,另外可以做一些懶惰解析的策略。

6. WASM的優缺點

WASM適合於那種對計算性能特別高的,如圖形計算方面的,缺點是它的類型檢驗比較嚴格,寫JS編譯經常會報錯,不方便debug。

WASM官網提供的一個WebGL + WebAssembly坦克遊戲如下所示:

它的數據和函數都是用的wasm格式:

7. C/Rust寫前端

WASM還支持用C/Rust寫,需要安裝一個emsdk。然後用C函數寫一個fibonacci.c文件如下所示:

/* 不考慮溢出 */int fibonacci(int num){ if(num <= 0) return 0; if(num == 1 || num == 2) return 1; int fn = 1, fn1 = 1, fn2 = fn + fn1; for(int i = 4; i <= num; i++){ fn = fn1; fn1 = fn2; fn2 = fn1 + fn; } return fn2;}

運行以下命令編譯成一個wasm文件:

emcc fibonacci.c -Os -s WASM=1 -s SIDE_MODULE=1 -o fibonacci.wasm

這個wasm和上面的是一樣的格式,然後再用同樣的方式在瀏覽器載入使用。

用C寫比用JS寫更加地流暢,定義一個變數不用在後面寫一個「| 0」,編譯起來也非常順暢,一次就過了,如果出錯了,提示非常友好。這就可以把一些C庫直接挪過來前端用。

8. WASM對寫JS的提示

WASM為什麼非得強類型的呢?因為它要轉成彙編,彙編裡面就得是強類型,這個對於JS解釋器也是一樣的,如果一個變數一下子是數字,一下子又變成字元串,那麼解釋器就得額外的工作,例如把原本的變數銷毀再創建一個新的變數,同時代碼可讀性也會變差。所以提倡:

  1. 定義變數的時候告訴解釋器變數的類型
  2. 不要隨意改變變數的類型
  3. 函數返回值類型是要確定的

這個我在《Effective前端8:JS書寫優化》已經提到.

到此,介紹完畢,通過本文應該對程序的編譯有一個直觀的了解,特別是代碼是怎麼變成機器碼的,還有WebAssembly和JS的關係又是怎麼樣的,Webassembly是如何提高運行速度,為什麼要提倡強類型風格代碼書寫。對這些問題應該可以有一個理解。

另外一方面,web前端技術的發展真的是非常地活躍,在學這些新技術的同時,別忘了打好基本功。


推薦閱讀:

如何實現一個Web版五子棋遊戲的界面?

TAG:前端开发 | HTML5 | JavaScript |