執行 JavaScript 的 V8 引擎做了什麼?

神Q超人
Starbugs Weekly 星巴哥技術專欄
8 min readMay 24, 2022
Photo by lee attwood on Unsplash

Hi!大家好,我是神 Q 超人!我想點進來的大家應該都聽過,也在瀏覽器或 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 的程式碼:

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

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

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。(如果想知道 Deoptimization 的詳細步驟,可以查看這篇文章:從編譯器優化角度初探 Javascript的V8 引擎

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

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

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

兩次執行 1 千萬次的時間

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

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

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

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

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

總結

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

V8 引擎執行 JavaScript 的流程圖

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

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

又完成了一直備著但沒有下筆的一個題目,發現自己非常不擅長於搞懂要某些東西或是技術背後在做的事情,導致每次都要花很長的週期,看了很多文章才有辦法稍微理解整個概觀,也希望自己留下來的文字可以幫助大家初步理解相關內容。

最後如果文章裡有任何錯誤,或是解釋不清楚的地方,再麻煩留言告訴我,另外還有想知道什麼技術相關的知識,也歡迎和我說唷!任何回應都非常感謝! 🙌

參考文章

  1. JS 原力覺醒 Day02 — JavaScript V8 引擎
  2. 從編譯器優化角度初探 Javascript的V8 引擎
  3. How JavaScript Works: Under the Hood of the V8 Engine
  4. Read JavaScript Source Code, Using an AST
  5. V8 是怎么跑起来的 — V8 的 JavaScript 执行管道 2021

--

--