摘要:在拿到這塊內(nèi)存后,是擁有完全操作的權(quán)利的。后面定義了一個(gè)函數(shù),并導(dǎo)出為函數(shù)。首先,使用在棧內(nèi)壓入一個(gè)位整型常數(shù),然后使用在棧內(nèi)壓入一個(gè)位整型常數(shù),之后調(diào)用指令,這個(gè)指
前端開(kāi)發(fā)人員想必對(duì)現(xiàn)代瀏覽器都已經(jīng)非常熟悉了吧?HTML5,CSS4,JavaScript ES6,這些已經(jīng)在現(xiàn)代瀏覽器中慢慢普及的技術(shù)為前端開(kāi)發(fā)帶來(lái)了極大的便利。JavaScript 在瀏覽器中是怎么跑起來(lái)的?得益于 JIT(Just-in-time)技術(shù),JavaScript 的運(yùn)行速度比原來(lái)快了 10 倍,這也是 JavaScript 被運(yùn)用得越來(lái)越廣泛的原因之一。但是,這是極限了嗎?
隨著瀏覽器技術(shù)的發(fā)展,Web 游戲眼看著又要“卷土重來(lái)”了,不過(guò)這一次不是基于 Flash 的游戲,而是充分利用了現(xiàn)代 HTML5 技術(shù)實(shí)現(xiàn)。JavaScript 成為了 Web 游戲的開(kāi)發(fā)語(yǔ)言,但是對(duì)于游戲這樣需要大量運(yùn)算的程序來(lái)說(shuō),即便是有 JIT 加持,JavaScript 的性能還是不能滿足人類貪婪的欲望。
對(duì)于現(xiàn)在的計(jì)算機(jī)來(lái)說(shuō),它們只能讀懂“機(jī)器語(yǔ)言”,而人類的大腦能力有限,直接編寫機(jī)器語(yǔ)言難度有點(diǎn)大,為了能讓人更方便地編寫程序,人類發(fā)明了大量的“高級(jí)編程語(yǔ)言”,JavaScript 就屬于其中特殊的一種。
為什么說(shuō)是特殊的一種呢?由于計(jì)算機(jī)并不認(rèn)識(shí)“高級(jí)編程語(yǔ)言”寫出來(lái)的東西,所以大部分“高級(jí)編程語(yǔ)言”在寫好以后都需要經(jīng)過(guò)一個(gè)叫做“編譯”的過(guò)程,將“高級(jí)編程語(yǔ)言”翻譯成“機(jī)器語(yǔ)言”,然后交給計(jì)算機(jī)來(lái)運(yùn)行。但是,JavaScript 不一樣,它沒(méi)有“編譯”的過(guò)程,那么機(jī)器是怎么認(rèn)識(shí)這種語(yǔ)言的呢?
實(shí)際上,JavaScript 與其他一部分腳本語(yǔ)言采用的是一種“邊解釋邊運(yùn)行”的姿勢(shì)來(lái)運(yùn)行的,將代碼一點(diǎn)一點(diǎn)地翻譯給計(jì)算機(jī)。
那么,JavaScript 的“解釋”與其他語(yǔ)言的“編譯”有什么區(qū)別呢?不都是翻譯成“機(jī)器語(yǔ)言”嗎?簡(jiǎn)單來(lái)講,“編譯”類似于“全文翻譯”,就是代碼編寫好后,一次性將所有代碼全部編譯成“機(jī)器語(yǔ)言”,然后直接交給計(jì)算機(jī);而“解釋”則類似于“實(shí)時(shí)翻譯”,代碼寫好后不會(huì)翻譯,運(yùn)行到哪,翻譯到哪。
“解釋”和“編譯”兩種方法各有利弊。使用“解釋”的方法,程序編寫好后就可以直接運(yùn)行了,而使用“編譯”的方法,則需要先花費(fèi)一段時(shí)間等待整個(gè)代碼編譯完成后才可以執(zhí)行。這樣一看似乎是“解釋”的方法更快,但是如果一段代碼要執(zhí)行多次,使用“解釋”的方法,程序每次運(yùn)行時(shí)都需要重新“解釋”一遍,而“編譯”的方法則不需要了。這樣一看,“編譯”的整體效率似乎更高,因?yàn)樗肋h(yuǎn)只翻譯一次,而“解釋”是運(yùn)行一次翻譯一次。并且,“編譯”由于是一開(kāi)始就對(duì)整個(gè)代碼進(jìn)行的,所以可以對(duì)代碼進(jìn)行針對(duì)性的優(yōu)化。
JavaScript 是使用“解釋”的方案來(lái)運(yùn)行的,這就造成了它的效率低下,因?yàn)榇a每運(yùn)行一次都要翻譯一次,如果一個(gè)函數(shù)被循環(huán)調(diào)用了 10 次、100 次,這個(gè)執(zhí)行效率可想而知。
好在聰明的人類發(fā)明了 JIT(Just-in-time)技術(shù),它綜合了“解釋”與“編譯”的優(yōu)點(diǎn),它的原理實(shí)際上就是在“解釋”運(yùn)行的同時(shí)進(jìn)行跟蹤,如果某一段代碼執(zhí)行了多次,就會(huì)對(duì)這一段代碼進(jìn)行編譯優(yōu)化,這樣,如果后續(xù)再運(yùn)行到這一段代碼,則不用再解釋了。
JIT 似乎是一個(gè)好東西,但是,對(duì)于 JavaScript 這種動(dòng)態(tài)數(shù)據(jù)類型的語(yǔ)言來(lái)說(shuō),要實(shí)現(xiàn)一個(gè)完美的 JIT 非常難。為什么呢?因?yàn)?JavaScript 中的很多東西都是在運(yùn)行的時(shí)候才能確定的。比如我寫了一行代碼:const sum = (a, b, c) => a + b + c;,這是一個(gè)使用 ES6 語(yǔ)法編寫的 JavaScript 箭頭函數(shù),可以直接放在瀏覽器的控制臺(tái)下運(yùn)行,這將聲明一個(gè)叫做 sum 的函數(shù)。然后我們可以直接調(diào)用它,比如:console.log(sum(1, 2, 3)),任何一個(gè)合格的前端開(kāi)發(fā)人員都能很快得口算出答案,這將輸出一個(gè)數(shù)字 6。但是,如果我們這樣調(diào)用呢:console.log(sum("1", 2, 3)),第一個(gè)參數(shù)變成了一個(gè)字符串,這在 JavaScript 中是完全允許的,但是這時(shí)得到的結(jié)果就完全不同了,這會(huì)導(dǎo)致一個(gè)字符串和兩個(gè)數(shù)字進(jìn)行連接,得到 "123"。這樣一來(lái),針對(duì)這一個(gè)函數(shù)的優(yōu)化就變得非常困難了。
雖說(shuō) JavaScript 自身的“特性”為 JIT 的實(shí)現(xiàn)帶來(lái)了一些困難,但是不得不說(shuō) JIT 還是為 JavaScript 帶來(lái)了非常可觀的性能提升。
WebAssembly為了能讓代碼跑得更快,WebAssembly 出現(xiàn)了(并且現(xiàn)在主流瀏覽器也都開(kāi)始支持了),它能夠允許你預(yù)先使用“編譯”的方法將代碼編譯好后,直接放在瀏覽器中運(yùn)行,這一步就做得比較徹底了,不再需要 JIT 來(lái)動(dòng)態(tài)得進(jìn)行優(yōu)化了,所有優(yōu)化都可以在編譯的時(shí)候直接確定。
WebAssembly 到底是什么呢?
首先,它不是直接的機(jī)器語(yǔ)言,因?yàn)槭澜缟系臋C(jī)器太多了,它們都說(shuō)著不同的語(yǔ)言(架構(gòu)不同),所以很多情況下都是為各種不同的機(jī)器架構(gòu)專門生成對(duì)應(yīng)的機(jī)器代碼。但是要為各種機(jī)器都生成的話,太復(fù)雜了,每種語(yǔ)言都要為每種架構(gòu)編寫一個(gè)編譯器。為了簡(jiǎn)化這個(gè)過(guò)程,就有了“中間代碼(Intermediate representation,IR)”,只要將所有代碼都翻譯成 IR,再由 IR 來(lái)統(tǒng)一應(yīng)對(duì)各種機(jī)器架構(gòu)。
實(shí)際上,WebAssembly 和 IR 差不多,就是用于充當(dāng)各種機(jī)器架構(gòu)翻譯官的角色。WebAssembly 并不是直接的物理機(jī)器語(yǔ)言,而是抽象出來(lái)的一種虛擬的機(jī)器語(yǔ)言。從 WebAssembly 到機(jī)器語(yǔ)言雖說(shuō)也需要一個(gè)“翻譯”過(guò)程,但是在這里的“翻譯”就沒(méi)有太多的套路了,屬于機(jī)器語(yǔ)言到機(jī)器語(yǔ)言的翻譯,所以速度上已經(jīng)非常接近純機(jī)器語(yǔ)言了。
這里有一個(gè) WebAssembly 官網(wǎng)上提供的 Demo,是使用 [Unity] 開(kāi)發(fā)并發(fā)布為 WebAssembly 的一個(gè)小游戲:https://webassembly.org/demo/,可以去體驗(yàn)體驗(yàn)。.wasm 文件 與 .wat 文件
WebAssembly 是通過(guò) *.wasm 文件進(jìn)行存儲(chǔ)的,這是編譯好的二進(jìn)制文件,它的體積非常的小。
在瀏覽器中,提供了一個(gè)全局的 window.WebAssembly 對(duì)象,可以用于實(shí)例化 WASM 模塊。
WebAssembly 是一種“虛擬機(jī)器語(yǔ)言”,所以它也有對(duì)應(yīng)的“匯編語(yǔ)言”版本,也就是 *.wat 文件,這是 WebAssembly 模塊的文本表示方法,采用“S-表達(dá)式(S-Expressions)”進(jìn)行描述,可以直接通過(guò)工具將 *.wat 文件編譯為 *.wasm 文件。熟悉 [LISP] 的同學(xué)可能對(duì)這種表達(dá)式語(yǔ)法比較熟悉。
一個(gè)非常簡(jiǎn)單的例子我們來(lái)看一個(gè)非常簡(jiǎn)單的例子,這個(gè)已經(jīng)在 Chrome 69 Canary 和 Chrome 70 Canary 中測(cè)試通過(guò),理論上可以在所有已經(jīng)支持 WebAssembly 的瀏覽器中運(yùn)行。(在后文中有瀏覽器的支持情況)
首先,我們先使用 S-表達(dá)式 編寫一個(gè)十分簡(jiǎn)單的程序:
;; test.wat (module (import "env" "mem" (memory 1)) ;; 這里指定了從 env.mem 中導(dǎo)入一個(gè)內(nèi)存對(duì)象 (func (export "get") (result i32) ;; 定義并導(dǎo)出一個(gè)叫做“get”的函數(shù),這個(gè)函數(shù)擁有一個(gè) int32 類型的返回值,沒(méi)有參數(shù) memory.size)) ;; 最終返回 memory 對(duì)象的“尺寸”(單位為“頁(yè)”,目前規(guī)定 1 頁(yè) = 64 KiB = 65536 Bytes)
可以使用 [wabt] 中的 [wasm2wat] 工具將 wasm 文件轉(zhuǎn)為使用“S-表達(dá)式”進(jìn)行描述的 wat 文件。同時(shí)也可以使用 [wat2wasm] 工具將 wat 轉(zhuǎn)為 wasm。
在 wat 文件中,雙分號(hào) ;; 開(kāi)頭的內(nèi)容都是注釋。
上面這個(gè) wat 文件定義了一個(gè) module,并導(dǎo)入了一個(gè)內(nèi)存對(duì)象,然后導(dǎo)出了一個(gè)叫做“get”的函數(shù),這個(gè)函數(shù)返回當(dāng)前內(nèi)存的“尺寸”。
在 WebAssembly 中,線性內(nèi)存可以在內(nèi)部直接定義然后導(dǎo)出,也可以從外面導(dǎo)入,但是最多只能擁有一個(gè)內(nèi)存。這個(gè)內(nèi)存的大小并不是固定的,只需要給一個(gè)初始大小 initial,后期還可以根據(jù)需要調(diào)用 grow 函數(shù)進(jìn)行擴(kuò)展,也可以指定最大大小 maximum。(這里所有內(nèi)存大小的單位都是“頁(yè)”,目前規(guī)定的是 1 頁(yè) = 64 KiB = 65536 Bytes。)
上面這個(gè) 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 ..
為了讓這個(gè)程序能在瀏覽器中運(yùn)行,我們還必須使用 JavaScript 編寫一段“膠水代碼(glue code)”,以便這個(gè)程序能被加載到瀏覽器中并執(zhí)行:
// 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(); // 調(diào)用 WebAssembly 模塊導(dǎo)出的 get 函數(shù) console.log(result); // 1 memory.grow(2); result = mod.instance.exports.get(); // 調(diào)用 WebAssembly 模塊導(dǎo)出的 get 函數(shù) console.log(result); // 3
這里我使用了現(xiàn)代瀏覽器都已經(jīng)支持的 ES6 語(yǔ)法,首先,使用瀏覽器原生提供的 fetch 函數(shù)加載我們編譯好的 test.wasm 文件。注意,這里根據(jù)規(guī)范,HTTP 響應(yīng)的 Content-Type 中指定的 MIME 類型必須為 application/wasm。
接下來(lái),我們 new 了一個(gè) WebAssembly.Memory 對(duì)象,通過(guò)這個(gè)對(duì)象,可以實(shí)現(xiàn) JavaScript 與 WebAssembly 之間互通數(shù)據(jù)。
再接下來(lái),我們使用了 WebAssembly.instantiateStreaming 來(lái)實(shí)例化加載的 WebAssembly 模塊,這里第一個(gè)參數(shù)是一個(gè) Readable Stream,第二個(gè)參數(shù)是 importObject,用于指定導(dǎo)入 WebAssembly 的結(jié)構(gòu)。因?yàn)樯厦娴?wat 代碼中指定了要從 env.mem 導(dǎo)入一個(gè)內(nèi)存對(duì)象,所以這里就得要將我們 new 出來(lái)的內(nèi)存對(duì)象放到 env.mem 中。
WebAssembly 還提供了一個(gè) instantiate 函數(shù),這個(gè)函數(shù)的第一個(gè)參數(shù)可以提供一個(gè) [ArrayBuffer] 或是 [TypedArray]。但是這個(gè)函數(shù)是不推薦使用的,具體原因做過(guò)流量代理轉(zhuǎn)發(fā)的同學(xué)可能會(huì)比較清楚,這里就不具體解釋了。
最后,我們就可以調(diào)用 WebAssembly 導(dǎo)出的函數(shù) get 了,首先輸出的內(nèi)容為 memory 的 initial 的值。然后我們調(diào)用了 memory.grow 方法來(lái)增長(zhǎng) memory 的尺寸,最后輸出的內(nèi)容就是增長(zhǎng)后內(nèi)存的大小 1 + 2 = 3。
一個(gè) WebAssembly 與 JavaScript 數(shù)據(jù)互通交互的例子在 WebAssembly 中有一塊內(nèi)存,這塊內(nèi)存可以是內(nèi)部定義的,也可以是從外面導(dǎo)入的,如果是內(nèi)部定義的,則可以通過(guò) export 進(jìn)行導(dǎo)出。JavaScript 在拿到這塊“內(nèi)存”后,是擁有完全操作的權(quán)利的。JavaScript 使用 [DataView] 對(duì) Memory 對(duì)象進(jìn)行包裝后,就可以使用 DataView 下面的函數(shù)對(duì)內(nèi)存對(duì)象進(jìn)行讀取或?qū)懭氩僮鳌?/p>
這里是一個(gè)簡(jiǎn)單的例子:
;; 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))))
這個(gè)代碼首先從 env.mem 導(dǎo)入一個(gè)內(nèi)存對(duì)象作為默認(rèn)內(nèi)存,這和前面的例子是一樣的。
然后從 js.log 導(dǎo)入一個(gè)函數(shù),這個(gè)函數(shù)擁有一個(gè) 32 位整型的參數(shù),不需要返回值,在 wat 內(nèi)部被命名為“$log”,這個(gè)名字只存在于 wat 文件中,在編譯為 wasm 后就不存在了,只存儲(chǔ)一個(gè)偏移地址。
后面定義了一個(gè)函數(shù),并導(dǎo)出為“example”函數(shù)。在 WebAssembly 中,函數(shù)里的內(nèi)容都是在棧上的。
首先,使用 i32.const 0 在棧內(nèi)壓入一個(gè) 32 位整型常數(shù) 0,然后使用 i64.const 8022916924116329800 在棧內(nèi)壓入一個(gè) 64 位整型常數(shù) 8022916924116329800,之后調(diào)用 i64.store 指令,這個(gè)指令將會(huì)將棧頂部第一個(gè)位置的一個(gè) 64 位整數(shù)存儲(chǔ)到棧頂部第二個(gè)位置指定的“內(nèi)存地址”開(kāi)始的連續(xù) 8 個(gè)字節(jié)空間中。
簡(jiǎn)而言之,就是在內(nèi)存的第 0 個(gè)位置開(kāi)始的連續(xù) 8 個(gè)字節(jié)的空間里,存入一個(gè) 64 位整型數(shù)字 8022916924116329800。這個(gè)數(shù)字轉(zhuǎn)為 16 進(jìn)制表示為:0x 6f 57 20 6f 6c 6c 65 48,但是由于 WebAssembly 中規(guī)定的[字節(jié)序]是使用“小端序(Little-Endian Byte Order)”來(lái)存儲(chǔ)數(shù)據(jù),所以,在內(nèi)存中第 0 個(gè)位置存儲(chǔ)的是 0x48,第 1 個(gè)位置存儲(chǔ)的是 0x65……所以,最終存儲(chǔ)的實(shí)際上是 0x 48 65 6c 6c 6f 20 57 6f,對(duì)應(yīng)著 [ASCII] 碼為:"Hello Wo"。
然后,后面的一句指令 (i32.store (i32.const 8) (i32.const 560229490)) 的格式是上面三條指令的“S-表達(dá)式”形式,只不過(guò)這里換成了 i32.store 來(lái)存儲(chǔ)一個(gè) 32 位整型常數(shù) 560229490 到 8 號(hào)“內(nèi)存地址”開(kāi)始的連續(xù) 4 個(gè)字節(jié)空間中。
實(shí)際上這一句指令的寫法寫成上面三句的語(yǔ)法是完全等效的:
i32.const 8 i32.const 560229490 i32.store
類似的,這里是在內(nèi)存的第 8 個(gè)位置開(kāi)始的連續(xù) 4 個(gè)字節(jié)的空間里,存入一個(gè) 32 位整型數(shù)字 560229490。這個(gè)數(shù)字轉(zhuǎn)為 16 進(jìn)制表示位:0x 21 64 6c 72,同樣采用“小端序”來(lái)存儲(chǔ),所以存儲(chǔ)的實(shí)際上是 0x 72 6c 64 21,對(duì)應(yīng)著 [ASCII] 碼為:"rld!"。
所以,最終,內(nèi)存中前 12 個(gè)字節(jié)中的數(shù)據(jù)為 0x 48 65 6c 6c 6f 20 57 6f 72 6c 64 21,連起來(lái)就是對(duì)應(yīng)著 [ASCII] 碼:"Hello World!"。
將這個(gè) 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....
接下來(lái),還是使用 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(); // 調(diào)用 WebAssembly 模塊導(dǎo)出的 example 函數(shù)
這里,使用 DataView 對(duì) memory 進(jìn)行了一次包裝,這樣就可以方便地對(duì)內(nèi)存對(duì)象進(jìn)行讀寫操作了。
然后,這里在 JavaScript 中實(shí)現(xiàn)了一個(gè) log 函數(shù),函數(shù)接受一個(gè)參數(shù)(這個(gè)參數(shù)在上面的 wat 中指定了是整數(shù)型)。下面的實(shí)現(xiàn)首先是確定輸出的字符串長(zhǎng)度(字符串通常以 "