這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
原文:Why is a Goroutine's stack infinite?
譯者:youngsterxyf
Go編程新手可能會偶然發現Go語言---與一個Goroutine可用棧空間大小相關---的一個古怪特性。這通常是由於程式員無意間構造了一個無限遞迴函式調用而產生的。為了闡明這個特性,以如下代碼(有點刻意設計的)為例。
package mainimport "fmt"type S struct { a, b int}// String implements the fmt.Stringer interfacefunc (s *S) String() string { return fmt.Sprintf("%s", s) // Sprintf will call s.String()}func main() { s := &S{a: 1, b: 2} fmt.Println(s)}
如果你運行這個程式(我不建議你這樣做),你會發現你的機器開始頻繁地swap(譯註:不瞭解swap的,可以簡單理解為“記憶體與硬碟之間資料的匯出匯入”),並且可能不再響應操作事件,除非你在一切無法挽回之前及時地按下^C。我知道所有人都會先在Go官網的playground中嘗試運行這個程式,所以我已經為你準備好了。
大多數程式員應該都遇到過無限遞迴導致的問題,但這都只是對於他們的程式來說是致命的,對於他們的機器通常來說並不是。那麼,為何Go程式會不同呢?
Goroutine的主要特徵之一是其開銷---在記憶體佔用初始化方面,建立一個Goroutine的開銷非常小(相比於一個傳統POSIX線程的1-8M位元組),並且Goroutine的棧空間是按需擴大和縮小的。這就允許一個Goroutine以單個4096位元組的棧空間開始,然後按需擴容縮容,也不用擔心棧空間耗盡的風險。
為了實現這一特性,連結器(5l,6l,8l)在每個函數的開頭都插入一小段前置代碼$ ^1 $,這段代碼會檢測該函數需要的棧空間大小是否小於當前可用的棧空間。若大於,則調用runtime.morestack
分配一個新的棧頁(stack page)$ ^2 $,拷貝函數調用方傳遞來的參數,然後將控制權返回給原來要調用的函數,這樣這個函數就可以安全運行了。當這個函數退出時,再撤銷操作,將函數傳回值拷貝回函數調用方的棧幀(stack frame),不再需要的棧空間也被釋放。
通過這個過程,棧空間就好像無限大一樣,若假設不會持續地跨越兩個棧的大小邊界-通常稱為棧切分(stack splitting)(譯註:不太理解這句話,應該是指:程式執行到函數調用方,正好將近耗盡預分配的棧空間,而函數調用方中又不斷地調用其他函數,這樣每次函數調用就需要分配新的棧空間,函數調用結束後又需要釋放新分配的棧空間,所以開銷積累起來就比較大),這種棧空間分配方式的開銷也會很小。
然而,直到現在我都還未披露一個細節---粗心地使用遞迴函式導致記憶體耗盡的話,當需要新的棧頁時,就會從堆上分配(譯註:這句話可能有點問題。應該是Goroutine耗盡作業系統為Go程式分配的棧大小的話,就從堆上分配)。
由於無限遞迴函式持續地調用自己,新的棧頁最後就需要從堆上分配。堆的大小很快就會超過機器的可用實體記憶體空間,到那時,swapping會很快導致你的機器不可用。
Go程式可用的堆大小依賴於很多東西,包括機器的CPU架構和作業系統,但這通常是一個超出機器實體記憶體的值,因此機器很可能在程式耗盡它的堆空間之前就會頻繁地swap。
對於Go 1.1,曾有強烈要求增大32位、64位平台上堆的最大值,但這在某種程度上惡化了這一問題,比如,你的機器不太可能有128GB$ ^3 $的實體記憶體。
最後提一下,關於這個問題有幾個未解決的issue(連結,連結),目前還沒找到一個解決方案能夠不影響按常規編寫的程式的效能。
注釋
- 也適用於方法(method),雖然方法是作為第一個參數為方法接受者(the method receiver)的函數來實現的,但在討論Go語言中分段的(segmented)棧如何工作之時,並沒有實際的區別。
- 使用單詞page並不意味著僅按固定的4096位元組來分配,如果需要,runtime.morestack會分配一個更大的,倍數於一個頁大小的空間。
- 由於Go 1.1發布周期中一個後來的改變,64位的Windows平台僅允許32Gb大小的堆。
譯者補充相關文章
- A trip down the (split) rabbithole
- go語言中split stack(上),go語言中split stack(下)
- go在stack上幹了神馬?