- 后端早讀課翻譯計劃 第四篇-
- 翻譯自: a-journey-with-go
歡迎關注微信公眾號: 后端早讀課
本文詳細講述了 Golang 中,堆棧設計理念以及演變過程。描述了從 Segment Stack 到 Contiguous Stack 、初始堆棧大小從 8Kb 到 2Kb 的原因。
?? 文章基于 Go 1.12.
Go 提供了一個輕量且智能的協程管理機制。輕量是因為協程堆棧初始化只有 2Kb,智能是因為協程堆棧可以根據我們的需要自動增加 / 減少。
堆棧的大小定義,我們可以在這里找到 runtime/stack.go:
// The minimum size of stack used by Go code
_StackMin = 2048
我們需要注意的是,它曾經在以下版本的時間里進行過優化:
Go 1.2: 協程堆棧從 4Kb 增長到 8Kb.
Go 1.4: 協程堆棧從 8Kb 減少到 2Kb.
協程堆棧大小的變化主要是因為堆棧分配策略的變化。在文章后面我們一會兒將會提到這個問題。
默認的堆棧大小有的時候并不能滿足我們運行的程序。這時候 Go 就會自動的調整堆棧大小。
動態堆棧大小
如果 Go 可以自動的增長棧空間大小,那么也意味著他可以決定堆棧大小到底有沒有必要需要修改。讓我們看一個例子,分析一下它是怎么工作的:
func main() {
a := 1
b := 2
r := max(a, b)
println(`max: `+strconv.Itoa(r))
}
func max(a int, b int) int {
if a >= b {
return a
}
return b
}
這個例子只是計算了兩個數字中最大的一個。為了了解 Go 是如何管理協程堆棧分配的,我們可以看下 Go 的編譯流程代碼, 通過命令: go build -gcflags -S main.go
. 輸出 —— 我只保留了與堆棧有關的一些行 —— 它給我們一些有趣的信息,這些內容展示了 Go 都做了什么:
"".main STEXT size=186 args=0x0 locals=0x70
0x0000 00000 (/go/src/main.go:5) TEXT "".main(SB),
ABIInternal, $112-0
[...]
0x00b0 00176 (/go/src/main.go:5) CALL
runtime.morestack_noctxt(SB)
[...]
0x0000 00000 (/go/src/main.go:13) TEXT "".max(SB),
NOSPLIT|ABIInternal, $0-24
有兩條指令涉及到棧大小的更改:
- CALL runtime.morestack_noctxt: 這個方法會在需要的時候增加堆棧大小。
-NOSPLIT: 這條指令的意味著堆棧不需要溢出檢測,他與指令 //go:nosplit .比較相似。
我們看到這個方法:runtime.morestack_noctxt
,他會調用 runtime/stack.go
中的 newstack
方法:
func newstack() {
[...]
// Allocate a bigger segment and move the stack.
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize * 2
if newsize > maxstacksize {
print("runtime: goroutine stack exceeds ", maxstacksize, "-byte limit\n")
throw("stack overflow")
}
// The goroutine must be executing in order to call newstack,
// so it must be Grunning (or Gscanrunning).
casgstatus(gp, _Grunning, _Gcopystack)
// The concurrent GC will not scan the stack while we are doing the copy since
// the gp is in a Gcopystack status.
copystack(gp, newsize, true)
if stackDebug >= 1 {
print("stack grow done\n")
}
casgstatus(gp, _Gcopystack, _Grunning)
}
首先根據 gp.stack.hi
和 gp.stack.lo
的邊界來計算堆棧的大小,他們是指向堆棧頭部和尾部的指針。
type stack struct {
lo uintptr
hi uintptr
}
然后堆棧大小被乘以 2 倍,如果它沒有達到最大值的話 —— 最大值與系統架構有關。
// Max stack size is 1 GB on 64-bit, 250 MB on 32-bit.
// Using decimal instead of binary GB and MB because
// they look nicer in the stack overflow failure message.
if sys.PtrSize == 8 {
maxstacksize = 1000000000
} else {
maxstacksize = 250000000
}
現在我們已經了解了運行機制,我們來寫個簡單的例子來驗證以上的內容。為了 debug,我們需要設置 stackDebug
常量,它在上面 newstack
的方法里會打印一些 debug 信息,運行:
func main() {
var x [10]int
a(x)
}
//go:noinline
func a(x [10]int) {
println(`func a`)
var y [100]int
b(y)
}
//go:noinline
func b(x [100]int) {
println(`func b`)
var y [1000]int
c(y)
}
//go:noinline
func c(x [1000]int) {
println(`func c`)
}
//go:noinline
指令是為了避免編譯時把所有的方法都放到一行。如果都放到一行的話,我們將看不到每個方法開始時候的堆棧動態增長。
下面是一部分的 debug 日志:
runtime: newstack sp=0xc00002e6d8 stack=[0xc00002e000, 0xc00002e800]
stack grow done
func a
runtime: newstack sp=0xc000076888 stack=[0xc000076000, 0xc000077000]
stack grow done
runtime: newstack sp=0xc00003f888 stack=[0xc00003e000, 0xc000040000]
stack grow done
runtime: newstack sp=0xc000081888 stack=[0xc00007e000, 0xc000082000]
stack grow done
func b
runtime: newstack sp=0xc0000859f8 stack=[0xc000082000, 0xc00008a000]
func c
我們可以看到堆棧一共有 4 次增長。其實,方法開始會將堆棧增長到它需要的大小。就像我們在代碼中看到的,堆棧的邊界定義了堆棧的大小,所以我們可以計算每一個新的堆棧的大小 —— newstack stack=[...]
指令提供了當前堆棧邊界的指針:
runtime: newstack sp=0xc00002e6d8 stack=[0xc00002e000, 0xc00002e800]
0xc00002e800 - 0xc00002e000 = 2048
runtime: newstack sp=0xc000076888 stack=[0xc000076000, 0xc000077000]
0xc000077000 - 0xc000076000 = 4096
runtime: newstack sp=0xc00003f888 stack=[0xc00003e000, 0xc000040000]
0xc000040000 - 0xc00003e000 = 8192
runtime: newstack sp=0xc000081888 stack=[0xc00007e000, 0xc000082000]
0xc000082000 - 0xc00007e000 = 16384
runtime: newstack sp=0xc0000859f8 stack=[0xc000082000, 0xc00008a000]
0xc00008a000 - 0xc000082000 = 32768
我們可以看到在編譯時 Goroutine 的棧空間初始大小為 2Kb ,在函數起始的地方增長到它所需要的大小,直到大小已經滿足運行條件或者達到了系統限制。
堆棧分配管理
動態堆棧分配系統并不是唯一影響我們應用原因。不過,堆棧分配方式也可能會對應用產生很大的影響。通過兩個完整的日志跟蹤讓我們試著理解它是如何管理堆棧的。讓我們嘗試從前兩個堆棧增長的跟蹤中了解 Go 是如何進行堆棧管理的:
runtime: newstack sp=0xc00002e6d8 stack=[0xc00002e000, 0xc00002e800]
copystack gp=0xc000000300 [0xc00002e000 0xc00002e6e0 0xc00002e800] -> [0xc000076000 0xc000076ee0 0xc000077000]/4096
stackfree 0xc00002e000 2048
stack grow done
runtime: newstack sp=0xc000076888 stack=[0xc000076000, 0xc000077000]
copystack gp=0xc000000300 [0xc000076000 0xc000076890 0xc000077000] -> [0xc00003e000 0xc00003f890 0xc000040000]/8192
stackfree 0xc000076000 4096
stack grow done
第一條指令顯示了當前堆棧的地址, stack=[0xc00002e000, 0xc00002e800]
, 并把他復制到新的堆棧里,并且是之前的二倍大小, copystack [0xc00002e000 [...] 0xc00002e800] -> [0xc000076000 [...] 0xc000077000]
,4096 字節的長度和我們上面看到的一樣。然后之前的堆棧將被釋放: stackfree 0xc00002e000
。我們畫了個圖可以幫助理解上面的邏輯:
copystack
指令復制了整個堆棧,并把所有的地址都移向新的堆棧。我們可以通過一段簡短的代碼來很容易的發現這個現象:
func main() {
var x [10]int
println(&x)
a(x)
println(&x)
}
打印出來的地址為
0xc00002e738
[...]
0xc000089f38
地址 0xc00002e738
是被包含在我們之前看到的堆棧地址之中 stack=[0xc00002e000, 0xc00002e800]
,同樣的 0xc000089f38
這個地址也是包含在后一個堆棧之中 stack=[0xc000082000, 0xc00008a000]
,這兩個 stack 地址是我們上面通過 debug 模式追蹤到的。這也證明了確實所有的值都已經從老的堆棧移到了新的堆棧里。
另外,有趣的是,當垃圾回收被觸發時,堆棧會縮小(譯者注:一點也不 interesting)。
在我們的例子中,在函數調用之后,堆棧中除了主函數外沒有其他的有效函數調用,所以在垃圾回收啟動的時候,系統會將堆棧進行縮減。為了證明這個問題,我們可以強制進行垃圾回收:
func main() {
var x [10]int
println(&x)
a(x)
runtime.GC()
println(&x)
}
Debug 程序會展示出堆棧縮減的日志:
func c
shrinking stack 32768->16384
copystack gp=0xc000000300 [0xc000082000 0xc000089e60 0xc00008a000] -> [0xc00007e000 0xc000081e60 0xc000082000]/16384
正如我們看到的這樣,堆棧大小被縮減為原來的一半,并重用了之前的堆棧地址 stack=[0xc00007e000, 0xc000082000]
,同樣在 runtime/stack.go — shrinkstack()
中我們可以看到,縮減函數默認就是將當前堆棧大小除以 2:
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize / 2
連續堆棧 VS 分段堆棧
將堆棧復制到更大的堆棧空間中的策略稱之為 連續堆棧(contiguous stack),與 分段堆棧(segmented stack)正好相反。Go 在 1.3 版本中遷移到了連續堆棧的策略。為了看看他們的不同,我們可以在 Go 1.2 版本中跑相同的例子看看。同樣,我們需要修改 stackDebug 變量來展示 Debug 跟蹤信息。為此,由于 Go 1.2 的 runtime 是用 C 語言寫的,所以我們只能重新編譯源代碼.。這里是例子的運行結果:
func a
runtime: newstack framesize=0x3e90 argsize=0x320 sp=0x7f8875953848 stack=[0x7f8875952000, 0x7f8875953fa0]
-> new stack [0xc21001d000, 0xc210021950]
func b
func c
runtime: oldstack gobuf={pc:0x400cff sp:0x7f8875953858 lr:0x0} cret=0x1 argsize=0x320
當前的堆棧 stack=[0x7f8875952000, 0x7f8875953fa0]
大小是 8Kb (8192 字節 + 堆棧頂部的大小),同時新的堆棧創建大小為 18864 字節 ( 18768 字節 + 堆棧頂部的大小)。(譯者注:這里比較難理解
0x7f8875953fa0 - 0x7f8875952000
并不到 8Kb,應該是筆誤,應該是 8096 字節)
內存大小分配的邏輯如下:
// allocate new segment.
framesize += argsize;
framesize += StackExtra; // room for more functions, Stktop.
if(framesize < StackMin)
framesize = StackMin;
framesize += StackSystem;
其中常量 StackExtra
是 2048 , StackMin
是 8192 , StackSystem
從 0 到 512 都有可能(譯者注:根據平臺來判斷的)
所以我們新的堆棧包括了 :16016 (frame size) + 800 (arguments) + 2048 (StackExtra) + 0 (StackSystem)
。
一旦所有的函數都調用完畢,新的堆棧將被釋放(log runtime: oldstack
)。這個行為是迫使 Golang 團隊轉移到連續堆棧的原因之一:
當前分段堆棧機制有一個 “熱分離( hot split)”的問題 —— 如果堆棧快滿了,那么函數調用會引起一個新的堆棧塊被分配。當所有的函>數調用返回時,新的堆棧塊也被回收了。如果同樣的調用方式密集地重復發生,分配 / 回收 將會導致大量的開銷。
https://docs.google.com/document/d/1wAaf1rYoM4S4gtnPh0zOlGzWtrZFQ5suE8qr2sD8uWQ/pub
因為這個問題,Go 1.2 將最小堆棧大小增長到了 8Kb。之后因為實現了連續堆棧,則將堆棧大小縮減回了 2Kb。
下圖是分段堆棧的演示圖:
總結
Go 的堆棧管理是非常高效的,而且容易理解。Golang 不是唯一一個沒有選擇分段堆棧的語言, Rust 語言因為同樣的原因而沒有選擇這個方案。
如果你想了解更深入的堆棧內容,可以閱讀 Dave Cheney 的博客文章,該文章討論了 redzone ,還有 Bill Kennedy 的文章解釋了堆棧中的 frames。
閱讀原文:https://medium.com/a-journey-with-go/go-how-does-the-goroutine-stack-size-evolve-447fc02085e5
擴展閱讀:
歡迎關注微信公眾號: 后端早讀課