Closure in V8

發表於2024-02-11
本文作者:Vice

前言

對於我們前端開發來說,無時無刻不在接觸著閉包。比如在 React Hooks 中利用了閉包來捕獲元件的狀態,並在元件的生命週期中保持狀態的一致性。在 Vue 中利用閉包來定義計算屬性和監聽器,以及在元件之間共享資料。在 Angular 中利用閉包可以用於建立服務和依賴注入。

所以理解閉包產生的原因和原理對我們的日常開發非常重要。

熱個身

其實 JavaScript 本身的特性決定了一定要實現閉包:

  1. JavaScript 允許在函式內部定義新的函式。
  2. 因為 詞法作用域,可以在內部函式中訪問父函式中定義的變數。
  3. 函式作為一等公民,函式可以作為返回值。

利用上面三點列舉一個貫穿全文的 JavaScript 經典閉包程式碼:

function multi() {
    var a = 10;
    return function inner() {
        return a * 10;
    }
}
const p = multi();

此段程式碼宣告瞭 multi 函式,在函式內部定義了變數 a,並且返回了 inner 函式,inner 函式中訪問 multi 函式中宣告的 a,最後執行了 multi 函式並且將返回值返回給 p。這個時候閉包就建立完成啦,閉包讓開發者可以從內部函式訪問外部函式的作用域,p 函式始終能訪問到 multi 函式中的 a。

但是大家都知道,multi 函式執行完之後,理應內部宣告的變數都會被銷燬,但是因為閉包的原因,這個 a 變數突破了這種限制。

為了實現閉包,我們來看看 V8 都是怎麼做的吧。

V8 是如何執行一段 JavaScript 程式碼的

我們都知道,我們寫的 JavaScript 程式碼,是需要經過編譯的步驟,讓 CPU 獲取到一串二進位制的指令去執行的。完成這一步的通常有兩種方法:

  1. 解釋執行,將原始碼透過解析器生成中間程式碼,然後用直譯器解釋執行,它的優勢在於快速啟動執行,但執行速度相對較慢。
  2. 編譯執行,也是先生成中間程式碼,然後透過編譯器將中間程式碼直接轉換成二進位制程式碼,執行的時候直接執行二進位制檔案即可,它的優勢在於執行時直接操作二進位制檔案,執行速度更快,並且編譯過程只進行了一次,所以在多次執行相同程式碼時,編譯執行的效能更高,但是相對的啟動速度就會比較慢。

V8 採取的策略是混合編譯執行和解釋執行,也就是我們經常聽到的 JIT,是一種對上述兩種策略的一種權衡。流程如下:

V8 執行程式碼

  1. 初始化執行環境,比如堆疊空間、事件迴圈系統等。
  2. 解析器解析程式碼生成 AST 和作用域。
  3. 根據 AST 和作用域生成中間程式碼,也就是位元組碼。
  4. 直譯器解釋執行中間程式碼輸出結果。
  5. 監控直譯器執行,發現頻繁執行的熱點程式碼會生成二進位制程式碼以提高執行速度。
  6. 熱點程式碼改變或者執行頻率下降,編譯器會執行反最佳化重新讓這段程式碼生成位元組碼。

V8 遇到函式是如何編譯的?

上面說到執行 JavaScript 程式碼需要經過編譯到中間程式碼的步驟,但是實際上 V8 並不會把所有程式碼全部進行解析,是因為如果一次性編譯所有 JavaScript 程式碼,編譯時間會很長,需要全部編譯完才能執行程式碼,對使用者來說會感到嚴重的延遲特別是大型專案。並且編譯產生的大量中間程式碼會非常佔用記憶體資源,特別是移動裝置,記憶體的消耗是需要謹慎考慮的。

所以包括 V8,所有主流瀏覽器都實現了延遲解析(lazy parsing)。顧名思義,V8 會推遲對程式碼的解析,直到程式碼被實際執行時才進行解析。具體就是在解析器遇到函式宣告時,只會解析函式的宣告部分,而不會解析函式內部的程式碼。在執行函式的時候 V8 會對函式進行各種最佳化,例如內聯最佳化、型別推斷等。延遲解析也可以使 V8 有更多的執行上下文和執行時資訊,從而更好地進行最佳化,提高程式碼的執行效率。

我們來使用 D8 工具具體看個例子:

var top = 1;
function multi(a) {
    return a * 10;
}

透過 d8 --print-ast 命令列印出 AST 資訊:

V8 首先會接收到我們書寫的原始碼,為了理解這段原始碼,它需要結構化這段字串來生成原始碼中的語法結構和關係,便於後續 V8 的理解。比如語言轉換器 Babel、語法檢查工具 ESLint 等,底層都使用了 AST 去實現。
--- AST ---
FUNC at 0
. KIND 0
. LITERAL ID 0
. SUSPEND COUNT 0
. NAME ""
. INFERRED NAME ""
. DECLS
. . VARIABLE (0x7fa6a5810050) (mode = VAR, assigned = true) "top"
. . FUNCTION "multi" = function multi
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 10
. . . INIT at 10
. . . . VAR PROXY unallocated (0x7fa6a5810050) (mode = VAR, assigned = true) "top"
. . . . LITERAL 1

簡單解釋下這段被解析器解析生成的 AST,著重看 DECLSEXPRESSION STATEMENT

DECLS 代表一組宣告,此處宣告瞭一個名為 top 的變數,並且該變數被賦值(assigned = true)。還宣告瞭一個名為 multi 的函式。

EXPRESSION STATEMENT 表示一個表示式語句節點,這裡就是 var top = 1;,下面的內容代表這段表示式的結構化表述,將變數 top 的 proxy(指向了實際 top 的值,可以看到 0x7fbc75010c50 地址相同)並且初始化為字面量 1。

所以自始至終解析器並沒有解析函式體內部的程式碼,僅僅只解析了函式的宣告部分。

我們也可以透過 d8 --print-scopes 列印此時 multi 函式的作用域:

Global scope:
global { // (0x7ff32601e030) (0, 53)
  // will be compiled
  // NormalFunction
  // 1 stack slots
  // temporary vars:
  TEMPORARY .result;  // (0x7ff32601e530) local[0]
  // local vars:
  VAR top;  // (0x7ff32601e250) 
  VAR multi;  // (0x7ff32601e4a0) 

  function multi () { // (0x7ff32601e2e0) (27, 53)
    // lazily parsed
    // NormalFunction
    // 2 heap slots
  }
}

我們可以看到它沒有為 multi 函式生成作用域,而是進行 lazily parsed

那我們執行一下這個 multi 函式,看看 AST 會是什麼樣子:

var top = 1;
function multi(a) {
    return a * 10;
}
multi(3);
[generating bytecode for function: multi]
--- AST ---
FUNC at 27
. KIND 0
. LITERAL ID 1
. SUSPEND COUNT 0
. NAME "multi"
. PARAMS
. . VAR (0x7fe75782f670) (mode = VAR, assigned = false) "a"
. DECLS
. . VARIABLE (0x7fe75782f670) (mode = VAR, assigned = false) "a"
. RETURN at 37
. . MUL at 46
. . . VAR PROXY parameter[0] (0x7fe75782f670) (mode = VAR, assigned = false) "a"
. . . LITERAL 10

執行 multi 函式時,從 multi 函式物件中取出函式程式碼,和頂層程式碼一樣編譯為 AST 和位元組碼,然後再解釋執行,這裡我們簡單看看生成的 AST 吧:

PARAMS 代表函式引數部分,表示函式有一個引數 a,且該引數未被賦值(在執行階段才會指向堆和棧中相應的資料)。DECLS 中宣告瞭 a 變數,地址與引數 a 相同。RETURN at 代表函式返回語句位於原始碼的位置。MUL at 代表返回值是一個乘法表示式。下面一行代表乘法表示式的第一個運算元是引數 a。LITERAL 10 代表乘法表示式的第二個運算元是字面量 10。

延遲解析 & 閉包

當延遲解析遇到了閉包,那麼情況就又複雜了,我們來稍微改造一下上面的 multi 函式。

function multi() {
    var a = 10;
    return function inner() {
        return a * 10;
    }
}
const p = multi();

這是一段閉包程式碼,我們簡單分析下上述程式碼的執行流程:

  • 執行 multi 函式時,multi 函式會將它的內部函式 inner 返回給全域性變數 p。
  • 然後 multi 函式執行結束,執行上下文被 V8 銷燬。
V8 用執行上下文來維護執行當前程式碼所需要的變數宣告、this 指向等,比如這裡的 a 變數。
  • 雖然 multi 函式的執行上下文被銷燬了,但是被全域性 p 引用的 inner 函式引用了 multi 函式作用域中的變數 a。
為什麼 inner 函式中的 a 引用的是 multi 中的 a,這是因為 JavaScript 是基於詞法作用域,是靜態的作用域,和函式如何呼叫如何執行沒有關係,是程式碼編譯階段就決定好的,查詢順序都是照當前函式作用域向上冒泡,最後到全域性作用域。所以這裡的變數查詢規則為 inner 函式作用域 -> multi 函式作用域 -> 全域性作用域。

所以這裡就會帶來兩個問題?

  1. 當 multi 函式執行完成時,因為閉包的存在,此時 multi 的執行上下文被銷燬,但是 a 變數又被引用了,肯定不能被銷燬,那麼 V8 會採取什麼策略。
  2. 因為 V8 採用的延遲解析,在 inner 函式未執行的時候,是不會解析 inner 內部的程式碼的,所以 V8 並不知道是否引用了外部作用域中的變數。

預解析器(preparser)

V8 為了解決這兩個問題的,引入了 預解析器(preparser) 模組來解決,主要是做了兩件事:

  1. 當解析到頂層函式時,預解析器並不會直接跳過該函式,而是對該函式做一次快速的預解析,是為了判斷當前函式是不是存在一些語法上的錯誤。

報錯

在過去的版本中,預解析器在解析指令碼時會忽略變數宣告,例如在同一作用域中兩次宣告同名的變數應該被視為語法錯誤,但預解析器會允許這樣的程式碼透過預解析階段。當時是為了追求效能的提升,預解析器忽略了變數宣告的處理。現在修復後的預解析器能夠正確處理變數宣告和引用,符合ECMAScript規範,並且也沒有明顯的效能損失。
  1. 當執行函式時,只會將當前函式生成 AST 以及位元組碼,對內部宣告的其他函式進行預解析,是為了檢查函式內部是否引用了外部變數。如果函式內部引用了外部變數,預解析器會將這些變數從棧中複製(值型別複製值,引用型別複製地址)到堆中。這樣,在下次執行該函式時,函式可以直接使用堆中的引用,從而解決了閉包所帶來的問題。

我們來具體透過執行 multi 函式的位元組碼來理解下,透過 d8 --print-bytecode 來列印:

其實早期的 V8 為了提升程式碼的執行速度,是直接將 JavaScript 原始碼編譯成了沒有最佳化的二進位制的機器程式碼,但是隨著移動裝置的普及,V8 團隊逐漸發現將 JavaScript 原始碼直接編譯成二進位制程式碼存在兩個致命的問題。第一是編譯時間過久,影響程式碼啟動速度;第二是快取編譯後的二進位制程式碼佔用更多的記憶體。所以便引入位元組碼來解決上述啟動問題和空間問題。
[generated bytecode for function: multi (0x06d300259e19 <SharedFunctionInfo multi>)]
Bytecode length: 14
Parameter count 1
Register count 1
Frame size 8
Bytecode age: 0
         0x6d30025a092 @    0 : 83 00 01          CreateFunctionContext [0], [1]
         0x6d30025a095 @    3 : 1a fa             PushContext r0
         0x6d30025a097 @    5 : 0d 0a             LdaSmi [10]
         0x6d30025a099 @    7 : 25 02             StaCurrentContextSlot [2]
         0x6d30025a09b @    9 : 80 01 00 02       CreateClosure [1], [0], #2
         0x6d30025a09f @   13 : a9                Return
Constant pool (size = 2)
0x6d30025a061: [FixedArray] in OldSpace
 - map: 0x06d300002231 <Map(FIXED_ARRAY_TYPE)>
 - length: 2
           0: 0x06d300259ff9 <ScopeInfo FUNCTION_SCOPE>
           1: 0x06d30025a029 <SharedFunctionInfo inner>
Handler Table (size = 0)
Source Position Table (size = 0)

我們看到 Bytecode age: 0 (代表位元組碼的執行狀態,數字增加代表函式的熱度,也就是上面說的熱點程式碼,V8 就會對這串程式碼進行針對性最佳化)下的一條條指令就是位元組碼啦,這六條指令直譯器執行完就代表 multi 函式執行完成了,上面列印出來的位元組碼只是全部的冰山一角,若有同學有興趣的話,可以到V8原始碼檢視更多。

這裡的位元組碼最終透過直譯器解釋執行,在執行的過程中,需要透過某些手段去儲存引數、中間計算結果等,V8 的直譯器(Ignition)採用的是基於暫存器的架構,他透過暫存器來儲存所需要的資料。有興趣的同學可以詳細檢視Ignition 設計文件中的 register 相關內容。

下面我來簡單逐行解釋下列印出來的程式碼。

Bytecode length 表示函式 multi 的位元組碼長度。Parameter count 1 表示函式 multi 接收一個引數,這裡是隱式地傳入了 thisRegister count 表示使用的暫存器數量。Frame size 代表棧幀大小(因為 V8 是透過棧結構來管理函式呼叫,棧幀是一個用於儲存引數、被呼叫者的返回值、區域性變數和暫存器的空間)。

CreateFunctionContext 是用來建立函式上下文的,會把 multi 函式上下文和作用域資訊存到暫存器中,當然 inner 函式也會存進去。PushContext 用於將暫存器中的上下文推入執行上下文棧。LdaSmiStaCurrentContextSlot 代表將值 10 載入到暫存器中並且儲存到當前上下文中。CreateClosure 就是透過傳入上下文的一些資訊,若發現內部有引用外層作用域鏈上的變數,則輸出帶有閉包資訊的新的 inner 函式存進暫存器中最後返回。

我們重點看下下面的位元組碼,Constant pool 代表常量池,當程式碼中使用了多個相同的常量值時,V8 引擎會將這些常量值儲存在 Constant pool 中,並在需要使用時直接引用它們,而不是重複建立多個相同的常量值。繼續往下看 [FixedArray] in OldSpace 代表下面的常量存到了老生代中,老生代中的物件更穩定,不容易被回收,通常用於用於儲存生命週期較長的物件,例如函式、閉包、大型物件。下面的 ScopeInfo FUNCTION_SCOPE 表示函式作用域資訊的資料結構,它記錄了函式內部的變數和作用域鏈等資訊。SharedFunctionInfo inner 表示用於儲存 inner 函式的位元組碼等。這兩個常量同時存在表示內部函式 inner 與外部函式的作用域存在關聯,透過 ScopeInfo 中的作用域鏈查詢到內部函式訪問了外部函式的變數。最後在 SharedFunctionInfo 中會儲存內部函式引用的外部函式的變數作用域範圍的資訊,這裡就是儲存了閉包變數 a 的作用域範圍,儲存到了堆中供後續 inner 函式執行訪問。

所以 V8 透過預解析器使得 JavaScript 的閉包特性得以實現。

總結

本文我們介紹了在 V8 中是如何實現閉包這一特性的,V8 在處理函式的時候採用的延遲解析來提高啟動速度,但是延遲解析和閉包存在天然的矛盾,所以當一個函式中存在閉包並且執行時,V8 會透過引入預解析器去掃描內部函式使用到的外部變數,並且複製到堆中,下次執行內部函式的時候就是直接訪問堆中的引用。

最後我們要注意閉包可能導致的記憶體洩露問題,我們書寫閉包程式碼時如果引用了一些後續用不到的變數,比如說引用了一個大物件,但是我們只用這個物件中的一個屬性值,那麼就會導致這個大物件不會被銷燬,導致記憶體洩漏,解決方式們就是要將需要的屬性值提取出來成為一個新變數,在函式中引用此新變數就可以。還有一些引用 dom 節點產生的洩露等問題。

參考

圖解 Google V8

Blazingly fast parsing, part 2: lazy parsing

最後

更多崗位,可進入網易招聘官網檢視 https://hr.163.com/

相關文章