面試官:說說執行 JavaScript 的 V8 引擎做了什麼?

前端小智發表於2022-06-09

有夢想,有乾貨,微信搜尋 【大遷世界】 關注這個在凌晨還在刷碗的刷碗智。

本文 GitHub https://github.com/qq449245884/xiaozhi 已收錄,有一線大廠面試完整考點、資料以及我的系列文章。

Hi!大家好,我想點進來的大家應該都聽過,也在瀏覽器或 Node.js 上執行過 JavaScript,但你們有想過 JavaScript 是如何執行的嗎?這背後的功臣就是 JavaScript 引擎,而標題提到的 V8 引擎 也是其中之一喲!

V8 引擎是由 Google 用 C++ 開源的 JavaScript 與 WebAssembly 引擎,目前像是 Chrome 和 Node.js 都是使用 V8 在執行 JavaScript。除了 V8 以外還有 SpiderMonkey(最早的 JavaScript 引擎,目前是 Firefox 瀏覽器在使用)與 JavaScriptCore(Safari 瀏覽器使用)等其他 JavaScript 引擎。

好的,那麼 V8 引擎到底是如何執行 JavaScript 的呢?

V8 引擎執行流程

Scanner

V8 引擎取得 JavaScript 原始碼後的第一步,就是讓 Parser 使用 Scanner 提供的 Tokens(Tokens 裡有 JavaScript 內的語法關鍵字,像是 function、async、if 等),將 JavaScript 的原始碼解析成 abstract syntax tree,就是大家常在相關文章中看到的 AST(抽象語法樹)。

如果好奇 AST 長什麼樣子的話,可以使用 acron 這個 JavaScript Parser,或是 這個網站 生成 AST 參考看看。以下是使用 acron 的程式碼:

const { Parser } = require('acorn')

const javascriptCode = `
  let name;
  name = 'Clark';
`;

const ast = Parser.parse(javascriptCode, { ecmaVersion: 2020 });
console.log(JSON.stringify(ast));

下方是解析 let name; name = 'Clark'; 所得到的 AST:

{
  "type": "Program",
  "start": 0,
  "end": 31,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 3,
      "end": 12,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 7,
          "end": 11,
          "id": {
            "type": "Identifier",
            "start": 7,
            "end": 11,
            "name": "name"
          },
          "init": null
        }
      ],
      "kind": "let"
    },
    {
      "type": "ExpressionStatement",
      "start": 15,
      "end": 30,
      "expression": {
        "type": "AssignmentExpression",
        "start": 15,
        "end": 29,
        "operator": "=",
        "left": {
          "type": "Identifier",
          "start": 15,
          "end": 19,
          "name": "name"
        },
        "right": {
          "type": "Literal",
          "start": 22,
          "end": 29,
          "value": "Clark",
          "raw": "'Clark'"
        }
      }
    }
  ],
  "sourceType": "script"
}

如果再進一步,將上方的 AST 轉化成圖表,會長這樣:

image.png

AST 可以從上到下,由左而右去理解它在執行的步驟:

  1. 走 VariableDeclaration 建立名字為 name 的變數
  2. ExpressionStatement 到表示式
  3. AssignmentExpression 遇到 =,且左邊為 name,右邊為字串 Clark

產生 AST 後,就完成了 V8 引擎的第一個步驟。

JIT(Just-In-Time)

JIT 的中文名稱是即時編譯,這也是 V8 引擎所採用在執行時編譯 JavaScript 的方式。

將程式碼轉變為可執行的語言有幾種方法,第一種是編譯語言,像是 C/C++ 在寫完程式碼的時候,會先經過編譯器將程式碼變成機器碼才能執行。第二種就像 JavaScript,會在執行的時候將程式碼解釋成機器懂的語言,一邊解釋邊執行的這種,稱作直譯語言。

編譯語言的好處是可以在執行前的編譯階段,審視所有的程式碼,將可以做的優化都完成,但直譯語言就無法做到這一點,因為執行時才開始解釋的關係,執行上就相對較慢,也沒辦法在一開始做優化,為了處理這個狀況,JIT 出現了。

JIT 結合解釋和編譯兩者,讓執行 JavaScript 的時候,能夠分析程式碼執行過程的情報,並在取得足夠情報時,將相關的程式碼再編譯成效能更快的機器碼。

聽起來 JIT 超讚,而在 V8 引擎裡負責處理 JIT 的左右手分別為 IgnitionTurboFan

Ignition & TurboFan

成功解析出 AST 後,Ignition 會將 AST 解釋為 ByteCode,成為可執行的語言,但是 V8 引擎還未在這裡結束,Ignition 用 ByteCode 執行的時候,會蒐集程式碼在執行時的型別資訊。舉個例子,如果我們有個 sum 函式,並且始終確定呼叫的引數型別都是 number,那麼 Ignition 會將它記錄起來。

此時,在另一方面的 TurboFan 就會去檢視這些資訊,當它確認到“只有 number 型別的引數會被送進 sum 這個函式執行”這個情報的時候,就會進行 Optimization,把 sum 從 ByteCode 再編譯為更快的機器碼執行。

如此一來,就能夠保留 JavaScript 直譯語言的特性,又能夠在執行的時候優化效能。

但畢竟是 JavaScript,誰也不敢保證第一百萬零一次送進來的引數仍然是 number,因此當 sum 接收到的引數與之前 Optimization 的策略不同時,就會進行 Deoptimization 的動作。

TurboFan 的 Optimization 並不是將原有的 ByteCode 直接變成機器碼,而是在產生機器碼的同時,增加一個 Checkpoint 到 ByteCode 和機器碼之間,在執行機器碼之前,會先用 Checkpoint 檢查是否與先前 Optimization 的型別符合。這樣的話,當 sum 以與 Optimization 不同的型別被呼叫的時候,就會在 Checkpoint 這關被擋下來,並進行 Deoptimization。

最後如果 TurboFan 重複執行了 5 次 Optimization 和 Deoptimization 的過程,就會直接放棄治療,不會再幫這個函式做 Optimization。

那到底該怎麼知道 TurboFan 有沒有真的做 Optimization 咧?我們可以用下方的程式碼來做個實驗:

const loopCount = 10000000;
const sum = (a, b) => a + b;

performance.mark('first_start');

for (let i = 0; i < loopCount; i += 1) {
    sum(1, i);
}

performance.mark('first_end');


performance.mark('second_start');

for (let i = 0; i < loopCount; i += 1) {
    sum(1, i);
}

performance.mark('second_end');

performance.measure('first_measure', 'first_start', 'first_end');
const first_measures = performance.getEntriesByName('first_measure');
console.log(first_measures[0]);

performance.measure('second_measure', 'second_start', 'second_end');
const second_measures = performance.getEntriesByName('second_measure');
console.log(second_measures[0]);

上方利用 Node.js v18.1 的 perf_hooks 做執行速度的測量,執行結果如下:

image.png

執行後會發現第一次執行的時間花了 8 秒,第二次的執行時間只花了 6 秒,大家可以再把 loopCount 的數字改大一點,差距會越來越明顯。

但是這麼做仍然沒辦法確認是 TurboFan 動了手腳,因此接下來執行的時候,加上 --trace-opt 的 flag,看看 Optimization 是否有發生:

image.png

執行後的資訊顯示了 TurboFan 做的幾次 Optimization,也有把每次 Optimization 的原因寫下來,像第一二行分別顯示了原因為 hot and stable 和 small function,這些都是 TurboFan 背後做的 Optimization 策略。

那 Deoptimization 的部分呢?要測試也很簡單,只要把第二個迴圈的引數型別改成 String 送給 sum 函式執行,那 TurboFan 就會進行 Deoptimization,為了檢視 Deoptimization 的訊息,下方執行的時候再加上 --trace-deopt

image.png

在 highlight 的那一段,就是因為送入 sum 的引數型別不同,所以執行了 Deoptimization,但是接下來又因為一直送 String 進 sum 執行,所以 TurboFan 又會再替 sum 重新做 Optimization。

總結

整理 V8 引擎執行 JavaScript 的過程後,能夠得出下方的流程圖:

image.png

搭配上圖解說 V8 引擎如何執行 JavaScript:

  1. Parser 透過 Scanner 的 Tokens 將 JavaScript 解析成 AST
  2. Ignition 把 AST 解釋成 ByteCode 執行,並且在執行時蒐集型別資訊
  3. TurboFan 針對資訊將 ByteCode 再編譯為機器碼
  4. 如果機器碼檢查到這次的執行和之前 Optimization 策略不同,就做 Deop timization 回到 ByteCode,以繼續蒐集型別資訊或放棄治療。

作者:神Q超人 > 來源:medium

原文:https://medium.com/tarbugs/%E...

交流

文章每週持續更新,可以微信搜尋「 大遷世界 」第一時間閱讀和催更(比部落格早一到兩篇喲),本文 GitHub https://github.com/qq449245884/xiaozhi 已經收錄,整理了很多我的文件,歡迎Star和完善,大家面試可以參照考點複習,另外關注公眾號,後臺回覆福利,即可看到福利,你懂的。

相關文章