這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
Go 1.4Beta1剛剛發布,在Go 1.4Beta1中,Go語言的stack處理方式由之前的"segmented stacks"改為了"continuous stacks"。關於Go語言對stack的處理機制、發展曆史、存在問題等,CloudFlare的一篇官方blog進行了系統的闡述,這裡的內容就是 翻譯自CloudFlare的那篇blog:《How Stacks are Handled in Go》。
在CloudFlare,我們使用Go語言實現各種服務和應用。在這篇博文中,我們將帶領大家深入挖掘一些Go的某些紛繁複雜的技術細節。
Go語言的重要特性之一是goroutines。它們是代價低廉、協同調度的執行線程,被用於實現各種操作,諸如timeout、產生器、相互競 爭的後端程式。為了使goroutines可以適應更多地任務,我們不僅需要保證每個goroutines的記憶體最小佔用量,還要保證人們可以使 用最低配置將它們啟動起來。
為了實現這個目標,Go語言採用了棧管理,這一與其他程式設計語言類似的方案,但在具體實現層面,又與其他語言有著較大的不同。
一、線程棧(thread stacks)介紹
在我們研究Go的棧處理方式之前,我們先來看看傳統語言,比如C是如何進行棧管理的。
當你啟動一個C實現的thread時,C標準庫會負責分配一塊記憶體作為這個線程的棧。標準庫分配這塊記憶體,告訴核心它的位置並讓核心處理這個線程 的執行。不過當這塊記憶體不夠用時,問題就來了,我們來看一下下面這個函數:
int a(int m, int n) {
if (m == 0) {
return n + 1;
} else if (m > 0 && n == 0) {
return a(m – 1, 1);
} else {
return a(m – 1, a(m, n – 1));
}
}
這個函數大量使用遞迴,執行a(4, 5)就會降所有棧記憶體耗盡。要解決這個問題,你可以調整標準庫給線程棧分配的記憶體塊的大小。但是全線提高棧大小意味著每個線程都會提高棧的記憶體使用量量,即 便它們不是大量採用遞迴方式的。這樣一來,你將用光所有記憶體,即便你的程式還尚未使用棧上的記憶體。
另外一種可選的解決方案則是為每個線程單獨確定棧大小。這樣一來你就不得不完成這樣的任務:根據每個線程的需要,估算它們的棧記憶體的大小。這將是 建立線程的難度超出我們的期望。想搞清楚一般情況下一個線程棧需要多少記憶體是不可行的,即便是通常情況也是非常困難的。
二、Go是如何應對這個問題的
Go運行時會試圖按需為goroutine提供它們所需要的棧空間,而不是為每個goroutine分配一個固定大小的棧空間。這樣可以把程式員 們從決定棧空間大小的煩心事中解脫了出來。不過Go核心團隊正在嘗試切換到另外一種方案,這裡我將嘗試闡述舊方案以及它的缺點,新方案以及為何要 做出如此改變。
三、分段棧(Segmented Stacks)
分段棧(segmented stacks)是Go語言最初用來處理棧的方案。當建立一個goroutine時,Go運行時會分配一段8K位元組的記憶體用於棧供goroutine運行使 用,我們讓goroutine在這個棧上完成其任務處理。
當我們用光這8K位元組的棧空間後,問題隨之而來。為瞭解決這個問題,每個go函數在函數入口處都會有一小段代碼(called prologue),這段代碼會檢查是否用光了已指派的棧空間,如果用光了,這段代碼會調用morestack函數。
morestack函數會分配一段新記憶體用作棧空間,接下來它會將有關棧的各種資料資訊寫入棧底的一個struct中(譯註:中Stack info),包括上一段棧的地址。有點我們擁有了一個新的棧段(stack segment),我們將重啟goroutine,從導致棧空間用光的那個函數(譯註:中的Foobar)開始執行。這就是所謂的“棧分裂 (stack split)”。
下面的棧剛好是我們進行棧分裂後的情形:
在新棧的底部,我們插入了一個棧入口函數lessstack。我們不會調用該函數,設定這個函數就是用於我們從那個導致我們用光棧空間的函數(譯 註:Foobar)返回時用的。當那個函數(譯註:Foobar)返回時,我們回到lessstack(這個棧幀),lessstack會尋找 stack底部的那個struct,並調整棧指標(stack pointer),使得我們返回到前一段棧空間。這樣做之後,我們就可以將這個新棧段(stack segment)釋放掉,並繼續執行我們的程式了。
四、分段棧(Segmented stacks)的問題
分段棧給了我們具備按需伸縮能力的棧。程式員們無需擔心計算棧的大小了,啟動一個新的goroutine代價低廉並且程式員不會知道棧將增長多 大。
這就是直到目前Go語言處理stack增長的方法,但是這個方法有個瑕疵。那就是棧縮小會是一個相對代價高昂的操作。如果你在一個迴圈遇到棧分裂 (stack split),你會最有感觸。一個函數會增加棧空間,做棧分裂,返回並釋放棧段(stack segment)。如果你在一個迴圈中進行這些,你會付出很大的代價(效能方面)。
這就是所謂的“hot split”問題。它也是Go核心開發組更換到一個新的棧管理方案-棧拷貝(stack copying)的主要原因。
五、棧拷貝(stack copying)
棧拷貝初始階段與分段棧類似。goroutine在棧上運行著,當用光棧空間,它遇到與舊方案中相同的棧溢出檢查。但是與舊方案採用的保留一個返 回前一段棧的link不同,新方案建立一個兩倍於原stack大小的新stack,並將舊棧拷貝到其中。這意味著當棧實際使用的空間縮小為原先的 大小時,go運行時不用做任何事情。棧縮小是一個無任何代價的操作。此外,當棧再次增長時,運行時也無需做任何事情,我們只需要重用之前分配的空 閑空間即可。
六、棧是怎麼拷貝的
拷貝棧聽起來簡單,但實際上它是一件有難度的事情。因為Go中棧上的變數都有自己的地址,一旦你擁有指向棧上變數的指標,這種情況下你就無法如你 所願。當你移動棧時,指向原棧的指標都將變為無效指標。
幸運的是,只有在棧上分配的指標才能指向棧上的地址。這點對於記憶體安全是極其必要的,否則,程式可能會訪問到已不再使用了的棧上的地址。
由於我們需要知道那些需要被垃圾收集器回收的指標的位置,因此我們知道棧上哪些部分是指標。當我們移動棧時,我們可以更新棧裡地指標使其指向新的 目標地址,並且所有相關的指標都要被照顧到。
由於我們使用記憶體回收的資訊來協助完成棧拷貝,因此所有出現在棧上的函數都必須具備這些資訊。但事情不總是這樣的。因為Go運行時的大部分代碼是 用C編寫的,大量的運行時調用沒有指標資訊可用,這樣就無法進行拷貝。一旦這種情況發生,我們又不得不退回到分段棧方案,並接受為其付出的高昂代 價。
這就是當前Go運行時開發人員大規模重寫Go runtime的原因。那些無法用Go重寫的代碼,比如調度器和垃圾收集器的核心,將在一個特殊的棧上執行,這個特殊棧的size由runtime開發人員 單獨計算確定。
除了讓棧拷貝成為可能之外,這個方法還會使得我們在未來能夠實現出並發記憶體回收等特性。
七、關於虛擬記憶體
另外一種不同的棧處理方式就是在虛擬記憶體中分配大記憶體段。由於實體記憶體只是在真正使用時才會被分配,因此看起來好似你可以分配一個大記憶體段並讓操 作系統處理它。下面是這種方法的一些問題
首先,32位系統只能支援4G位元組虛擬記憶體,並且應用只能用到其中的3G空間。由於同時運行百萬goroutines的情況並不少見,因此你很可 能用光虛擬記憶體,即便我們假設每個goroutine的stack只有8K。
第二,然而我們可以在64位系統中分配大記憶體,它依賴於過量記憶體使用量。所謂過量使用是指當你分配的記憶體大小超出實體記憶體大小時,依賴作業系統保證 在需要時能夠分配出實體記憶體。然而,允許過量使用可能會導致一些風險。由於一些進程分配了超出機器實體記憶體大小的記憶體,如果這些進程使用更多記憶體 時,作業系統將不得不為它們補充分配記憶體。這會導致作業系統將一些記憶體段放入磁碟緩衝,這常常會增加不可預測的處理延遲。正是考慮到這個原因,一 些新系統關閉了對過量使用的支援。
八、結論
為了使goroutine使用代價更加低廉,更快速,適合更多task情況,Go開發組做出了很多努力。棧管理只是其中一小部分。如果你想瞭解更 多關於棧拷貝的細節,可以參考其設計文檔。此外,如果你想瞭解更多有關Go運行 時重寫的細節,這裡有一個mail list。
2014, bigwhite. 著作權.