作者:UC 國際研發 叫獸
寫在最前:歡迎你來到“UC國際技術”公眾號,我們將為大家提供與客戶端、服務端、演算法、測試、資料、前端等相關的高質量技術文章,不限於原創與翻譯。
Call Stack
(呼叫棧
) 一般指計算機程式執行時子程式之間訊息處理的相互呼叫產生的一些列函式序列,而且幾乎所有的計算機程式都依賴於呼叫棧。
在探討 Call Stack
前,先來搞清楚 Stack
(棧
)的概念。
Stack
就是一種特殊的串列形式的資料結構,特殊之處在於只能允許在連結串列或陣列的一端(稱為堆疊頂端指標,英語:top)進行加入資料(英語:push)和輸出資料(英語:pop)的運算。因此棧的資料結構只允許在一端進行操作,按照後進先出(LIFO, Last In First Out)的原理運作。
讓我們看看下面的程式碼:
它的執行結果是:
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 環境的可用棧深情況。
computeMaxCallStackSize 15705
computeMaxCallStackSize 15700
computeMaxCallStackSize 15718
computeMaxCallStackSize 15674
從執行結果看,雖然各個版本的呼叫棧空間預設都是 984kB,從 4.8.3、5.12.0 和 6.10.2 數個版本棧深度大約在 15700 以上,而 7.9.0 版的深度則為 15674。
從實際使用上看,這樣的棧深表示一個執行緒上執行函式的呼叫棧可達到 15700 層,除非程式碼中出現"死迴圈"等情況,對於日常的運算基本是不會有任何問題。
但需要注意的是,呼叫棧的深度要根據當前呼叫函式的函式體大小和 local 變數的多少來決定,假如呼叫棧需要儲存的本地變數數量較多,則需要佔用較多棧空間來放置這些變數指標,那麼棧深度就將遠小於該值。
如果需要修改棧的大小,可以通過以下指令增加其大小:
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)
實驗的結果,在棧大小不變的情況下,程式碼被重複執行 2、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國際技術”致力於與你共享高質量的技術文章
歡迎關注我們的公眾號、將文章分享給你的好友