深刻理解 V8 的 Call Stack

做者:UC 國際研發 叫獸前端

寫在最前:歡迎你來到「UC國際技術」公衆號,咱們將爲你們提供與客戶端、服務端、算法、測試、數據、前端等相關的高質量技術文章,不限於原創與翻譯。node


Call Stack 與 Stack 的概念

Call Stack(調用棧) 通常指計算機程序執行時子程序之間消息處理的相互調用產生的一些列函數序列,並且幾乎全部的計算機程序都依賴於調用棧。
算法

在探討 Call Stack 前,先來搞清楚 Stack()的概念。數據結構

Stack 就是一種特殊的串列形式的數據結構,特殊之處在於只能容許在連接串列或陣列的一端(稱爲堆疊頂端指標,英語:top)進行加入數據(英語:push)和輸出數據(英語:pop)的運算。所以棧的數據結構只容許在一端進行操做,按照後進先出(LIFO, Last In First Out)的原理運做。less



Call Stack 是如何運做的

讓咱們看看下面的代碼:
函數

它的執行結果是:性能

c
測試

b優化

a編碼

該代碼執行過程經歷了兩個階段 首先是執行入棧。

執行 a() 方法後,此時 a 就被添加到調用棧的頂部。

在 a 內部調用了 b(),此時的調用棧頂部添加了 b:

一樣 b 內部調用了 c(),此時的調用棧頂部添加了 c,最終的調用棧變成了:

此時 console.log('c'); 首先被執行。

當執行完 c 後,調用並不就此完成,開始第二階段的出棧

b 方法從新得到了線程控制, 執行了 console.log('b'); 。

b 執行完成,棧退到 a 方法上:

執行 console.log('a'); 。


最後調用完成,調用棧 emptied。



調用棧的大小

因爲操做系統對每組線程的棧內存有必定的限制,爲適應線程各類操做系統,因此 Node.js 默認的棧大小爲 984k。

Slightly less than 1MB, since Windows' default stack size for the main execution thread is 1MB for both 32 and 64-bit. @src/globals.h:108:1

  • 如何獲取當前環境的調用棧大小?

不過,因爲不一樣版本的 Node 集成的 V8 版本和優化等不一樣,即便一樣 size 的棧空間,調用棧的棧深淺各不相同,咱們嘗試使用遞歸函數來測試一下每一個版本的 Node.js 環境的可用棧深狀況。

node v4.8.3

computeMaxCallStackSize 15705

node v5.12.0

computeMaxCallStackSize 15700

node v6.10.2

computeMaxCallStackSize 15718

node v7.9.0

computeMaxCallStackSize 15674

從執行結果看,雖然各個版本的調用棧空間默認都是 984kB,從 4.8.三、5.12.0 和 6.10.2 數個版本棧深度大約在 15700 以上,而 7.9.0 版的深度則爲 15674。

從實際使用上看,這樣的棧深表示一個線程上執行函數的調用棧可達到 15700 層,除非代碼中出現"死循環"等狀況,對於平常的運算基本是不會有任何問題。

但須要注意的是,調用棧的深度要根據當前調用函數的函數體大小和 local 變量的多少來決定,假如調用棧須要保存的本地變量數量較多,則須要佔用較多棧空間來放置這些變量指針,那麼棧深度就將遠小於該值。

若是須要修改棧的大小,能夠經過如下指令增長其大小:


V8 的調用棧優化

V8 爲提升 JavaScript code 的運行性能,從一開始就採起激進的基於機器碼編碼方案,那麼 V8 在處理調用棧的問題上,是否又有進行了優化呢?

咱們對以上的代碼進行修改,嘗試對同一段代碼進行 10 次重複執行。

各個版本下,咱們看到輸出的數據:

node v4.8.3 (v8@4.5.103.47)

node v5.12.0 (v8@4.6.85.32)


node v6.10.2 (v8@5.1.281.98)

node v7.9.2 (v8@5.5.372.43)

實驗的結果,在棧大小不變的狀況下,代碼被重複執行 二、3 遍後,棧的深度會增長(但 6.10.2 除外,比較詭異),能夠理解爲棧的內存獲得了優化。在而 7.9.2 的版本,運行了兩次後,棧的深度更大幅增長 14.28%。

根據 V8 的優化機制,當程序進入 V8 VM 環境後,代碼會首先進行簡單編譯(Full Compliler),這個過程爲 gencode,生成機器碼並後纔開始執行,而 Crankshaft 的優化編譯機制並不會被啓動,由於此階段對於編譯器來講,看到的只是代碼,還沒法分析出這些代碼哪部分須要優化的。

每一個通過 FC 編譯的函數都會包含一個計數器,當函數返回或完成一輪循環的時候,就會減小計數的值, 分析器在計數減到 0 的時候,內置性能分析器就能夠挑選這類的熱點函數,並啓動 Crankshaft(優化編譯)對其進一步的優化處理,指向其代碼的指針就會被改寫指向爲一個 V8 內置的函數——Lazy Recompile,這樣函數再次被調用時將執行通過優化的函數代碼。(筆者認爲:同時堆棧上的空間上用於存儲的函數將被替換,指針指向了棧外的某個堆內存上,節省了棧空間的佔用)。


總結

Call Stack(調用棧)實際上就是用於存儲函數的一種內存數據,並且遵循 LIFO 原理實現的進棧和出棧等一系列操做。棧的大小受到操做系統的限制,通常會少於 1MB 的空間,能使用的回調棧層數受制於棧中每一個棧函數的內部變量數量等不一樣,調用棧的深淺也不同。

從咱們的開發層面看,代碼的執行和棧深通常都是有限的,因此默認的狀況下代碼都不會出現調用棧溢出異常的問題發生。

在瞭解調用棧的工做原理,及調用棧在各個版本上的運行表現後,其實咱們應該思考一下,假設我須要設計一個相似 process.nextTick() 或者 co.next() 這樣的函數時,應該如何設計函數方法體,讓該函數的代碼既有效率地執行同時又能被系統作優化處理,而什麼樣的代碼不行的問題。



「UC國際技術」致力於與你共享高質量的技術文章

歡迎關注咱們的公衆號、將文章分享給你的好友

相關文章
相關標籤/搜索