瀏覽器可以有多快?

mforever78發表於2015-08-26

  React.js 以高效的 UI 渲染著稱,其中一個很重要的原因是它維護了一個虛擬 DOM,使用者可以直接在虛擬 DOM 上進行操作,React.js 用 diff 演算法得出需要對瀏覽器 DOM 進行的最小操作,這樣就避免了手動大量修改 DOM 的時候造成的效能損失。等等,明明是在中間加了一層,為什麼結果反而變快了呢?React.js 的核心思想是認為 DOM 操作是緩慢的,因此可以需要最小化 DOM 操作,以換取整體的效能提升。DOM 操作慢是有目共睹的,而其他 JavaScript 指令碼的執行速度就一定快嗎?

  在 V8 出世之前,這個問題的答案是否定的。Google 早年商業模式建立在 Web 的基礎上,當它在瀏覽器中寫出 Gmail 這樣一個無比複雜的 Web app 的時候,它不可能意識不到瀏覽器難以忍受的效能,而這主要是因為 JavaScript 的執行速度太慢。2008 年 9 月,Google 決定自己造一個 JavaScript 引擎來改變這一現狀—— V8。當搭載著 V8 的 Chrome 瀏覽器出現在市場上的時候,它的速度遠遠甩開了當時的所有瀏覽器。瀏覽器效能的空前提升讓複雜的 Web app 成為了可能。

  近七年過去,瀏覽器的效能隨著 CPU 的效能不斷上升,但再也沒有獲得過 2008 年那樣突破性的增長。V8 到底用了什麼樣的技術讓 JavaScript 的效能獲得瞭如此大的提升呢?

 V8 的優化

  要說如何讓 JavaScript 變快,就應該先來談談它為什麼會慢。眾所周知 JavaScript 是 Brendan Eich 這個傢伙用了一週多的時間開發出來的,相比現如今如日中天的 Swift 是 Apple 的一個團隊四年工作的成果,你首先可能就不應該對它有過高的期待。事實上,Brendan Eich 並未意識到自己要開發的是這樣一個體量的語言。為了程式設計師編寫時的靈活,他將 JavaScript 設計成為弱型別的語言,並且在執行時可以對物件的屬性增添刪改。難倒一大群人的 C++ 中的繼承、多型,還有什麼模板、虛擬函式、動態繫結這些概念在 JavaScript 中完全不存在了。那這些工作誰來做了呢?自然就只有 JavaScript 引擎。由於不知道變數型別,它在執行時做著大量的型別推導工作。在 Parser 完成工作建出一棵抽象語法樹(AST)的時候,引擎會把這棵 AST 翻譯成位元組碼(bytecode)交給位元組碼直譯器去執行。其中最拖慢效能的一步就是直譯器執行位元組碼的階段。回望當時,大家不知道直譯器效能低下嗎?其實不是,這樣設計的原因是當時的人們普遍認為 JavaScript 作為一種給設計師開發的語言(前端工程師有沒有心裡一涼?),並不需要太高的效能,這樣做符合成本,也滿足需求。

  V8 做的工作主要就是去掉了這個拖慢引擎速度的部分,它從 AST 直接生成了 CPU 可執行的機器碼。這種即時編譯的技術被稱為 JIT (Just in time)。如果你足夠好奇,一個自然的想法就是,這到底是怎麼辦到的?

  我們舉一個例子來說:

function Foo(x, y) {
    this.x = x;
    this.y = y;
}

var foo = new Foo(7, 8);
var bar = new Foo(8, 7);
foo.z = 9;

  屬性讀取

  首先是資料結構。你打算如何索引物件的屬性?我們已經太熟悉 JSON 中 key: value 的資料結構,但在記憶體中可以以 key 來索引嗎?value 在記憶體中的位置可以確定嗎?當然可以,只要對每個物件維護一個表,裡面存著每個 key 對應的 value 在記憶體中的位置就可以了不是嗎?

  這裡的陷阱在於,你需要對每一個物件都維護這樣一個表。為什麼?我們來看看 C 語言是怎麼做的。

struct Foo {
    int x, y;
};

struct Foo foo, bar;

foo.x = 7;
foo.y = 8;
bar.x = 8;
bar.y = 7;

// Cant' set foo.z

  仔細想想大學時候的教材,foo.x 和 foo.y 的地址是可以直接算出來的呀。這是因為成員 x 和 y 的型別是確定的,JavaScript 裡完全可以 foo.x = "Hello" ,而 C 語言就沒辦法這樣做了。

  V8 不想給每個物件都維護一個這樣的表。它也想讓 JavaScript 擁有 C/C++ 直接用偏移就讀出屬性的特性。所以它的解決思路就是讓動態型別靜態化。V8 實現了一個叫做隱藏類(Hidden Class)的特性,即給每個物件分配一個隱藏類。對於 foo 物件,它生成一個類似於這樣的類:

class Foo {
    int x, y;
}

  當新建一個 bar 物件的時候,它的 x 和 y 屬性恰好都是 int 型別,那麼它和 foo 物件就共享了這個隱藏類。把型別確定以後,讀取屬性就只是在記憶體中增加一個偏移的事情了。而當 foo 新建了 z 屬性的時候,V8 發現原來的類不能用了,於是就會給 foo 新建一個隱藏類。修改屬性型別也是類似。

  Inline caching

  由上可知,當訪問一個物件的屬性的時候,V8 首先要做的就是確定物件當前的隱藏類。但每次這樣做的開銷也很大,那很容易想到的另一個計算機中常用的解決方案,就是快取。在第一次訪問給定物件屬性的時候,V8 將假設所有同一部分程式碼的其他物件也都使用了這個物件的隱藏類,於是會告訴其他物件直接使用這個類的資訊。在訪問其他物件的時候,如果校驗正確,那麼只需要一條指令就可以得到所需的屬性,如果失敗,V8 就會自動取消剛才的優化。上面這段話用程式碼來表述就是:

foo.x
# ebx = the foo object
cmp [ebx,<hidden class offset>],<cached hidden class>
jne <inline cache miss>
mov eax,[ebx, <cached x offset>]

  這極大提升了 V8 引擎的速度。

 還能更快嗎?

  隨著 Intel 宣佈 Tick-Tock 模型的延緩,CPU 處理速度不再能像之前一樣穩步增長了,那麼瀏覽器還能繼續變快嗎?V8 的優化是瀏覽器效能的終點嗎?

  JavaScript 的問題在於錯誤地假設前端工程師都是水平不高的程式設計人員(如果不是,你應該不會讀到這裡),豈圖讓程式設計師寫得舒服而讓計算機執行得痛苦。在現代瀏覽器引擎已經優化到這個地步的時候,我們不禁想問:為什麼一定是 JavaScript ?前端工程師是不是可以讓出一步,讓自己多做一點點事情,而讓引擎得以更高效地優化效能?JavaScript 成為事實上的瀏覽器指令碼標準有歷史原因,但這不能是我們停止進步的藉口。

  當 Web Assembly 正式宣佈的時候,我才確定了不僅僅是我一個名不見經傳的小程式設計師有這樣的想法,那些世界上最頂級的頭腦已經開始行動了。瀏覽器在大量需求的驅動下正在朝著一個高效能的方向前進,瀏覽器究竟可以有多快,2015 可能是這條路上另一個轉折點。

相關文章