當(dāng)前 WebAssembly 的狀況
上一篇文章《關(guān)于WebAssembly 的背景知識》讓大家基本了解了 WebAssembly ,接下來我們繼續(xù)介紹WebAssembly 工作原理以及為什么 WebAssembly 運(yùn)行的更快。
一、WebAssembly 工作原理
WebAssembly 是除了 JavaScript 以外,另一種可以在網(wǎng)頁中運(yùn)行的編程語言。過去如果你想在瀏覽器中運(yùn)行代碼來對網(wǎng)頁中各種元素進(jìn)行控制,只有 JavaScript 這一種選擇。
所以當(dāng)人們談?wù)?WebAssembly 的時(shí)候,往往會(huì)拿 JavaScript 來進(jìn)行比較。但是它們其實(shí)并不是“二選一”的關(guān)系——并不是只能用 WebAssembly 或者 JavaScript。
實(shí)際上,我們鼓勵(lì)開發(fā)者將這兩種語言一起使用,即使你不親自實(shí)現(xiàn) WebAssembly 模塊,你也可以學(xué)習(xí)它現(xiàn)有的模塊,并它的優(yōu)勢來實(shí)現(xiàn)你的功能。
WebAssembly 模塊定義的一些功能可以通過 JavaScript 來調(diào)用。所以就像你通過 npm 下載 lodash 模塊并通過 API 使用它一樣,未來你也可以下載 WebAssembly 模塊并且使用其提供的功能。
那么,就讓我們來看一下如何開發(fā) WebAssembly 模塊,以及如何通過 JavaScript 使用他們。
1. WebAssembly 處于哪個(gè)環(huán)節(jié)?
在上一篇關(guān)于WebAssembly 背景知識的文章中,我介紹了編譯器是如何從高級語言翻譯到機(jī)器碼的。
那么在上圖中,WebAssembly 在什么位置呢?實(shí)際上,你可以把它看成另一種“目標(biāo)匯編語言”。
每一種目標(biāo)匯編語言(x86、ARM)都依賴于特定的機(jī)器結(jié)構(gòu)。當(dāng)你想要把你的代碼放到用戶的機(jī)器上執(zhí)行的時(shí)候,你并不知道目標(biāo)機(jī)器結(jié)構(gòu)是什么樣的。
而 WebAssembly 與其他的匯編語言不一樣,它不依賴于具體的物理機(jī)器??梢猿橄蟮乩斫獬伤歉拍顧C(jī)器的機(jī)器語言,而不是實(shí)際的物理機(jī)器的機(jī)器語言。
正因?yàn)槿绱耍琖ebAssembly 指令有時(shí)也被稱為虛擬指令。它比 JavaScript 代碼更直接地映射到機(jī)器碼,它也代表了“如何能在通用的硬件上更有效地執(zhí)行代碼”的一種理念。所以它并不直接映射成特定硬件的機(jī)器碼。
瀏覽器把 WebAssembly 下載下來后,可以迅速地將其轉(zhuǎn)換成機(jī)器匯編代碼。
2. 編譯到 .wasm 文件
目前對于 WebAssembly 支持情況***的編譯器工具鏈?zhǔn)?LLVM。有很多不同的前端和后端插件可以用在 LLVM 上。
提示:很多 WebAssembly 開發(fā)者用 C 語言或者 Rust 開發(fā),再編譯成 WebAssembly。其實(shí)還有其他的方式來開發(fā) WebAssembly 模塊。例如利用 TypeScript 開發(fā) WebAssembly 模塊,或者直接用文本格式的 WebAssembly 也可以。
假設(shè)想從 C 語言到 WebAssembly,我們就需要 clang 前端來把 C 代碼變成 LLVM 中間代碼。當(dāng)變換成了 LLVM IR 時(shí),說明 LLVM 已經(jīng)理解了代碼,它會(huì)對代碼自動(dòng)地做一些優(yōu)化。
為了從 LLVM IR 生成 WebAssembly,還需要后端編譯器。在 LLVM 的工程中有正在開發(fā)中的后端,而且應(yīng)該很快就開發(fā)完成了,現(xiàn)在這個(gè)時(shí)間節(jié)點(diǎn),暫時(shí)還看不到它是如何起作用的。
還有一個(gè)易用的工具,叫做 Emscripten。它通過自己的后端先把代碼轉(zhuǎn)換成自己的中間代碼(叫做 asm.js),然后再轉(zhuǎn)化成 WebAssembly。實(shí)際上它背后也是使用的 LLVM。
Emscripten 還包含了許多額外的工具和庫來包容整個(gè) C/C++ 代碼庫,所以它更像是一個(gè)軟件開發(fā)者工具包(SDK)而不是編譯器。例如系統(tǒng)開發(fā)者需要文件系統(tǒng)以對文件進(jìn)行讀寫,Emscripten 就有一個(gè) IndexedDB 來模擬文件系統(tǒng)。
不考慮太多的這些工具鏈,只要知道最終生成了 .wasm 文件就可以了。后面我會(huì)介紹 .wasm 文件的結(jié)構(gòu),在這之前先一起了解一下在 JS 中如何使用它。
3. 加載一個(gè) .wasm 模塊到 JavaScript
.wasm 文件是 WebAssembly 模塊,它可以加載到 JavaScript 中使用,現(xiàn)階段加載的過程稍微有點(diǎn)復(fù)雜。
- function fetchAndInstantiate(url, importObject) {
- return fetch(url).then(response =>
- response.arrayBuffer()
- ).then(bytes =>
- WebAssembly.instantiate(bytes, importObject)
- ).then(results =>
- results.instance
- );
- }
如果想深入了解,可以在 MDN 文檔中了解更多。
我們一直在致力于把這一過程變得簡單,對工具鏈進(jìn)行優(yōu)化。希望能夠把它整合到現(xiàn)有的模塊打包工具中,比如 webpack 中,或者整合到加載器中,比如 SystemJS 中。我們相信加載 WebAssembly 模塊也可以像加載 JavaScript 一樣簡單。
這里介紹 WebAssembly 模塊和 JavaScript 模塊的主要區(qū)別。當(dāng)前的 WebAssembly 只能使用數(shù)字(整型或者浮點(diǎn)型)作為參數(shù)或者返回值。
對于任何其他的復(fù)雜類型,比如 string,就必須得用 WebAssembly 模塊的內(nèi)存操作了。如果是經(jīng)常使用 JavaScript,對直接操作內(nèi)存不是很熟悉的話,可以回想一下 C、C++ 和 Rust 這些語言,它們都是手動(dòng)操作內(nèi)存。WebAssembly 的內(nèi)存操作和這些語言的內(nèi)存操作很像。
為了實(shí)現(xiàn)這個(gè)功能,它使用了 JavaScript 中稱為 ArrayBuffer 的數(shù)據(jù)結(jié)構(gòu)。ArrayBuffer 是一個(gè)字節(jié)數(shù)組,所以它的索引(index)就相當(dāng)于內(nèi)存地址了。
如果你想在 JavaScript 和 WebAssembly 之間傳遞字符串,可以利用 ArrayBuffer 將其寫入內(nèi)存中,這時(shí)候 ArrayBuffer 的索引就是整型了,可以把它傳遞給 WebAssembly 函數(shù)。此時(shí),***個(gè)字符的索引就可以當(dāng)做指針來使用。
這就好像一個(gè) web 開發(fā)者在開發(fā) WebAssembly 模塊時(shí),把這個(gè)模塊包裝了一層外衣。這樣其他使用者在使用這個(gè)模塊的時(shí)候,就不用關(guān)心內(nèi)存管理的細(xì)節(jié)。
如果你想了解更多的內(nèi)存管理,看一下我們寫的 WebAssembly 的內(nèi)存操作。
4. .wasm 文件結(jié)構(gòu)
如果你是寫高級語言的開發(fā)者,并且通過編譯器編譯成 WebAssembly,那你不用關(guān)心 WebAssembly 模塊的結(jié)構(gòu)。但是了解它的結(jié)構(gòu)有助于你理解一些基本問題。
如果你對編譯器還不了解,建議先讀一下“系列三之編譯器如何生成匯編這篇文章。
這段代碼是即將生成 WebAssembly 的 C 代碼:
- int add42(int num) {
- return num + 42;
- }
你可以使用 WASM Explorer 來編譯這個(gè)函數(shù)。
打開 .wasm 文件(假設(shè)你的編輯器支持的話),可以看到下面代碼:
- 00 61 73 6D 0D 00 00 00 01 86 80 80 80 00 01 60
- 01 7F 01 7F 03 82 80 80 80 00 01 00 04 84 80 80
- 80 00 01 70 00 00 05 83 80 80 80 00 01 00 01 06
- 81 80 80 80 00 00 07 96 80 80 80 00 02 06 6D 65
- 6D 6F 72 79 02 00 09 5F *** 35 61 64 64 34 32 69
- 00 00 0A 8D 80 80 80 00 01 87 80 80 80 00 00 20
- 00 41 2A 6A 0B
這是模塊的“二進(jìn)制”表示。之所以用引號把“二進(jìn)制”引起來,是因?yàn)樯厦嫫鋵?shí)是用十六進(jìn)制表示的,不過把它變成二進(jìn)制或者人們能看懂的十進(jìn)制表示也很容易。
例如,下面是 num + 42 的各種表示方法。
5. 代碼是如何工作的:基于棧的虛擬機(jī)
如果你對具體的操作過程很好奇,那么這幅圖可以告訴你指令都做了什么。
從圖中我們可以注意到 加 操作并沒有指定哪兩個(gè)數(shù)字進(jìn)行加。這是因?yàn)?WebAssembly 是采用“基于棧的虛擬機(jī)”的機(jī)制。即一個(gè)操作符所需要的所有值,在操作進(jìn)行之前都已經(jīng)存放在堆棧中。
所有的操作符,比如加法,都知道自己需要多少個(gè)值。加需要兩個(gè)值,所以它從堆棧頂部取兩個(gè)值就可以了。那么加指令就可以變的更短(單字節(jié)),因?yàn)橹噶畈恍枰付ㄔ醇拇嫫骱湍康募拇嫫?。這也使得 .wasm 文件變得更小,進(jìn)而使得加載 .wasm 文件更快。
盡管 WebAssembly 使用基于棧的虛擬機(jī),但是并不是說在實(shí)際的物理機(jī)器上它就是這么生效的。當(dāng)瀏覽器翻譯 WebAssembly 到機(jī)器碼時(shí),瀏覽器會(huì)使用寄存器,而 WebAssembly 代碼并不指定用哪些寄存器,這樣做的好處是給瀏覽器***的自由度,讓其自己來進(jìn)行寄存器的***分配。
6. WebAssembly 模塊的組成部分
除了上面介紹的,.wasm 文件還有其他部分。一些組成部分對于模塊來講是必須的,一些是可選的。
必須部分:
- Type。在模塊中定義的函數(shù)的函數(shù)聲明和所有引入函數(shù)的函數(shù)聲明。
- Function。給出模塊中每個(gè)函數(shù)一個(gè)索引。
- Code。模塊中每個(gè)函數(shù)的實(shí)際函數(shù)體。
可選部分:
- Export。使函數(shù)、內(nèi)存、表(tables)、全局變量等對其他 WebAssembly 或 JavaScript 可見,允許動(dòng)態(tài)鏈接一些分開編譯的組件,即 .dll 的WebAssembly 版本。
- Import。允許從其他 WebAssembly 或者 JavaScript 中導(dǎo)入指定的函數(shù)、內(nèi)存、表或者全局變量。
- Start。當(dāng) WebAssembly 模塊加載進(jìn)來的時(shí)候,可以自動(dòng)運(yùn)行的函數(shù)(類似于 main 函數(shù))。
- Global。聲明模塊的全局變量。
- Memory。定義模塊用到的內(nèi)存。
- Table。使得可以映射到 WebAssembly 模塊以外的值,如映射到 JavaScript 的對象。這在間接函數(shù)調(diào)用時(shí)很有用。
- Data。初始化導(dǎo)入的或者局部內(nèi)存。
- Element。初始化導(dǎo)入的或者局部的表。
如果你想了解關(guān)于這些組成部分的更深入的內(nèi)容,可以閱讀這些組成部分的工作原理。
二、為什么 WebAssembly 更快?
上面我介紹了如何編寫 WebAssembly 程序,也表達(dá)了我希望看到更多的開發(fā)者在自己的工程中同時(shí)使用 WebAssembly 和 JavaScript 的期許。
開發(fā)者們不必糾結(jié)于到底選擇 WebAssembly 還是 JavaScript,已經(jīng)有了 JavaScript 工程的開發(fā)者們,希望能把部分 JavaScript 替換成 WebAssembly 來嘗試使用。
例如,正在開發(fā) React 程序的團(tuán)隊(duì)可以把協(xié)調(diào)性代碼(即虛擬 DOM)替換成 WebAssembly 的版本。而對于你的 web 應(yīng)用的用戶來說,他們就跟以前一樣使用,不會(huì)發(fā)生任何變化,同時(shí)他們還能享受到 WebAssembly 所帶來的好處——快。
而開發(fā)者們選擇替換為 WebAssembly 的原因正是因?yàn)?WebAssembly 比較快。
1. 當(dāng)前的 JavaScript 性能如何?
在我們了解 JavaScript 和 WebAssembly 的性能區(qū)別之前,需要先理解 JS 引擎的工作原理。
下面這張圖片介紹了性能使用的大概分布情況。
JS 引擎在圖中各個(gè)部分所花的時(shí)間取決于頁面所用的 JavaScript 代碼。圖表中的比例并不代表真實(shí)情況下的確切比例情況。
圖中的每一個(gè)顏色條都代表了不同的任務(wù):
- Parsing——表示把源代碼變成解釋器可以運(yùn)行的代碼所花的時(shí)間;
- Compiling + optimizing——表示基線編譯器和優(yōu)化編譯器花的時(shí)間。一些優(yōu)化編譯器的工作并不在主線程運(yùn)行,不包含在這里。
- Re-optimizing——當(dāng) JIT 發(fā)現(xiàn)優(yōu)化假設(shè)錯(cuò)誤,丟棄優(yōu)化代碼所花的時(shí)間。包括重優(yōu)化的時(shí)間、拋棄并返回到基線編譯器的時(shí)間。
- Execution——執(zhí)行代碼的時(shí)間
- Garbage collection——垃圾回收,清理內(nèi)存的時(shí)間
這里注意:這些任務(wù)并不是離散執(zhí)行的,或者按固定順序依次執(zhí)行的。而是交叉執(zhí)行,比如正在進(jìn)行解析過程時(shí),其他一些代碼正在運(yùn)行,而另一些正在編譯。
這樣的交叉執(zhí)行給早期 JavaScript 帶來了很大的效率提升,早期的 JavaScript 執(zhí)行類似于下圖,各個(gè)過程順序進(jìn)行:
早期時(shí),JavaScript 只有解釋器,執(zhí)行起來非常慢。當(dāng)引入了 JIT 后,大大提升了執(zhí)行效率,縮短了執(zhí)行時(shí)間。
JIT 所付出的開銷是對代碼的監(jiān)視和編譯時(shí)間。JavaScript 開發(fā)者可以像以前那樣開發(fā) JavaScript 程序,而同樣的程序,解析和編譯的時(shí)間也大大縮短。這就使得開發(fā)者們更加傾向于開發(fā)更復(fù)雜的 JavaScript 應(yīng)用。
同時(shí),這也說明了執(zhí)行效率上還有很大的提升空間。
2. WebAssembly 對比
下面是 WebAssembly 和典型的 web 應(yīng)用的近似對比圖:
各種瀏覽器處理上圖中不同的過程,有著細(xì)微的差別,拿 SpiderMonkey 作為例子。
3. 文件獲取
這一步并沒有顯示在圖表中,但是這看似簡單地從服務(wù)器獲取文件這個(gè)步驟,卻會(huì)花費(fèi)很長時(shí)間。
WebAssembly 比 JavaScript 的壓縮率更高,所以文件獲取也更快。即便通過壓縮算法可以顯著地減小 JavaScript 的包大小,但是壓縮后的 WebAssembly 的二進(jìn)制代碼依然更小。
這就是說在服務(wù)器和客戶端之間傳輸文件更快,尤其在網(wǎng)絡(luò)不好的情況下。
4. 解析
當(dāng)?shù)竭_(dá)瀏覽器時(shí),JavaScript 源代碼就被解析成了抽象語法樹。
瀏覽器采用懶加載的方式進(jìn)行,只解析真正需要的部分,而對于瀏覽器暫時(shí)不需要的函數(shù)只保留它的樁(stub,譯者注:關(guān)于樁的解釋可以在之前的文章中有提及)。
解析過后 AST (抽象語法樹)就變成了中間代碼(叫做字節(jié)碼),提供給 JS 引擎編譯。
而 WebAssembly 則不需要這種轉(zhuǎn)換,因?yàn)樗旧砭褪侵虚g代碼。它要做的只是解碼并且檢查確認(rèn)代碼沒有錯(cuò)誤就可以了。
5. 編譯和優(yōu)化
在關(guān)于 JIT 的文章中,我有介紹過,JavaScript 是在代碼的執(zhí)行階段編譯的。因?yàn)樗侨躅愋驼Z言,當(dāng)變量類型發(fā)生變化時(shí),同樣的代碼會(huì)被編譯成不同版本。
不同瀏覽器處理 WebAssembly 的編譯過程也不同,有些瀏覽器只對 WebAssembly 做基線編譯,而另一些瀏覽器用 JIT 來編譯。
不論哪種方式,WebAssembly 都更貼近機(jī)器碼,所以它更快,使它更快的原因有幾個(gè):
在編譯優(yōu)化代碼之前,它不需要提前運(yùn)行代碼以知道變量都是什么類型。
編譯器不需要對同樣的代碼做不同版本的編譯。
很多優(yōu)化在 LLVM 階段就已經(jīng)做完了,所以在編譯和優(yōu)化的時(shí)候沒有太多的優(yōu)化需要做。
6. 重優(yōu)化
有些情況下,JIT 會(huì)反復(fù)地進(jìn)行“拋棄優(yōu)化代碼<->重優(yōu)化”過程。
當(dāng) JIT 在優(yōu)化假設(shè)階段做的假設(shè),執(zhí)行階段發(fā)現(xiàn)是不正確的時(shí)候,就會(huì)發(fā)生這種情況。比如當(dāng)循環(huán)中發(fā)現(xiàn)本次循環(huán)所使用的變量類型和上次循環(huán)的類型不一樣,或者原型鏈中插入了新的函數(shù),都會(huì)使 JIT 拋棄已優(yōu)化的代碼。
反優(yōu)化過程有兩部分開銷。***,需要花時(shí)間丟掉已優(yōu)化的代碼并且回到基線版本。第二,如果函數(shù)依舊頻繁被調(diào)用,JIT 可能會(huì)再次把它發(fā)送到優(yōu)化編譯器,又做一次優(yōu)化編譯,這是在做無用功。
在 WebAssembly 中,類型都是確定了的,所以 JIT 不需要根據(jù)變量的類型做優(yōu)化假設(shè)。也就是說 WebAssembly 沒有重優(yōu)化階段。
7. 執(zhí)行
自己也可以寫出執(zhí)行效率很高的 JavaScript 代碼。你需要了解 JIT 的優(yōu)化機(jī)制,例如你要知道什么樣的代碼編譯器會(huì)對其進(jìn)行特殊處理(JIT 文章里面有提到過)。
然而大多數(shù)的開發(fā)者是不知道 JIT 內(nèi)部的實(shí)現(xiàn)機(jī)制的。即使開發(fā)者知道 JIT 的內(nèi)部機(jī)制,也很難寫出符合 JIT 標(biāo)準(zhǔn)的代碼,因?yàn)槿藗兺ǔ榱舜a可讀性更好而使用的編碼模式,恰恰不合適編譯器對代碼的優(yōu)化。
加之 JIT 會(huì)針對不同的瀏覽器做不同的優(yōu)化,所以對于一個(gè)瀏覽器優(yōu)化的比較好,很可能在另外一個(gè)瀏覽器上執(zhí)行效率就比較差。
正是因?yàn)檫@樣,執(zhí)行 WebAssembly 通常會(huì)比較快,很多 JIT 為 JavaScript 所做的優(yōu)化在 WebAssembly 并不需要。另外,WebAssembly 就是為了編譯器而設(shè)計(jì)的,開發(fā)人員不直接對其進(jìn)行編程,這樣就使得 WebAssembly 專注于提供更加理想的指令(執(zhí)行效率更高的指令)給機(jī)器就好了。
執(zhí)行效率方面,不同的代碼功能有不同的效果,一般來講執(zhí)行效率會(huì)提高 10% - 800%。
8. 垃圾回收
JavaScript 中,開發(fā)者不需要手動(dòng)清理內(nèi)存中不用的變量。JS 引擎會(huì)自動(dòng)地做這件事情,這個(gè)過程叫做垃圾回收。
可是,當(dāng)你想要實(shí)現(xiàn)性能可控,垃圾回收可能就是個(gè)問題了。垃圾回收器會(huì)自動(dòng)開始,這是不受你控制的,所以很有可能它會(huì)在一個(gè)不合適的時(shí)機(jī)啟動(dòng)。目前的大多數(shù)瀏覽器已經(jīng)能給垃圾回收安排一個(gè)合理的啟動(dòng)時(shí)間,不過這還是會(huì)增加代碼執(zhí)行的開銷。
目前為止,WebAssembly 不支持垃圾回收。內(nèi)存操作都是手動(dòng)控制的(像 C、C++一樣)。這對于開發(fā)者來講確實(shí)增加了些開發(fā)成本,不過這也使代碼的執(zhí)行效率更高。
9. 總結(jié)
WebAssembly 比 JavaScript 執(zhí)行更快是因?yàn)椋?/p>
- 文件抓取階段,WebAssembly 比 JavaScript 抓取文件更快。即使 JavaScript 進(jìn)行了壓縮,WebAssembly 文件的體積也比 JavaScript 更小;
- 解析階段,WebAssembly 的解碼時(shí)間比 JavaScript 的解析時(shí)間更短;
- 編譯和優(yōu)化階段,WebAssembly 更具優(yōu)勢,因?yàn)?WebAssembly 的代碼更接近機(jī)器碼,而 JavaScript 要先通過服務(wù)器端進(jìn)行代碼優(yōu)化。
- 重優(yōu)化階段,WebAssembly 不會(huì)發(fā)生重優(yōu)化現(xiàn)象。而 JS 引擎的優(yōu)化假設(shè)則可能會(huì)發(fā)生“拋棄優(yōu)化代碼<->重優(yōu)化”現(xiàn)象。
- 執(zhí)行階段,WebAssembly 更快是因?yàn)殚_發(fā)人員不需要懂太多的編譯器技巧,而這在 JavaScript 中是需要的。WebAssembly 代碼也更適合生成機(jī)器執(zhí)行效率更高的指令。
- 垃圾回收階段,WebAssembly 垃圾回收都是手動(dòng)控制的,效率比自動(dòng)回收更高。
這就是為什么在大多數(shù)情況下,同一個(gè)任務(wù) WebAssembly 比 JavaScript 表現(xiàn)更好的原因。
但是,還有一些情況 WebAssembly 表現(xiàn)的會(huì)不如預(yù)期;同時(shí) WebAssembly 的未來也會(huì)朝著使 WebAssembly 執(zhí)行效率更高的方向發(fā)展。這些我會(huì)在下一篇文章《WebAssembly 的現(xiàn)在與未來》中介紹。
點(diǎn)擊《WebAssembly 系列(四)WebAssembly 工作原理》和《WebAssembly 系列(五)為什么 WebAssembly 更快?》閱讀原文。
【本文是51CTO專欄作者“胡子大哈”的原創(chuàng)文章,轉(zhuǎn)載請聯(lián)系作者本人獲取授權(quán)】