Webassembly(WASM)和CSS的Grid布局一樣都是一個新東西,Chrome從57開始支持。在講wasm之前我們先看代碼是怎么編譯的成機器碼,因為計算機只認識機器碼。
1. 機器碼
計算機只能運行機器碼,機器碼是一串二進制的數(shù)字,如下面的可執(zhí)行文件a.out:
上面顯示成16進制,是為了節(jié)省空間。
例如我用C寫一個函數(shù),如下:
int main(){
int a = 5;
int b = 6;
int c = a + b;
return 0;
}
然后把它編譯成一個可執(zhí)行文件,就變成了上面的a.out。a.out是一條條的指令組成的,如下圖所示,研究一下為了做一個加法是怎么進行的:
第一個字節(jié)表示它是哪條指令,每條指令的長度可能不一樣。上面總共有四條指令,第一條指令的意思是把0x5即5這個數(shù)放到內(nèi)存內(nèi)置為[rbp - 0x8]的位置,第二條指令的意思是把6放到內(nèi)存地址為[rbp - 0xc]的位置,為什么內(nèi)存的位置是這樣呢,因為我們定義了兩個局部變量a和b,局部變量是放在棧里面的,而new出來的是放在內(nèi)存堆里面的。上面main函數(shù)的內(nèi)存棧空間如下所示:
rbp是一個base pointer,即當前棧的基地址,這里應該為main函數(shù)入口地地址,然后又定義了兩個局部變量,它們依次入棧,棧由下往上增長,向內(nèi)存的低位增長,在我的這個Linux操作系統(tǒng)上是這樣的。最后return返回的時候這個棧就會一直pop到入口地址位置,回到調(diào)它的那個函數(shù)的地址,這樣你就知道函數(shù)棧調(diào)用是怎么回事了。
一個棧最大的空間為多少呢?可以執(zhí)行ulimit -s或者ulimit -a命令,它會打印出當前操作系統(tǒng)的內(nèi)存棧最大值:
ulimit -a
stack size (kbytes, -s) 8192
這里為8Mb,相對于一些OS默認的64Kb,已經(jīng)是一個比較大的值了。一旦超出這個值,就會發(fā)生棧溢出stack overflow.
理解了第一條指令和第二條指令的意思后就不難理解第三條和第四條了。第三條是把內(nèi)存地址為[rbp - 8]放到ecx寄存器里面,第四條做一個加法,把[rbp - 12]加到ecx寄存器。就樣就完成了c = a + b的加法。
更多匯編和機器碼的運算讀者有興趣可以自行去查資料繼續(xù)擴展,這里我提了一下,幫助讀者理解這種比較較陌生的機器碼是怎么回事,也是為了下面講解WASM.
2. 編譯和解釋
我們知道編程語言分為兩種,一種是編譯型的如C/C++,另一種是解釋型如Java/Python/JS等。
在編譯型語言里面,代碼需經(jīng)過以下步驟轉成機器碼:
先把代碼文本進行詞法分析、語法分析、語義分析,轉成匯編語言,其實解釋型語言也是需要經(jīng)過這些步驟。通過詞法分析識別單詞,例如知道了var是一個關鍵詞,people這個單詞是自定義的變量名字;語法分析把單詞組成了短句,例如知道了定義了一個變量,寫了一個賦值表達式,還有一個for循環(huán);而語義分析是看邏輯合不合法,例如如果賦值給了this常量將會報錯。
再把匯編再翻譯成機器碼,匯編和機器碼是兩個比較接近的語言,只是匯編不需要去記住哪個數(shù)字代表哪個指令。
編譯型語言需要在運行之前生成機器碼,所以它的執(zhí)行速度比較快,比解釋型的要快若干倍,缺點是由于它生成的機器碼是依賴于那個平臺的,所以可執(zhí)行的二進制文件無法在另一個平臺運行,需要再重新編譯。
相反,解釋型為了達到一次書寫,處處運行(write once, run evrywhere)的目的,它不能先編譯好,只能在運行的時候,根據(jù)不同的平臺再一行行解釋成機器碼,導致運行速度要明顯低于編譯型語言。
如果你看Chrome源碼的話,你會發(fā)現(xiàn)V8的解釋器是一個很復雜的工程,有200多個文件:
最后終于可以來講WebAssembly了。
3. WebAssembly介紹
WASM的意義在于它不需要JS解釋器,可直接轉成匯編代碼(assembly code),所以運行速度明顯提升,速度比較如下:
通過一些實驗的數(shù)據(jù),JS大概比C++慢了7倍,ASM.js官網(wǎng)認為它們的代碼運行效率是用clang編譯的代碼的1/2,所以就得到了上面比較粗糙的對比。
Mozilla公司最開始開發(fā)asm.js,后來受到Chrome等瀏覽器公司的支持,慢慢發(fā)展成WASM,W3C還有一個專門的社區(qū),叫WebAssembly Community Group。
WASM是JS的一個子集,它必須是強類型的,并且只支持整數(shù)、浮點數(shù)、函數(shù)調(diào)用、數(shù)組、算術計算,如下使用asm規(guī)范寫的代碼做兩數(shù)的加法:
function () {
"use asm";
function add(x, y) {
x = x | 0;
y = y | 0;
return x | 0 + y | 0;
}
return {add: add};
}
正如asm.js官網(wǎng)提到的:
An extremely restricted subset of JavaScript that provides only strictly-typed integers, floats, arithmetic, function calls, and heap accesses
WASM的兼容性,如caniuse所示:
最新的主流瀏覽器基本上已經(jīng)支持。
4. WASM Demo
(1)準備
Mac電腦需要安裝以下工具:
cmake make Clang/XCode
Windows需要安裝:
cmake make VS2015 以上
然后再裝一個
WebAssembly binaryen (asm2Wasm)
(2)開始
寫一個add.asm.js,按照asm規(guī)范,如下圖所示:
然后再運行剛剛裝的工具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函數(shù)了,如下控制臺的輸出:
可以看到add函數(shù)已經(jīng)變成機器碼了。
現(xiàn)在來寫一個比較有用的函數(shù),斐波那契函數(shù),先寫一個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,所以先用傳參的方式。
然后再把剛剛那個加載編譯的函數(shù)封裝成一個函數(shù),如下所示:
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)的時候發(fā)生了溢出,在《JS與多線程》這一篇提到JS溢出了會自動轉成浮點數(shù),但是WASM就不會了,所以可以看到WASM/ASM其實和JS沒有直接的關系,只是說你可以用JS寫WASM,雖然官網(wǎng)的說法是ASM是JS的一個子集,但其實兩者沒有血肉關系,用JS寫ASM你會發(fā)現(xiàn)非常地笨拙和不靈活,編譯成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行代碼的時間,統(tǒng)計結果如下:
WASM的編譯時間要高于JS,因為JS定義的函數(shù)只有被執(zhí)行的時候才去解析,而WASM需要一口氣把它們都解析了。
上面表格的時間是一個什么概念呢,可以比較一下常用庫的解析時間,如下圖所示:
(3)文件大小比較
20000行代碼,wasm格式只有3.4k,而壓縮后的js還有165K,如下圖所示:
所以wasm文件小,它的加載時間就會少,可以一定程度上彌補解析上的時間缺陷,另外可以做一些懶惰解析的策略。
6. WASM的優(yōu)缺點
WASM適合于那種對計算性能特別高的,如圖形計算方面的,缺點是它的類型檢驗比較嚴格,寫JS編譯經(jīng)常會報錯,不方便debug。
WASM官網(wǎng)提供的一個WebGL + WebAssembly坦克游戲如下所示:
它的數(shù)據(jù)和函數(shù)都是用的wasm格式:
7. C/Rust寫前端
WASM還支持用C/Rust寫,需要安裝一個emsdk。然后用C函數(shù)寫一個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解釋器也是一樣的,如果一個變量一下子是數(shù)字,一下子又變成字符串,那么解釋器就得額外的工作,例如把原本的變量銷毀再創(chuàng)建一個新的變量,同時代碼可讀性也會變差。所以提倡:
定義變量的時候告訴解釋器變量的類型
不要隨意改變變量的類型
函數(shù)返回值類型是要確定的
這個我在《Effective前端8:JS書寫優(yōu)化》已經(jīng)提到.
到此,介紹完畢,通過本文應該對程序的編譯有一個直觀的了解,特別是代碼是怎么變成機器碼的,還有WebAssembly和JS的關系又是怎么樣的,Webassembly是如何提高運行速度,為什么要提倡強類型風格代碼書寫。對這些問題應該可以有一個理解。
另外一方面,web前端技術的發(fā)展真的是非常地活躍,在學這些新技術的同時,別忘了打好基本功。
原文:極樂科技知乎專欄