解密 Go 協程的棧內存管理

應用程序的內存會分成堆區(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,也是經過了幾個版本的更迭

分段棧和連續棧

分段棧

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

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

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

連續棧

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

  1. 調用用runtime.newstack在內存空間中分配更大的棧內存空間;

  2. 使用runtime.copystack將舊棧中的所有內容複製到新的棧中;

  3. 將指向舊棧對應變量的指針重新指向新棧

  4. 調用runtime.stackfree銷燬並回收舊棧的內存空間;

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.stackpoolruntime.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 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)
}

舊棧的大小是通過我們上面說的保存在goroutine中的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")
   }

如果目標棧的大小沒有超出程序的限制,會將 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 < _FixedStack {
  return
 }
 avail := gp.stack.hi - gp.stack.lo
 if used := gp.stack.hi - gp.sched.sp + _StackLimit; used >= 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 叨」第一時間獲取我的文章推送。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ErnQDHeL5K8MPDYUPwjSYA