淺談WebAssembly

生動形象地介紹 WebAssembly

你可能已經聽說過,WebAssembly 執行的更快。但是 WebAssembly 爲什麼執行的更快呢?

在這個系列文章中,我會爲你解釋這一點。

等等,什麼是 WebAssembly?

WebAssembly 是除了 JavaScript 以外,另一種可以在瀏覽器中執行的編程語言。所以當人們說 WebAssembly 更快的時候,一般來講是與 JavaScript 相比而言的。

我不會暗示大家開發時對二者的選擇——或者選擇 WebAssembly,或者選擇 JavaScript。實際上,我們更希望在同一個工程中,兩個你同時使用。

對二者的比較倒是非常有必要的,這樣你就可以瞭解到 WebAssembly 所擁有的獨特特性。

一些關於性能的歷史

JavaScript 於 1995 年問世,它的設計初衷並不是爲了執行起來快,在前 10 個年頭,它的執行速度也確實不快。

緊接着,瀏覽器市場競爭開始激烈起來。

被人們廣爲傳播的“性能大戰”在 2008 年打響。許多瀏覽器引入了 Just-in-time 編譯器,也叫 JIT。基於 JIT 的模式,JavaScript 代碼的運行漸漸變快。

正是由於這些 JIT 的引入,使得 JavaScript 的性能達到了一個轉折點,JS 代碼執行速度快了 10 倍。

隨着性能的提升,JavaScript 可以應用到以前根本沒有想到過的領域,比如用於後端開發的 Node.js。性能的提升使得 JavaScript 的應用範圍得到很大的擴展。

現在通過 WebAssembly,我們很有可能正處於第二個拐點。

所以,接下來,我們深入瞭解一下爲什麼 WebAssembly 更快、執行效率更高。

JavaScript Just-in-time (JIT) 工作原理

JavaScript 的啓動比較緩慢,但是通過 JIT 可以使其變快,那麼 JIT 是如何起作用的呢?

JavaScript 在瀏覽器中是如何運行的?

如果是你一個開發者,當你決定在你的頁面中使用 JavaScript 的時候,有兩個要考慮的事情:目標和問題。

目標:告訴計算機你想做什麼。

問題:你和計算機說不同的語言,無法溝通。

你說的是人類的語言,而計算機用的是機器語言。機器語言也是一種語言,只是 JavaScript 或者其他高級編程語言機器能看得懂,而人類不用他們來交流罷了。它們是基於人類認知而設計出來的。

所以呢,JavaScript 引擎的工作就是把人類的語言轉換成機器能看懂的語言。

這就像電影《降臨》中,人類和外星人的互相交流一樣。

在電影裏面,人類和外星人不僅僅是語言不同,兩個羣體看待世界的方式都是不一樣的。其實人類和機器也是類似(後面我會詳細介紹)。

那麼翻譯是如何進行的呢?

在代碼的世界中,通常有兩種方式來翻譯機器語言:解釋器和編譯器。

如果是通過解釋器,翻譯是一行行地邊解釋邊執行

編譯器是把源代碼整個編譯成目標代碼,執行時不再需要編譯器,直接在支持目標代碼的平臺上運行。

這兩種翻譯的方式都各有利弊。

解釋器的利弊

解釋器啓動和執行的更快。你不需要等待整個編譯過程完成就可以運行你的代碼。從第一行開始翻譯,就可以依次繼續執行了。

正是因爲這個原因,解釋器看起來更加適合 JavaScript。對於一個 Web 開發人員來講,能夠快速執行代碼並看到結果是非常重要的。

這就是爲什麼最開始的瀏覽器都是用 JavaScript 解釋器的原因。

可是當你運行同樣的代碼一次以上的時候,解釋器的弊處就顯現出來了。比如你執行一個循環,那解釋器就不得不一次又一次的進行翻譯,這是一種效率低下的表現。

編譯器的利弊

編譯器的問題則恰好相反。

它需要花一些時間對整個源代碼進行編譯,然後生成目標文件才能在機器上執行。對於有循環的代碼執行的很快,因爲它不需要重複的去翻譯每一次循環。

另外一個不同是,編譯器可以用更多的時間對代碼進行優化,以使的代碼執行的更快。而解釋器是在 runtime 時進行這一步驟的,這就決定了它不可能在翻譯的時候用很多時間進行優化。

Just-in-time 編譯器:綜合了兩者的優點

爲了解決解釋器的低效問題,後來的瀏覽器把編譯器也引入進來,形成混合模式。

不同的瀏覽器實現這一功能的方式不同,不過其基本思想是一致的。在 JavaScript 引擎中增加一個監視器(也叫分析器)。監視器監控着代碼的運行情況,記錄代碼一共運行了多少次、如何運行的等信息。

起初,監視器監視着所有通過解釋器的代碼。

如果同一行代碼運行了幾次,這個代碼段就被標記成了 “warm”,如果運行了很多次,則被標記成 “hot”。

基線編譯器

如果一段代碼變成了 “warm”,那麼 JIT 就把它送到編譯器去編譯,並且把編譯結果存儲起來。

代碼段的每一行都會被編譯成一個“樁”(stub),同時給這個樁分配一個以“行號 + 變量類型”的索引。如果監視器監視到了執行同樣的代碼和同樣的變量類型,那麼就直接把這個已編譯的版本 push 出來給瀏覽器。

通過這樣的做法可以加快執行速度,但是正如前面我所說的,編譯器還可以找到更有效地執行代碼的方法,也就是做優化。

基線編譯器可以做一部分這樣的優化(下面我會給出例子),不過基線編譯器優化的時間不能太久,因爲會使得程序的執行在這裏 hold 住。

不過如果代碼確實非常 “hot”(也就是說幾乎所有的執行時間都耗費在這裏),那麼花點時間做優化也是值得的。

優化編譯器

如果一個代碼段變得 “very hot”,監視器會把它發送到優化編譯器中。生成一個更快速和高效的代碼版本出來,並且存儲之。

爲了生成一個更快速的代碼版本,優化編譯器必須做一些假設。例如,它會假設由同一個構造函數生成的實例都有相同的形狀——就是說所有的實例都有相同的屬性名,並且都以同樣的順序初始化,那麼就可以針對這一模式進行優化。

整個優化器起作用的鏈條是這樣的,監視器從他所監視代碼的執行情況做出自己的判斷,接下來把它所整理的信息傳遞給優化器進行優化。如果某個循環中先前每次迭代的對象都有相同的形狀,那麼就可以認爲它以後迭代的對象的形狀都是相同的。可是對於 JavaScript 從來就沒有保證這麼一說,前 99 個對象保持着形狀,可能第 100 個就少了某個屬性。

正是由於這樣的情況,所以編譯代碼需要在運行之前檢查其假設是不是合理的。如果合理,那麼優化的編譯代碼會運行,如果不合理,那麼 JIT 會認爲做了一個錯誤的假設,並且把優化代碼丟掉。

這時(發生優化代碼丟棄的情況)執行過程將會回到解釋器或者基線編譯器,這一過程叫做去優化。

通常優化編譯器會使得代碼變得更快,但是一些情況也會引起一些意想不到的性能問題。如果你的代碼一直陷入優化<->去優化的怪圈,那麼程序執行將會變慢,還不如基線編譯器快。

大多數的瀏覽器都做了限制,當優化/去優化循環發生的時候會嘗試跳出這種循環。比如,如果 JIT 做了 10 次以上的優化並且又丟棄的操作,那麼就不繼續嘗試去優化這段代碼了樁。

一個優化的例子:類型特化(Type specialization)

有很多不同類型的優化方法,這裏我介紹一種,讓大家能夠明白是如何優化的。優化編譯器最成功一個特點叫做類型特化,下面詳細解釋。

JavaScript 所使用的動態類型體系在運行時需要進行額外的解釋工作,例如下面代碼:

+= 循環中這一步看起來很簡單,只需要進行一步計算,但是恰恰因爲是用動態類型,他所需要的步驟要比你所想象的更復雜一些。

我們假設 arr 是一個有 100 個整數的數組。當代碼被標記爲 “warm” 時,基線編譯器就爲函數中的每一個操作生成一個樁。 sum+=arr[i] 會有一個相應的樁,並且把裏面的 += 操作當成整數加法。

但是, sum 和 arr[i] 兩個數並不保證都是整數。因爲在 JavaScript 中類型都是動態類型,在接下來的循環當中, arr[i] 很有可能變成了 string 類型。整數加法和字符串連接是完全不同的兩個操作,會被編譯成不同的機器碼。

JIT 處理這個問題的方法是編譯多基線樁。如果一個代碼段是單一形態的(即總是以同一類型被調用),則只生成一個樁。如果是多形態的(即調用的過程中,類型不斷變化),則會爲操作所調用的每一個類型組合生成一個樁。

這就是說 JIT 在選擇一個樁之前,會進行多分枝選擇,類似於決策樹,問自己很多問題纔會確定最終選擇哪個,見下圖:

正是因爲在基線編譯器中每行代碼都有自己的樁,所以 JIT 在每行代碼被執行的時候都會檢查數據類型。在循環的每次迭代,JIT 也都會重複一次分枝選擇。

如果代碼在執行的過程中,JIT 不是每次都重複檢查的話,那麼執行的還會更快一些,而這就是優化編譯器所需要做的工作之一了。

優化編譯器中,整個函數被統一編譯,這樣的話就可以在循環開始執行之前進行類型檢查。

一些瀏覽器的 JIT 優化更加複雜。比如在 Firefox 中,給一些數組設定了特定的類型,比如裏面只包含整型。如果 arr 是這種數組類型,那麼 JIT 就不需要檢查 arr[i] 是不是整型了,這也意味着 JIT 可以在進入循環之前進行所有的類型檢查。

總結

簡而言之 JIT 是什麼呢?它是使 JavaScript 運行更快的一種手段,通過監視代碼的運行狀態,把 hot 代碼(重複執行多次的代碼)進行優化。通過這種方式,可以使 JavaScript 應用的性能提升很多倍。

爲了使執行速度變快,JIT 會增加很多多餘的開銷,這些開銷包括:

優化和去優化開銷
監視器記錄信息對內存的開銷
發生去優化情況時恢復信息的記錄對內存的開銷
對基線版本和優化後版本記錄的內存開銷
這裏還有很大的提升空間:即消除開銷。通過消除開銷使得性能上有進一步地提升,這也是 WebAssembly 所要做的事之一。

編譯器如何生成彙編

理解什麼是彙編,以及編譯器如何生成它,對於理解 WebAssembly 是很有幫助的。

在上一篇關於 JIT 的文章中,我介紹了和計算機打交道,就像同外星人打交道一樣。

現在來思考一下“外星人”的大腦是如何工作的——機器的“大腦”是如何對我們輸入給它的內容進行分析和理解的。

“大腦”中,有一部分負責思考——處理加法、減法或者邏輯運算。還有其他的部分分別負責短暫記憶和長期記憶的。

這些不同的部分都有自己的名字:

負責思考的部分叫做算數邏輯單元(ALU)
寄存器提供短暫記憶功能
隨機存取存儲器(RAM)提供長期記憶功能

機器代碼中的語句稱作指令。

那麼在指令進入“大腦”以後都發生了什麼呢?它們會被切分爲不同的部分傳送到不同的單元進行處理。

“大腦”切分指令通過不同連接線路進行。舉個例子,“大腦”會將指令最開始的 6 比特通過管道送到 ALU 中。而 ALU 會通過 0 和 1 的位置來決定對兩個數做加法。

這串 01 串就叫做“操作碼”,它告訴了 ALU 要執行什麼樣的操作。

然後“大腦”會取後面兩個連續的 3 比特 01 串來確定把哪兩個數加到一起,而這 3 比特指的是寄存器的地址。

注意看上面機器碼的註釋:“ADD R1 R2”,這對於人類來講很容易理解其含義。這就是彙編,也叫符號機器碼,它使人類也能看懂機器代碼的含義。

可以看到彙編和這臺機器的機器碼之間有直接的映射關係。正是因爲如此,擁有不同機器結構的計算機會有不同的彙編系統。如果你有一個機器,它有自己的內部結構,那麼它就需要它所獨有的彙編語言。

從上面的分析可以知道我們進行機器碼的翻譯並不是只有一種,不同的機器有不同的機器碼,就像我們人類也說各種各樣的語言一樣,機器也“說”不同的語言。

人類和外星人之間的語言翻譯,可能會從英語、德語或中文翻譯到外星語 A 或者外星語 B。而在程序的世界裏,則是從 C、C++ 或者 JAVA 翻譯到 x86 或者 ARM。

你想要從任意一個高級語言翻譯到衆多彙編語言中的一種(依賴機器內部結構),其中一種方式是創建不同的翻譯器來完成各種高級語言到彙編的映射。

這種翻譯的效率實在太低了。爲了解決這個問題,大多數編譯器都會在中間多加一層。它會把高級語言翻譯到一個低層,而這個低層又沒有低到機器碼這個層級。這就是中間代碼( intermediate representation,IR)。

這就是說編譯器會把高級語言翻譯到 IR 語言,而編譯器另外的部分再把 IR 語言編譯成特定目標結構的可執行代碼。

重新總結一下:編譯器的前端把高級語言翻譯到 IR,編譯器的後端把 IR 翻譯成目標機器的彙編代碼。

總結

本文介紹了什麼是彙編以及編譯器是如何把高級語言翻譯成彙編語言的。

WebAssembly 工作原理

WebAssembly 是除了 JavaScript 以外,另一種可以在網頁中運行的編程語言。過去如果你想在瀏覽器中運行代碼來對網頁中各種元素進行控制,只有 JavaScript 這一種選擇。

所以當人們談論 WebAssembly 的時候,往往會拿 JavaScript 來進行比較。但是它們其實並不是“二選一”的關係——並不是只能用 WebAssembly 或者 JavaScript。

實際上,我們鼓勵開發者將這兩種語言一起使用,即使你不親自實現 WebAssembly 模塊,你也可以學習它現有的模塊,並它的優勢來實現你的功能。

WebAssembly 模塊定義的一些功能可以通過 JavaScript 來調用。所以就像你通過 npm 下載 lodash 模塊並通過 API 使用它一樣,未來你也可以下載 WebAssembly 模塊並且使用其提供的功能。

那麼就讓我們來看一下如何開發 WebAssembly 模塊,以及如何通過 JavaScript 使用他們。

WebAssembly 處於哪個環節?

在上一篇關於彙編的文章中,我介紹了編譯器是如何從高級語言翻譯到機器碼的。

那麼在上圖中,WebAssembly 在什麼位置呢?實際上,你可以把它看成另一種“目標彙編語言”。

每一種目標彙編語言(x86、ARM)都依賴於特定的機器結構。當你想要把你的代碼放到用戶的機器上執行的時候,你並不知道目標機器結構是什麼樣的。

而 WebAssembly 與其他的彙編語言不一樣,它不依賴於具體的物理機器。可以抽象地理解成它是概念機器的機器語言,而不是實際的物理機器的機器語言。

正因爲如此,WebAssembly 指令有時也被稱爲虛擬指令。它比 JavaScript 代碼更直接地映射到機器碼,它也代表了“如何能在通用的硬件上更有效地執行代碼”的一種理念。所以它並不直接映射成特定硬件的機器碼。

瀏覽器把 WebAssembly 下載下來,然後先經過 WebAssembly 模塊,再到目標機器的彙編代碼。

編譯到 .wasm 文件

目前對於 WebAssembly 支持情況最好的編譯器工具鏈是 LLVM。有很多不同的前端和後端插件可以用在 LLVM 上。

提示:很多 WebAssembly 開發者用 C 語言或者 Rust 開發,再編譯成 WebAssembly。其實還有其他的方式來開發 WebAssembly 模塊。例如利用 TypeScript 開發 WebAssembly 模塊,或者直接用 WebAssembly 文本也可以。
假設想從 C 語言到 WebAssembly,我們就需要 clang 前端來把 C 代碼變成 LLVM 中間代碼。當變換成了 LLVM IR 時,說明 LLVM 已經理解了代碼,它會對代碼自動地做一些優化。

爲了從 LLVM IR 生成 WebAssembly,還需要後端編譯器。在 LLVM 的工程中有正在開發中的後端,而且應該很快就開發完成了,現在這個時間節點,暫時還看不到它是如何起作用的。

還有一個易用的工具,叫做 Emscripten。它通過自己的後端先把代碼轉換成自己的中間代碼(叫做 asm.js),然後再轉化成 WebAssembly。實際上它背後也是使用的 LLVM。

Emscripten 還包含了許多額外的工具和庫來包容整個 C/C++ 代碼庫,所以它更像是一個軟件開發者工具包(SDK)而不是編譯器。例如系統開發者需要文件系統以對文件進行讀寫,Emscripten 就有一個 IndexedDB 來模擬文件系統。

不考慮太多的這些工具鏈,只要知道最終生成了 .wasm 文件就可以了。後面我會介紹 .wasm 文件的結構,在這之前先一起了解一下在 JS 中如何使用它。

加載一個 .wasm 模塊到 JavaScript

.wasm 文件是 WebAssembly 模塊,它可以加載到 JavaScript 中使用,現階段加載的過程稍微有點複雜。

如果想深入瞭解,可以在 MDN 文檔中瞭解更多。

我們一直在致力於把這一過程變得簡單,對工具鏈進行優化。希望能夠把它整合到現有的模塊打包工具中,比如 webpack 中,或者整合到加載器中,比如 SystemJS 中。我們相信加載 WebAssembly 模塊也可以像加載 JavaScript 一樣簡單。

這裏介紹 WebAssembly 模塊和 JavaScript 模塊的主要區別。當前的 WebAssembly 只能使用數字(整型或者浮點型)作爲參數或者返回值。

對於任何其他的複雜類型,比如 string,就必須得用 WebAssembly 模塊的內存操作了。如果是經常使用 JavaScript,對直接操作內存不是很熟悉的話,可以回想一下 C、C++ 和 Rust 這些語言,它們都是手動操作內存。WebAssembly 的內存操作和這些語言的內存操作很像。

爲了實現這個功能,它使用了 JavaScript 中稱爲 ArrayBuffer 的數據結構。ArrayBuffer 是一個字節數組,所以它的索引(index)就相當於內存地址了。

如果你想在 JavaScript 和 WebAssembly 之間傳遞字符串,可以利用 ArrayBuffer 將其寫入內存中,這時候 ArrayBuffer 的索引就是整型了,可以把它傳遞給 WebAssembly 函數。此時,第一個字符的索引就可以當做指針來使用。

這就好像一個 web 開發者在開發 WebAssembly 模塊時,把這個模塊包裝了一層外衣。這樣其他使用者在使用這個模塊的時候,就不用關心內存管理的細節。

如果你想了解更多的內存管理,看一下我們寫的 WebAssembly 的內存操作。

.wasm 文件結構

如果你是寫高級語言的開發者,並且通過編譯器編譯成 WebAssembly,那你不用關心 WebAssembly 模塊的結構。但是瞭解它的結構有助於你理解一些基本問題。

如果你對編譯器還不瞭解,建議先讀一下“WebAssembly 系列(三)編譯器如何生成彙編”這篇文章。

這段代碼是即將生成 WebAssembly 的 C 代碼:

這是模塊的“二進制”表示。之所以用引號把“二進制”引起來,是因爲上面其實是用十六進制表示的,不過把它變成二進制或者人們能看懂的十進制表示也很容易。

例如,下面是 num + 42 的各種表示方法。

代碼是如何工作的:基於棧的虛擬機

如果你對具體的操作過程很好奇,那麼這幅圖可以告訴你指令都做了什麼。

從圖中我們可以注意到 加 操作並沒有指定哪兩個數字進行加。這是因爲 WebAssembly 是採用“基於棧的虛擬機”的機制。即一個操作符所需要的所有值,在操作進行之前都已經存放在堆棧中。

所有的操作符,比如加法,都知道自己需要多少個值。 加 需要兩個值,所以它從堆棧頂部取兩個值就可以了。那麼 加指令就可以變的更短(單字節),因爲指令不需要指定源寄存器和目的寄存器。這也使得 .wasm 文件變得更小,進而使得加載 .wasm 文件更快。

儘管 WebAssembly 使用基於棧的虛擬機,但是並不是說在實際的物理機器上它就是這麼生效的。當瀏覽器翻譯 WebAssembly 到機器碼時,瀏覽器會使用寄存器,而 WebAssembly 代碼並不指定用哪些寄存器,這樣做的好處是給瀏覽器最大的自由度,讓其自己來進行寄存器的最佳分配。

WebAssembly 模塊的組成部分

除了上面介紹的,.wasm 文件還有其他部分,通常把它們叫做部件。一些部件對於模塊來講是必須的,一些是可選的。

必須部分:

Type。在模塊中定義的函數的函數聲明和所有引入函數的函數聲明。
Function。給出模塊中每個函數一個索引。
Code。模塊中每個函數的實際函數體。
可選部分:

Export。使函數、內存、表單(table)、全局變量等對其他 WebAssembly 或 JavaScript 可見,允許動態鏈接一些分開編譯的組件,即 .dll 的WebAssembly 版本。
Import。允許從其他 WebAssembly 或者 JavaScript 中引入指定的函數、內存、表單或者全局變量。
Start。當 WebAssembly 模塊加載進來的時候,可以自動運行的函數(類似於 main 函數)。
Global。聲明模塊的全局變量。
Memory。定義模塊用到的內存。
Table。使得可以映射到 WebAssembly 模塊以外的值,如映射到 JavaScript 對象中。這在間接函數調用時很有用。
Data。初始化內存。
Element。初始化表單(table)。
如果想要了解更多的部件,可以在“如何使用部件”中深入瞭解。

爲什麼 WebAssembly 更快?

上一篇文章中,我介紹瞭如何編寫 WebAssembly 程序,也表達了我希望看到更多的開發者在自己的工程中同時使用 WebAssembly 和 JavaScript 的期許。

開發者們不必糾結於到底選擇 WebAssembly 還是 JavaScript,已經有了 JavaScript 工程的開發者們,希望能把部分 JavaScript 替換成 WebAssembly 來嘗試使用。

例如,正在開發 React 程序的團隊可以把調節器代碼(即虛擬 DOM)替換成 WebAssembly 的版本。而對於你的 web 應用的用戶來說,他們就跟以前一樣使用,不會發生任何變化,同時他們還能享受到 WebAssembly 所帶來的好處——快。

而開發者們選擇替換爲 WebAssembly 的原因正是因爲 WebAssembly 比較快。那麼爲什麼它執行的快呢?我們來一起了解一下。

當前的 JavaScript 性能如何?

在我們瞭解 JavaScript 和 WebAssembly 的性能區別之前,需要先理解 JS 引擎的工作原理。

下面這張圖片介紹了性能使用的大概分佈情況。

JS 引擎在圖中各個部分所花的時間取決於頁面所用的 JavaScript 代碼。圖表中的比例並不代表真實情況下的確切比例情況。

圖中的每一個顏色條都代表了不同的任務:

Parsing——表示把源代碼變成解釋器可以運行的代碼所花的時間;
Compiling + optimizing——表示基線編譯器和優化編譯器花的時間。一些優化編譯器的工作並不在主線程運行,不包含在這裏。
Re-optimizing——當 JIT 發現優化假設錯誤,丟棄優化代碼所花的時間。包括重優化的時間、拋棄並返回到基線編譯器的時間。
Execution——執行代碼的時間
Garbage collection——垃圾回收,清理內存的時間
這裏注意:這些任務並不是離散執行的,或者按固定順序依次執行的。而是交叉執行,比如正在進行解析過程時,其他一些代碼正在運行,而另一些正在編譯。

這樣的交叉執行給早期 JavaScript 帶來了很大的效率提升,早期的 JavaScript 執行類似於下圖,各個過程順序進行:

早期時,JavaScript 只有解釋器,執行起來非常慢。當引入了 JIT 後,大大提升了執行效率,縮短了執行時間。

JIT 所付出的開銷是對代碼的監視和編譯時間。JavaScript 開發者可以像以前那樣開發 JavaScript 程序,而同樣的程序,解析和編譯的時間也大大縮短。這就使得開發者們更加傾向於開發更復雜的 JavaScript 應用。

同時,這也說明了執行效率上還有很大的提升空間。

WebAssembly 對比

下面是 WebAssembly 和典型的 web 應用的近似對比圖:

各種瀏覽器處理上圖中不同的過程,有着細微的差別,我用 SpiderMonkey 作爲模型來講解不同的階段:

文件獲取

這一步並沒有顯示在圖表中,但是這看似簡單地從服務器獲取文件這個步驟,卻會花費很長時間。

WebAssembly 比 JavaScript 的壓縮率更高,所以文件獲取也更快。即便通過壓縮算法可以顯著地減小 JavaScript 的包大小,但是壓縮後的 WebAssembly 的二進制代碼依然更小。

這就是說在服務器和客戶端之間傳輸文件更快,尤其在網絡不好的情況下。

解析

當到達瀏覽器時,JavaScript 源代碼就被解析成了抽象語法樹。

瀏覽器採用懶加載的方式進行,只解析真正需要的部分,而對於瀏覽器暫時不需要的函數只保留它的樁。

解析過後 AST (抽象語法樹)就變成了中間代碼(叫做字節碼),提供給 JS 引擎編譯。

而 WebAssembly 則不需要這種轉換,因爲它本身就是中間代碼。它要做的只是解碼並且檢查確認代碼沒有錯誤就可以了。

編譯和優化

上一篇關於 JIT 的文章中,我有介紹過,JavaScript 是在代碼的執行階段編譯的。因爲它是弱類型語言,當變量類型發生變化時,同樣的代碼會被編譯成不同版本。

不同瀏覽器處理 WebAssembly 的編譯過程也不同,有些瀏覽器只對 WebAssembly 做基線編譯,而另一些瀏覽器用 JIT 來編譯。

不論哪種方式,WebAssembly 都更貼近機器碼,所以它更快,使它更快的原因有幾個:

在編譯優化代碼之前,它不需要提前運行代碼以知道變量都是什麼類型。
編譯器不需要對同樣的代碼做不同版本的編譯。
很多優化在 LLVM 階段就已經做完了,所以在編譯和優化的時候沒有太多的優化需要做。

重優化

有些情況下,JIT 會反覆地進行“拋棄優化代碼<->重優化”過程。

當 JIT 在優化假設階段做的假設,執行階段發現是不正確的時候,就會發生這種情況。比如當循環中發現本次循環所使用的變量類型和上次循環的類型不一樣,或者原型鏈中插入了新的函數,都會使 JIT 拋棄已優化的代碼。

反優化過程有兩部分開銷。第一,需要花時間丟掉已優化的代碼並且回到基線版本。第二,如果函數依舊頻繁被調用,JIT 可能會再次把它發送到優化編譯器,又做一次優化編譯,這是在做無用功。

在 WebAssembly 中,類型都是確定了的,所以 JIT 不需要根據變量的類型做優化假設。也就是說 WebAssembly 沒有重優化階段。

執行

自己也可以寫出執行效率很高的 JavaScript 代碼。你需要了解 JIT 的優化機制,例如你要知道什麼樣的代碼編譯器會對其進行特殊處理(JIT 文章裏面有提到過)。

然而大多數的開發者是不知道 JIT 內部的實現機制的。即使開發者知道 JIT 的內部機制,也很難寫出符合 JIT 標準的代碼,因爲人們通常爲了代碼可讀性更好而使用的編碼模式,恰恰不合適編譯器對代碼的優化。

加之 JIT 會針對不同的瀏覽器做不同的優化,所以對於一個瀏覽器優化的比較好,很可能在另外一個瀏覽器上執行效率就比較差。

正是因爲這樣,執行 WebAssembly 通常會比較快,很多 JIT 爲 JavaScript 所做的優化在 WebAssembly 並不需要。另外,WebAssembly 就是爲了編譯器而設計的,開發人員不直接對其進行編程,這樣就使得 WebAssembly 專注於提供更加理想的指令(執行效率更高的指令)給機器就好了。

執行效率方面,不同的代碼功能有不同的效果,一般來講執行效率會提高 10% - 800%。

垃圾回收

JavaScript 中,開發者不需要手動清理內存中不用的變量。JS 引擎會自動地做這件事情,這個過程叫做垃圾回收。

可是,當你想要實現性能可控,垃圾回收可能就是個問題了。垃圾回收器會自動開始,這是不受你控制的,所以很有可能它會在一個不合適的時機啓動。目前的大多數瀏覽器已經能給垃圾回收安排一個合理的啓動時間,不過這還是會增加代碼執行的開銷。

目前爲止,WebAssembly 不支持垃圾回收。內存操作都是手動控制的(像 C、C++一樣)。這對於開發者來講確實增加了些開發成本,不過這也使代碼的執行效率更高。

總結

WebAssembly 比 JavaScript 執行更快是因爲:

文件抓取階段,WebAssembly 比 JavaScript 抓取文件更快。即使 JavaScript 進行了壓縮,WebAssembly 文件的體積也比 JavaScript 更小;
解析階段,WebAssembly 的解碼時間比 JavaScript 的解析時間更短;
編譯和優化階段,WebAssembly 更具優勢,因爲 WebAssembly 的代碼更接近機器碼,而 JavaScript 要先通過服務器端進行代碼優化。
重優化階段,WebAssembly 不會發生重優化現象。而 JS 引擎的優化假設則可能會發生“拋棄優化代碼<->重優化”現象。
執行階段,WebAssembly 更快是因爲開發人員不需要懂太多的編譯器技巧,而這在 JavaScript 中是需要的。WebAssembly 代碼也更適合生成機器執行效率更高的指令。
垃圾回收階段,WebAssembly 垃圾回收都是手動控制的,效率比自動回收更高。
這就是爲什麼在大多數情況下,同一個任務 WebAssembly 比 JavaScript 表現更好的原因。

但是,還有一些情況 WebAssembly 表現的會不如預期;同時 WebAssembly 的未來也會朝着使 WebAssembly 執行效率更高的方向發展。

WebAssembly 的現在與未來

2017 年 2 月 28 日,四個主要的瀏覽器一致同意宣佈 WebAssembly 的MVP 版本已經完成,它是一個瀏覽器可以搭載的穩定版本。

它提供了瀏覽器可以搭載的穩定核,這個核並沒有包含 WebAssembly 組織所計劃的所有特徵,而是提供了可以使 WebAssembly 穩定運行的基本版本。

這樣一來開發者就可以使用 WebAssembly 代碼了。對於舊版本的瀏覽器,開發者可以通過 asm.js 來向下兼容代碼,asm.js 是 JavaScript 的一個子集,所有 JS 引擎都可以使用它。另外,通過 Emscripten 工具,你可以把你的應用編譯成 WebAssembly 或者 asm.js。

儘管是第一個版本,WebAssembly 已經能發揮出它的優勢了,未來通過不斷地改善和融入新特徵,WebAssembly 會變的更快。

提升瀏覽器中 WebAssembly 的性能

隨着各種瀏覽器都使自己的引擎支持 WebAssembly,速度提升就變成自然而然的了,目前各大瀏覽器廠商都在積極推動這件事情。

JavaScript 和 WebAssembly 之間調用的中間函數

目前,在 JS 中調用 WebAssembly 的速度比本應達到的速度要慢。這是因爲中間需要做一次“蹦牀運動”。JIT 沒有辦法直接處理 WebAssembly,所以 JIT 要先把 WebAssembly 函數發送到懂它的地方。這一過程是引擎中比較慢的地方。

按理來講,如果 JIT 知道如何直接處理 WebAssembly 函數,那麼速度會有百倍的提升。

如果你傳遞的是單一任務給 WebAssembly 模塊,那麼不用擔心這個開銷,因爲只有一次轉換,也會比較快。但是如果是頻繁地從 WebAssembly 和 JavaScript 之間切換,那麼這個開銷就必須要考慮了。

快速加載

JIT 必須要在快速加載和快速執行之間做權衡。如果在編譯和優化階段花了大量的時間,那麼執行的必然會很快,但是啓動會比較慢。目前有大量的工作正在研究,如何使預編譯時間和程序真正執行時間兩者平衡。

WebAssembly 不需要對變量類型做優化假設,所以引擎也不關心在運行時的變量類型。這就給效率的提升提供了更多的可能性,比如可以使編譯和執行這兩個過程並行。

加之最新增加的 JavaScript API 允許 WebAssembly 的流編譯,這就使得在字節流還在下載的時候就啓動編譯。

FireFox 目前正在開發兩個編譯器系統。一個編譯器先啓動,對代碼進行部分優化。在代碼已經開始運行時,第二個編譯器會在後臺對代碼進行全優化,當全優化過程完畢,就會將代碼替換成全優化版本繼續執行。

添加後續特性到 WebAssembly 標準的過程

WebAssembly 的發展是採用小步迭代的方式,邊測試邊開發,而不是預先設計好一切。

這就意味着有很多功能還在襁褓之中,沒有經過徹底思考以及實際驗證。它們想要寫進標準,還要通過所有的瀏覽器廠商的積極參與。

這些特性叫做:未來特性。這裏列出幾個。

直接操作 DOM

目前 WebAssembly 沒有任何方法可以與 DOM 直接交互。就是說你還不能通過比如 element.innerHTML 的方法來更新節點。

想要操作 DOM,必須要通過 JS。那麼你就要在 WebAssembly 中調用 JavaScript 函數(WebAssembly 模塊中,既可以引入 WebAssembly 函數,也可以引入 JavaScript 函數)。

不管怎麼樣,都要通過 JS 來實現,這比直接訪問 DOM 要慢得多,所以這是未來一定要解決的一個問題。

共享內存的併發性

提升代碼執行速度的一個方法是使代碼並行運行,不過有時也會適得其反,因爲不同的線程在同步的時候可能會花費更多的時間。

這時如果能夠使不同的線程共享內存,那就能降低這種開銷。實現這一功能 WebAssembly 將會使用 JavaScript 中的 SharedArrayBuffer,而這一功能的實現將會提高程序執行的效率。

SIMD(單指令,多數據)

如果你之前瞭解過 WebAssembly 相關的內容,你可能會聽說過 SIMD,全稱是:Single Instruction, Multiple Data(單指令,多數據),這是並行化的另一種方法。

SIMD 在處理存放大量數據的數據結構有其獨特的優勢。比如存放了很多不同數據的 vector(容器),就可以用同一個指令同時對容器的不同部分做處理。這種方法會大幅提高複雜計算的效率,比如遊戲或者 VR。

這對於普通 web 應用開發者不是很重要,但是對於多媒體、遊戲開發者非常關鍵。

異常處理

許多語言都仿照 C++ 式的異常處理,但是 WebAssembly 並沒有包含異常處理。

如果你用 Emscripten 編譯代碼,就知道它會模擬異常處理,但是這一過程非常之慢,慢到你都想用 “DISABLEEXCEPTIONCATCHING” 標記把異常處理關掉。

如果異常處理加入到了 WebAssembly,那就不用採用模擬的方式了。而異常處理對於開發者來講又特別重要,所以這也是未來的一大功能點。

其他改進——使開發者開發起來更簡單

一些未來特性不是針對性能的,而是使開發者開發 WebAssembly 更方便。

一流的開發者工具。目前在瀏覽器中調試 WebAssembly 就像調試彙編一樣,很少的開發者可以手動地把自己的源代碼和彙編代碼對應起來。我們在致力於開發出更加適合開發者調試源代碼的工具。
垃圾回收。如果你能提前確定變量類型,那就可以把你的代碼變成 WebAssembly,例如 TypeScript 代碼就可以編譯成 WebAssembly。但是現在的問題是 WebAssembly 沒辦法處理垃圾回收的問題,WebAssembly 中的內存操作都是手動的。所以 WebAssembly 會考慮提供方便的 GC 功能,以方便開發者使用。
ES6 模塊集成。目前瀏覽器在逐漸支持用 script 標記來加載 JavaScript 模塊。一旦這一功能被完美執行,那麼像 <scriptsrc=urltype=“module”> 這樣的標記就可以運行了,這裏的 url 可以換成 WebAssembly 模塊。
總結

WebAssembly 執行起來更快,隨着瀏覽器逐步支持了 WebAssembly 的各種特性,WebAssembly 將會變得更快。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章