V8引擎工作機制

V8引擎工作機制


0.前言

在翻譯文章從嵌入V8開始中,從一個較爲黑盒的角度介紹瞭如何將V8引擎嵌入到自己的C++項目中,並簡單的介紹了一些相關API和概念。
本文將會從一個概念層次上介紹V8引擎的工作機制。涉及的源碼不多,僅對一些接口進行介紹,後續會在其他文章中進行詳細的源碼分析。


1. 工作機制演化

V8引擎主要能力是將JavaScript源碼解釋編譯,並運行。現階段的工作機制可以分爲以下幾個階段:

  1. Parser:解析器,將JS源碼解析爲AST(抽象語法樹,Abstract Syntax Tree);
  2. Ignition:基於寄存器的解釋器,用於將AST轉換爲字節碼;
  3. TurboFan:基於Ignition生成的字節碼,生成對應平臺的機器碼,並對其進行優化和反優化。

總體而言可以用下圖來形象地表示:
V8工作機制

其實,該工作流程其實是經過較大的重構的。在5.9版本之前,V8引擎是沒有生成字節碼這一過程的,而是直接將AST通過Full-codegen快速生成爲未優化的機器碼,之後再通過Crankshaft對熱點函數進行優化編譯。
5.9版本之前的工作機制

這種方式跳過了字節碼,極大減少了轉換時間。

然而這種優化方式還是過於激進了,谷歌又對其進行了大幅度重構,最終產生了現在的引擎工作機制。這次重構的起因是Chrome的一次bug上報:crbug.com/593477,對Facebook第一次加載時,v8.CompileScript花費了165ms,再次加載加入v8.ParseLazy,花費時間增長到376ms。然而期望的情況時緩存功能應該對JS腳本的解析結果進行了緩存,花費時間不應該這麼長。

後面經過分析發現,因爲機器碼佔用空間過大,無法一次性將所有JS代碼編譯成機器碼緩存下來,所以只編譯最外層的JS代碼,內部代碼則留到第一次被調用時再編譯。然而Facebook的開發者將各個獨立module編譯成單獨的文件,其中用到很多閉包,如:

__d('getActiveElement', [], function (module) {
    var MY_CONST = 1;
    module.exports = function getActiveElement(){
        ...
    };
});

這就導致了,緩存機制只能作用於最外層的__d()上面了,內部真正需要緩存的邏輯代碼卻被忽略掉了。

通過這個bug可以看出直接轉換機器碼的問題,那就是機器碼佔用空間過大,無法一次性編譯全部代碼,而只運行一次的代碼又浪費了內存資源。在沒有引入字節碼時,大約有30%的堆空間用於存儲未優化的機器碼。

於是乎,V8團隊又將主流的字節碼引入了引擎,期望通過犧牲執行時間換空間。字節碼比機器碼緊湊很多,減少佔用內存空間;因爲內存佔用過大問題的消除,可以提前編譯所有的代碼,這樣又提高了代碼的啓動速度。同時這次重構也帶來了一些潛在的優點:1. 簡化了V8的代碼複雜度。在之前的機制中,每次新增一個JS語言特性,都需要對不同的編譯管線進行更新,而在新的Ignition+TurboFan管線中,則減少了巨大的工作量;2. 優化和反優化更便捷,因爲字節碼是固定不變的,所以TurboFan可以直接從字節碼來進行優化,同時反優化時,可以不再考慮JS源碼和AST。


2. 解析器Parser

解析器Parser的主要作用是將JS源碼轉換爲抽象語法樹AST。其代碼主要在./src/parsing/目錄中。核心類是Parser

// ./src/parsing/parser.h
class V8_EXPORT_PRIVATE Parser : public NON_EXPORTED_BASE(ParserBase<Parser>) {
  ...
};

這個類主要管理調度解析流程,內部擁有ScannerParseInfo等,最終會產生出一個AST。

舉個簡單例子:

function add(x, y) {
  return x + y;
}

可轉換爲下圖示意:
AST
詳細的代碼會在後續文章中分析。


3. Ignition

Ignition的總體設計可以參考V8引擎官方的設計文檔《Ignition design document》
Ignition的設計目標是爲V8建立一個解釋器來執行低層級的字節碼,可以讓只運行一次或非熱點的代碼以字節碼形式存儲,這樣可以使其空間更緊湊。

Ignition通過繼承於AstVisitorBytecodeGenerator類對函數的AST進行遍歷。BytecodeGenerator以函數爲單位,爲每個節點生成相應的字節碼,並作爲SharedFunctionInfo對象中的一個屬性與函數關聯。函數的代碼入口設置到BuiltinsInterpreterEntryTrampoline的stub中。函數運行時,這個stub先初始化合適的棧幀,然後爲第一個字節碼調度到字節碼處理程序,從而在解釋器中執行該函數。其中字節碼處理程序是由TurboFan生成的,與字節碼一一對應。

Ignition是基於寄存器的解釋器,這些寄存器是函數棧幀中分配的寄存器文件中特定的slot,即一小塊內存。根據字節碼的參數指定操作的寄存器。

仍以上面的add函數爲例。Ignition會遍歷這個AST,產生如下的字節碼:

StackCheck
Ldar a1
Add a0, [0]
Return

這裏的LdarStar等都有相應的字節碼處理程序,這些字節碼處理程序會由TurboFan生成。例如Ldar表示從寄存器中讀取數據加載到累加器中(LoaD Accumulator from Register),其對應的字節碼處理程序如下:

// ./src/interpreter/interpreter-generator.cc

// Load accumulator with value from register <src>.
IGNITION_HANDLER(Ldar, InterpreterAssembler) {
  TNode<Object> value = LoadRegisterAtOperandIndex(0);
  SetAccumulator(value);
  Dispatch();
}

這個字節碼處理程序並不直接調用,而是通過每個字節碼處理程序調度到下一個字節碼,即上面的Dispatch方法。

在生成字節碼過程中,BytecodeGenerator還會爲各種變量、Context對象指針等分配寄存器,具體的分配方式將會在專門的文章中通過代碼仔細分析。

因爲JavaScript時動態語言,一般只有在運行時才知道變量的確切類型。所以Ignition在運行函數時,還會收集一些信息(例如變量類型等),將其保存在反饋向量中,並將其傳遞給TurboFan,用於加速對字節碼的解釋運行。比如對o.x這樣的屬性訪問,V8會緩存其獲取過程的信息,並在後續執行相同字節碼時,不需要再次搜索對象ox的位置。這裏講到的獲取過程和緩存機制,就是V8高性能的殺手鐗——隱藏類(Hidden Class)內聯緩存(Inline Cache)。這些都會在後續文章中單獨分析講解。


4. TurboFan

參考文章《Introduction to TurboFan》

V8的管線是由解釋器和編譯器組成的,其中的解釋器就是上節介紹的Ignition,而編譯器就是本節所要介紹的TurboFan。TurboFan是V8的優化編譯器,借力於一個叫做“節點海(Sea of Nodes)”的概念。TurboFan的主要作用是將Ignition的字節碼編譯爲機器碼,並根據Ignition運行時提供的反饋向量進行優化或反優化。其優化管線如下圖所示:
TurboFan管線
TurboFan主要利用了基於類型預測的優化技術和基於節點海(“Sea of Nodes”)的機器碼生成技術。這些都會在後續單獨的文章中分析。


5. 垃圾回收

V8的高效垃圾回收機制也是其高性能的助力之一。
V8中所有的對象都是通過堆來分配的,當代碼聲明變量並且賦值時,該對象的內存就分配到堆中。如果堆內存不夠,就繼續申請內存,知道大小達到V8限制爲止。此時就會觸發V8的垃圾回收動作。

V8採取了一種分代回收的策略,即將堆內存劃分爲不同的生代,根據各個生代的特點執行不同的垃圾回收算法。V8裏主要會處理新生代和老生代兩個分區。

  • 新生代特點是區域小、回收頻繁。主要採用Scavenge算法,利用空間換時間。
  • 老生代特點是對象生命週期長,佔用內存較多。主要採用Mark-Sweep和Mark-Compact相結合的策略,節省空間。

詳細的算法分析會在單獨的文章中進行分析。


總結

本文主要從一些概念層次對V8引擎進行了介紹。V8的高性能是由其採用了很多優化方法相結合決定的。這些都非常值得我們去深入研究。

我也會在後續文章中不斷的進行學習、分析。

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