Categories
程式開發

白話Go 語言內存管理(二)goroutine棧內存管理


應用程序的內存會分成堆區(Heap)和棧區(Stack)兩個部分,程序在運行期間可以主動從堆區申請內存空間,這些內存由內存分配器分配並由垃圾收集器負責回收。棧區的內存由編譯器自動進行分配和釋放,棧區中存儲著函數的參數以及局部變量,它們會隨著函數的創建而創建,函數的返回而銷毀。

堆和棧都是編程語言裡的虛擬概念,並不是說在物理內存上有堆和棧之分,兩者的主要區別是棧是每個線程或者協程獨立擁有的,從棧上分配內存時不需要加鎖。而整個程序在運行時只有一個堆,從堆中分配內存時需要加鎖防止多個線程造成衝突,同時回收堆上的內存塊時還需要運行可達性分析、引用計數等算法來決定內存塊是否能被回收,所以從分配和回收內存的方面來看棧內存效率更高。

在Go應用程序運行時,每個goroutine都維護著一個自己的棧區,這個棧區只能自己使用不能被其他goroutine使用。棧區的初始大小是2KB(比x86_64架構下線程的默認棧2M要小很多),在goroutine運行的時候棧區會按照需要增長和收縮,佔用的內存最大限制的默認值在64位系統上是1GB。棧大小的初始值和上限這部分的設置都可以在Go的源碼runtime/stack.go裡找到:

// rumtime.stack.go
// The minimum size of stack used by Go code
_StackMin = 2048

var maxstacksize uintptr = 1 << 20 // enough until runtime.main sets it for real

其實棧內存空間、結構和初始大小在最開始並不是2KB,也是經過了幾個版本的更迭

v1.0 ~ v1.1 — 最小棧內存空間為4KB;

v1.2 — 將最小棧內存提升到了8KB;

v1.3 — 使用連續棧替換之前版本的分段棧;

v1.4 — 將最小棧內存降低到了2KB;

分段棧和連續棧

分段棧

Go 1.3 版本前使用的棧結構是分段棧,隨著goroutine 調用的函數層級的深入或者局部變量需要的越來越多時,運行時會調用runtime.morestack 和runtime.newstack創建一個新的棧空間,這些棧空間是不連續的,但是當前goroutine 的多個棧空間會以雙向鍊錶的形式串聯起來,運行時會通過指針找到連續的棧片段:

白話Go 語言內存管理(二)goroutine棧內存管理 1

分段棧雖然能夠按需為當前goroutine 分配內存並且及時減少內存的佔用,但是它也存在一個比較大的問題:

如果當前goroutine 的棧幾乎充滿,那麼任意的函數調用都會觸發棧的擴容,當函數返回後又會觸發棧的收縮,如果在一個循環中調用函數,棧的分配和釋放就會造成巨大的額外開銷,這被稱為熱分裂問題(Hot split)。

為了解決這個問題,Go在1.2版本的時候不得不將棧的初始化內存從4KB增大到了8KB。後來把採用連續棧結構後,又把初始棧大小減小到了2KB。

連續棧

連續棧可以解決分段棧中存在的兩個問題,其核心原理就是每當程序的棧空間不足時,初始化一片比舊棧大兩倍的新棧並將原棧中的所有值都遷移到新的棧中,新的局部變量或者函數調用就有了充足的內存空間。使用連續棧機制時,棧空間不足導致的擴容會經歷以下幾個步驟:

調用用runtime.newstack在內存空間中分配更大的棧內存空間;使用runtime.copystack將舊棧中的所有內容複製到新的棧中;將指向舊棧對應變量的指針重新指向新棧;調用runtime .stackfree銷毀並回收舊棧的內存空間;

白話Go 語言內存管理(二)goroutine棧內存管理 2

copystack會把舊棧裡的所有內容拷貝到新棧裡然後調整所有指向舊棧的變量的指針指向到新棧, 我們可以用下面這個程序驗證下,棧擴容後同一個變量的內存地址會發生變化。

package main

func main() {
var x [10]int
println(&x)
a(x)
println(&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`)
}

程序的輸出可以看到在棧擴容前後,變量x的內存地址的變化:

0xc000030738
...
...
0xc000081f38

棧區的內存管理

前面說了每個goroutine都維護著自己的棧區,棧結構是連續棧,是一塊連續的內存,在goroutine的類型定義的源碼裡我們可以找到標記著棧區邊界的stack信息,stack裡記錄著棧區邊界的高位內存地址和低位內存地址:

type g struct {
stack stack
...
}

type stack struct {
lo uintptr
hi uintptr
}

全局棧緩存

棧空間在運行時中包含兩個重要的全局變量,分別是runtime.stackpool 和runtime.stackLarge,這兩個變量分別表示全局的棧緩存和大棧緩存,前者可以分配小於32KB 的內存,後者用來分配大於32KB 的棧空間:

// Number of orders that get caching. Order 0 is FixedStack
// and each successive order is twice as large.
// We want to cache 2KB, 4KB, 8KB, and 16KB stacks. Larger stacks
// will be allocated directly.
// Since FixedStack is different on different systems, we
// must vary NumStackOrders to keep the same maximum cached size.
// OS | FixedStack | NumStackOrders
// -----------------+------------+---------------
// linux/darwin/bsd | 2KB | 4
// windows/32 | 4KB | 3
// windows/64 | 8KB | 2
// plan9 | 4KB | 3
_NumStackOrders = 4 - sys.PtrSize/4*sys.GoosWindows - 1*sys.GoosPlan9

var stackpool [_NumStackOrders]mSpanList

type stackpoolItem struct {
mu mutex
span mSpanList
}

var stackLarge struct {
lock mutex
free [heapAddrBits - pageShift]mSpanList
}

//go:notinheap
type mSpanList struct {
first *mspan // first span in list, or nil if none
last *mspan // last span in list, or nil if none
}

可以看到這兩個用於分配空間的全局變量都與內存管理單元runtime.mspan 有關,所以我們棧內容的申請也是跟前面文章裡的一樣,先去當前線程的對應尺寸的mcache裡去申請,不夠的時候mache會從全局的mcental裡取內存等等,這部分的具體細節就參考前面的文章《白話Go 語言內存管理(一)內存分配原理"》吧。

其實從調度器和內存分配的角度來看,如果運行時只使用全局變量來分配內存的話,勢必會造成線程之間的鎖競爭進而影響程序的執行效率,棧內存由於與線程關係比較密切,所以在每一個線程緩存runtime.mcache 中都加入了棧緩存減少鎖競爭影響。

type mcache struct {
...
alloc [numSpanClasses]*mspan

stackcache [_NumStackOrders]stackfreelist
...
}

type stackfreelist struct {
list gclinkptr
size uintptr
}

棧擴容

編譯器會為函數調用插入運行時檢查runtime.morestack,它會在幾乎所有的函數調用之前檢查當前goroutine 的棧內存是否充足,如果當前棧需要擴容,會調用runtime.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 limitn")
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 donen")
}
casgstatus(gp, _Gcopystack, _Grunning)
}

舊棧的大小是通過我們上面說的保存在goroutine中的stack信息裡記錄的棧區內存邊界計算出來的,然後用舊棧兩倍的大小創建新棧,創建前會檢查是新棧的大小是否超過了單個棧的內存上限。

oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize * 2
if newsize > maxstacksize {
print("runtime: goroutine stack exceeds ", maxstacksize, "-byte limitn")
throw("stack overflow")
}

如果目標棧的大小沒有超出程序的限制,會將goroutine 切換至_Gcopystack 狀態並調用runtime.copystack 開始棧的拷貝,在拷貝棧的內存之前,運行時會先通過runtime.stackalloc 函數分配新的棧空間:

func copystack(gp *g, newsize uintptr) {
old := gp.stack
used := old.hi - gp.sched.sp
// 创建新栈
new := stackalloc(uint32(newsize))
...
// 把旧栈的内容拷贝至新栈
memmove(unsafe.Pointer(new.hi-ncopy), unsafe.Pointer(old.hi-ncopy), ncopy)
...
// 调整指针
adjustctxt(gp, &adjinfo)
// groutine里记录新栈的边界
gp.stack = new
...
// 释放旧栈
stackfree(old)
}

新棧的初始化和數據的複制是一個比較簡單的過程,整個過程中最複雜的地方是將指向源棧中內存的指針調整為指向新的棧,這一步完成後就會釋放掉舊棧的內存空間了。

我們可以通過修改一下源碼文件runtime.stack.go,把常量stackDebug的值修改為1,使用命令go build -gcflags -S main.go 運行文章最開始的那個例子,觀察棧的初始化和擴容過程:

stackalloc 2048
stackcacherefill order=0
allocated 0xc000030000
...
copystack gp=0xc000000180 [0xc000030000 0xc0000306e0 0xc000030800] -> [0xc00005c000 0xc00005cee0 0xc00005d000]/4096
stackfree 0xc000030000 2048
stack grow done
...
copystack gp=0xc000000180 [0xc00005c000 0xc00005c890 0xc00005d000] -> [0xc000064000 0xc000065890 0xc000066000]/8192
stackfree 0xc00005c000 4096
stack grow done
...
copystack gp=0xc000000180 [0xc000064000 0xc000065890 0xc000066000] -> [0xc00006c000 0xc00006f890 0xc000070000]/16384
stackfree 0xc000064000 8192
stack grow done
...
copystack gp=0xc000000180 [0xc00006c000 0xc00006f890 0xc000070000] -> [0xc000070000 0xc000077890 0xc000078000]/32768
stackfree 0xc00006c000 16384
stack grow done

棧縮容

在goroutine運行的過程中,如果棧區的空間使用率不超過1/4,那麼在垃圾回收的時候使用runtime.shrinkstack進行棧縮容,當然進行縮容前會執行一堆前置檢查,都通過了才會進行縮容

func shrinkstack(gp *g) {
...
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize / 2
if newsize = avail/4 {
return
}

copystack(gp, newsize)
}

如果要觸發棧的縮容,新棧的大小會是原始棧的一半,不過如果新棧的大小低於程序的最低限制2KB,那麼縮容的過程就會停止。縮容也會調用擴容時使用的runtime.copystack 函數開闢新的棧空間,將舊棧的數據拷貝到新棧以及調整原來指針的指向。

在我們上面的那個例子裡,當main函數里的其他函數執行完後,只有main函數還在棧區的空間裡,如果這個時候系統進行垃圾回收就會對這個goroutine的棧區進行縮容。在這裡我們可以在程序里通過調用runtime.GC,強制系統進行垃圾回收,來試驗看一下棧縮容的過程和效果:

func main() {
var x [10]int
println(&x)
a(x)
runtime.GC()
println(&x)
}

執行命令go build -gcflags -S main.go後會看到類似下面的輸出。

...
shrinking stack 32768->16384
stackalloc 16384
allocated 0xc000076000
copystack gp=0xc000000180 [0xc00007a000 0xc000081e60 0xc000082000] -> [0xc000076000 0xc000079e60 0xc00007a000]/16384
...

總結

棧內存是應用程序中重要的內存空間,它能夠支持本地的局部變量和函數調用,棧空間中的變量會與棧一同創建和銷毀,這部分內存空間不需要工程師過多的干預和管理,現代的編程語言通過逃逸分析減少了我們的工作量,理解棧空間的分配對於理解Go 語言的運行時有很大的幫助。

感謝你閱讀我的文章,微信搜索公眾號「網管叨bi叨」第一時間獲取更多原創圖解Go語言原理的文章。

推薦閱讀

白話Go 語言內存管理(一)內存分配原理