一文教你搞懂 Go 中棧操作
轉載請聲明出處哦~,本篇文章發佈於luozhiyun的部落格://www.luozhiyun.com/archives/513
本文使用的go的源碼15.7
知識點
LInux 進程在記憶體布局
多任務作業系統中的每個進程都在自己的記憶體沙盒中運行。在32位模式下,它總是4GB記憶體地址空間,記憶體分配是分配虛擬記憶體給進程,當進程真正訪問某一虛擬記憶體地址時,作業系統通過觸發缺頁中斷,在物理記憶體上分配一段相應的空間再與之建立映射關係,這樣進程訪問的虛擬記憶體地址,會被自動轉換變成有效物理記憶體地址,便可以進行數據的存儲與訪問了。
Kernel space:作業系統內核地址空間;
Stack:棧空間,是用戶存放程式臨時創建的局部變數,棧的增長方向是從高位地址到地位地址向下進行增長。在現代主流機器架構上(例如x86
)中,棧都是向下生長的。然而,也有一些處理器(例如B5000
)棧是向上生長的,還有一些架構(例如System Z
)允許自定義棧的生長方向,甚至還有一些處理器(例如SPARC
)是循環棧的處理方式;
Heap:堆空間,堆是用於存放進程運行中被動態分配的記憶體段,它的大小並不固定,可動態擴張或縮減;
BBS segment:BSS段,存放的是全局或者靜態數據,但是存放的是全局/靜態未初始化數據;
Data segment:數據段,通常是指用來存放程式中已初始化的全局變數的一塊記憶體區域;
Text segment:程式碼段,指用來存放程式執行程式碼的一塊記憶體區域。這部分區域的大小在程式運行前就已經確定,並且記憶體區域屬於只讀。
棧的相關概念
In computer science, a call stack is a stack data structure that stores information about the active subroutines of a computer program.
In computer programming, a subroutine is a sequence of program instructions that performs a specific task, packaged as a unit.
調用棧call stack
,簡稱棧,是一種棧數據結構,用於存儲有關電腦程式的活動 subroutines 資訊。在電腦編程中,subroutines 是執行特定任務的一系列程式指令,打包為一個單元。
A stack frame is a frame of data that gets pushed onto the stack. In the case of a call stack, a stack frame would represent a function call and its argument data.
棧幀stack frame
又常被稱為幀frame
是在調用棧中儲存的函數之間的調用關係,每一幀對應了函數調用以及它的參數數據。
有了函數調用自然就要有調用者 caller 和被調用者 callee ,如在 函數 A 里 調用 函數 B,A 是 caller,B 是 callee。
調用者與被調用者的棧幀結構如下圖所示:
Go 語言的彙編程式碼中棧暫存器解釋的非常模糊,我們大概只要知道兩個暫存器 BP 和 SP 的作用就可以了:
BP:基準指針暫存器,維護當前棧幀的基準地址,以便用來索引變數和參數,就像一個錨點一樣,在其它架構中它等價於幀指針FP
,只是在x86架構下,變數和參數都可以通過SP來索引;
SP:棧指針暫存器,總是指向棧頂;
Goroutine 棧操作
在 Goroutine 中有一個 stack 數據結構,裡面有兩個屬性 lo 與 hi,描述了實際的棧記憶體地址:
- stack.lo:棧空間的低地址;
- stack.hi:棧空間的高地址;
在 Goroutine 中會通過 stackguard0 來判斷是否要進行棧增長:
- stackguard0:
stack.lo + StackGuard
, 用於stack overlow的檢測; - StackGuard:保護區大小,常量Linux上為 928 位元組;
- StackSmall:常量大小為 128 位元組,用於小函數調用的優化;
- StackBig:常量大小為 4096 位元組;
根據被調用函數棧幀的大小來判斷是否需要擴容:
- 當棧幀大小(FramSzie)小於等於 StackSmall(128)時,如果 SP 小於 stackguard0 那麼就執行棧擴容;
- 當棧幀大小(FramSzie)大於 StackSmall(128)時,就會根據公式
SP - FramSzie + StackSmall
和 stackguard0 比較,如果小於 stackguard0 則執行擴容; - 當棧幀大小(FramSzie)大於StackBig(4096)時,首先會檢查 stackguard0 是否已轉變成 StackPreempt 狀態了;然後根據公式
SP-stackguard0+StackGuard <= framesize + (StackGuard-StackSmall)
判斷,如果是 true 則執行擴容;
需要注意的是,由於棧是由高地址向低地址增長的,所以對比的時候,都是小於才執行擴容,這裡需要大家品品。
當執行棧擴容時,會在記憶體空間中分配更大的棧記憶體空間,然後將舊棧中的所有內容複製到新棧中,並修改指向舊棧對應變數的指針重新指向新棧,最後銷毀並回收舊棧的記憶體空間,從而實現棧的動態擴容。
彙編
這裡簡單講解一下後面分析中會用到的一些 Go 語言使用的 Plan 9 彙編,以免看不太明白。
彙編函數
我們先來看看 plan9 的彙編函數的定義:
stack frame size:包含局部變數以及額外調用函數的參數空間;
arguments size:包含參數以及返回值大小,例如入參是 3 個 int64 類型,返回值是 1 個 int64 類型,那麼返回值就是 sizeof(int64) * 4;
棧調整
棧的調整是通過對硬體 SP 暫存器進行運算來實現的,例如:
SUBQ $24, SP // 對 sp 做減法,為函數分配函數棧幀
...
ADDQ $24, SP // 對 sp 做加法 ,清除函數棧幀
由於棧是往下增長的,所以 SUBQ 對 SP 做減法的時候實際上是為函數分配棧幀,ADDQ 則是清除棧幀。
常見指令
加減法操作:
ADDQ AX, BX // BX += AX
SUBQ AX, BX // BX -= AX
數據搬運:
常數在 plan9 彙編用 $num 表示,可以為負數,默認情況下為十進位。搬運的長度是由 MOV 的後綴決定。
MOVB $1, DI // 1 byte
MOVW $0x10, BX // 2 bytes
MOVD $1, DX // 4 bytes
MOVQ $-10, AX // 8 bytes
還有一點區別是在使用 MOVQ 的時候會有看到帶括弧和不帶括弧的區別。
// 加括弧代表是指針的引用
MOVQ (AX), BX // => BX = *AX 將AX指向的記憶體區域8byte賦值給BX
MOVQ 16(AX), BX // => BX = *(AX + 16)
//不加括弧是值的引用
MOVQ AX, BX // => BX = AX 將AX中存儲的內容賦值給BX,注意區別
跳轉:
// 無條件跳轉
JMP addr // 跳轉到地址,地址可為程式碼中的地址
JMP label // 跳轉到標籤,可以跳轉到同一函數內的標籤位置
JMP 2(PC) // 以當前指令為基礎,向前/後跳轉 x 行
// 有條件跳轉
JLS addr
地址運算:
LEAQ (AX)(AX*2), CX // => CX = AX + (AX * 2) = AX * 3
上面程式碼中的 2 代表 scale,scale 只能是 0、2、4、8。
解析
G 的創建
因為棧都是在 Goroutine 上的,所以先從 G 的創建開始看如何創建以及初始化棧空間的。由於我在《詳解Go語言調度循環源碼實現 //www.luozhiyun.com/archives/448 》中已經講過 G 的創建,所以這裡只對棧的初始化部分的程式碼進行講解。
G 的創建會調用 runtime·newproc
進行創建:
runtime.newproc
func newproc(siz int32, fn *funcval) {
argp := add(unsafe.Pointer(&fn), sys.PtrSize)
gp := getg()
// 獲取 caller 的 PC 暫存器
pc := getcallerpc()
// 切換到 G0 進行創建
systemstack(func() {
newg := newproc1(fn, argp, siz, gp, pc)
...
})
}
newproc 方法會切換到 G0 上調用 newproc1 函數進行 G 的創建。
runtime.newproc1
const _StackMin = 2048
func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) *g {
_g_ := getg()
...
_p_ := _g_.m.p.ptr()
// 從 P 的空閑鏈表中獲取一個新的 G
newg := gfget(_p_)
// 獲取不到則調用 malg 進行創建
if newg == nil {
newg = malg(_StackMin)
casgstatus(newg, _Gidle, _Gdead)
allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
}
...
return newg
}
newproc1 方法很長,裡面主要是獲取 G ,然後對獲取到的 G 做一些初始化的工作。我們這裡只看 malg 函數的調用。
在調用 malg 函數的時候會傳入一個最小棧大小的值:_StackMin(2048)。
runtime.malg
func malg(stacksize int32) *g {
// 創建 G 結構體
newg := new(g)
if stacksize >= 0 {
// 這裡會在 stacksize 的基礎上為每個棧預留系統調用所需的記憶體大小 _StackSystem
// 在 Linux/Darwin 上( _StackSystem == 0 )本行不改變 stacksize 的大小
stacksize = round2(_StackSystem + stacksize)
// 切換到 G0 為 newg 初始化棧記憶體
systemstack(func() {
newg.stack = stackalloc(uint32(stacksize))
})
// 設置 stackguard0 ,用來判斷是否要進行棧擴容
newg.stackguard0 = newg.stack.lo + _StackGuard
newg.stackguard1 = ^uintptr(0)
*(*uintptr)(unsafe.Pointer(newg.stack.lo)) = 0
}
return newg
}
在調用 malg 的時候會將傳入的記憶體大小加上一個 _StackSystem 值預留給系統調用使用,round2 函數會將傳入的值舍入為 2 的指數。然後會切換到 G0 執行 stackalloc 函數進行棧記憶體分配。
分配完畢之後會設置 stackguard0 為 stack.lo + _StackGuard
,作為判斷是否需要進行棧擴容使用,下面會談到。
棧的初始化
文件位置:src/runtime/stack.go
// 全局的棧快取,分配 32KB以下記憶體
var stackpool [_NumStackOrders]struct {
item stackpoolItem
_ [cpu.CacheLinePadSize - unsafe.Sizeof(stackpoolItem{})%cpu.CacheLinePadSize]byte
}
//go:notinheap
type stackpoolItem struct {
mu mutex
span mSpanList
}
// 全局的棧快取,分配 32KB 以上記憶體
var stackLarge struct {
lock mutex
free [heapAddrBits - pageShift]mSpanList // free lists by log_2(s.npages)
}
// 初始化stackpool/stackLarge全局變數
func stackinit() {
if _StackCacheSize&_PageMask != 0 {
throw("cache size must be a multiple of page size")
}
for i := range stackpool {
stackpool[i].item.span.init()
lockInit(&stackpool[i].item.mu, lockRankStackpool)
}
for i := range stackLarge.free {
stackLarge.free[i].init()
lockInit(&stackLarge.lock, lockRankStackLarge)
}
}
在進行棧分配之前我們先來看看棧初始化的時候會做什麼,需要注意的是,stackinit 是在調用 runtime·schedinit
初始化的,是在調用 runtime·newproc
之前進行的。
在執行棧初始化的時候會初始化兩個全局變數 stackpool 和 stackLarge。stackpool 可以分配小於 32KB 的記憶體,stackLarge 用來分配大於 32KB 的棧空間。
棧的分配
從初始化的兩個兩個全局變數我們也可以知道,棧會根據大小的不同從不同的位置進行分配。
小棧記憶體分配
文件位置:src/runtime/stack.go
func stackalloc(n uint32) stack {
// 這裡的 G 是 G0
thisg := getg()
...
var v unsafe.Pointer
// 在 Linux 上,_FixedStack = 2048、_NumStackOrders = 4、_StackCacheSize = 32768
// 如果申請的棧空間小於 32KB
if n < _FixedStack<<_NumStackOrders && n < _StackCacheSize {
order := uint8(0)
n2 := n
// 大於 2048 ,那麼 for 循環 將 n2 除 2,直到 n 小於等於 2048
for n2 > _FixedStack {
// order 表示除了多少次
order++
n2 >>= 1
}
var x gclinkptr
//preemptoff != "", 在 GC 的時候會進行設置,表示如果在 GC 那麼從 stackpool 分配
// thisg.m.p = 0 會在系統調用和 改變 P 的個數的時候調用,如果發生,那麼也從 stackpool 分配
if stackNoCache != 0 || thisg.m.p == 0 || thisg.m.preemptoff != "" {
lock(&stackpool[order].item.mu)
// 從 stackpool 分配
x = stackpoolalloc(order)
unlock(&stackpool[order].item.mu)
} else {
// 從 P 的 mcache 分配記憶體
c := thisg.m.p.ptr().mcache
x = c.stackcache[order].list
if x.ptr() == nil {
// 從堆上申請一片記憶體空間填充到stackcache中
stackcacherefill(c, order)
x = c.stackcache[order].list
}
// 移除鏈表的頭節點
c.stackcache[order].list = x.ptr().next
c.stackcache[order].size -= uintptr(n)
}
// 獲取到分配的span記憶體塊
v = unsafe.Pointer(x)
} else {
...
}
...
return stack{uintptr(v), uintptr(v) + uintptr(n)}
}
stackalloc 會根據傳入的參數 n 的大小進行分配,在 Linux 上如果 n 小於 32768 bytes,也就是 32KB ,那麼會進入到小棧的分配邏輯中。
小棧指大小為 2K/4K/8K/16K 的棧,在分配的時候,會根據大小計算不同的 order 值,如果棧大小是 2K,那麼 order 就是 0,4K 對應 order 就是 1,以此類推。這樣一方面可以減少不同 Goroutine 獲取不同棧大小的鎖衝突,另一方面可以預先快取對應大小的 span ,以便快速獲取。
thisg.m.p == 0
可能發生在系統調用 exitsyscall 或改變 P 的個數 procresize 時,thisg.m.preemptoff != ""
會發生在 GC 的時候。也就是說在發生在系統調用 exitsyscall 或改變 P 的個數在變動,亦或是在 GC 的時候,會從 stackpool 分配棧空間,否則從 mcache 中獲取。
如果 mcache 對應的 stackcache 獲取不到,那麼調用 stackcacherefill 從堆上申請一片記憶體空間填充到stackcache中。
主要注意的是,stackalloc 由於切換到 G0 進行調用,所以 thisg 是 G0,我們也可以通過《如何編譯調試 Go runtime 源碼 //www.luozhiyun.com/archives/506 》這一篇文章的方法來進行調試:
func stackalloc(n uint32) stack {
thisg := getg()
// 添加一行列印
if debug.schedtrace > 0 {
print("stackalloc runtime: gp: gp=", thisg, ", goid=", thisg.goid, ", gp->atomicstatus=", readgstatus(thisg), "\n")
}
...
}
下面我們分別看一下 stackpoolalloc 與 stackcacherefill 函數。
runtime.stackpoolalloc
func stackpoolalloc(order uint8) gclinkptr {
list := &stackpool[order].item.span
s := list.first
lockWithRankMayAcquire(&mheap_.lock, lockRankMheap)
if s == nil {
// no free stacks. Allocate another span worth.
// 從堆上分配 mspan
// _StackCacheSize = 32 * 1024
s = mheap_.allocManual(_StackCacheSize>>_PageShift, &memstats.stacks_inuse)
if s == nil {
throw("out of memory")
}
// 剛分配的 span 裡面分配對象個數肯定為 0
if s.allocCount != 0 {
throw("bad allocCount")
}
if s.manualFreeList.ptr() != nil {
throw("bad manualFreeList")
}
//OpenBSD 6.4+ 系統需要做額外處理
osStackAlloc(s)
// Linux 中 _FixedStack = 2048
s.elemsize = _FixedStack << order
//_StackCacheSize = 32 * 1024
// 這裡是將 32KB 大小的記憶體塊分成了elemsize大小塊,用單向鏈表進行連接
// 最後 s.manualFreeList 指向的是這塊記憶體的尾部
for i := uintptr(0); i < _StackCacheSize; i += s.elemsize {
x := gclinkptr(s.base() + i)
x.ptr().next = s.manualFreeList
s.manualFreeList = x
}
// 插入到 list 鏈表頭部
list.insert(s)
}
x := s.manualFreeList
// 代表被分配完畢
if x.ptr() == nil {
throw("span has no free stacks")
}
// 將 manualFreeList 往後移動一個單位
s.manualFreeList = x.ptr().next
// 統計被分配的記憶體塊
s.allocCount++
// 因為分配的時候第一個記憶體塊是 nil
// 所以當指針為nil 的時候代表被分配完畢
// 那麼需要將該對象從 list 的頭節點移除
if s.manualFreeList.ptr() == nil {
// all stacks in s are allocated.
list.remove(s)
}
return x
}
在 stackpoolalloc 函數中會去找 stackpool 對應 order 下標的 span 鏈表的頭節點,如果不為空,那麼直接將頭節點的屬性 manualFreeList 指向的節點從鏈表中移除,並返回;
如果 list.first
為空,那麼調用 mheap_的 allocManual 函數從堆中分配 mspan,具體的記憶體分配相關的文章可以看我這篇:《詳解Go中記憶體分配源碼實現 //www.luozhiyun.com/archives/434 》。
從 allocManual 函數會分配 32KB 大小的記憶體塊,分配好新的 span 之後會根據 elemsize 大小將 32KB 記憶體進行切割,然後通過單向鏈表串起來並將最後一塊記憶體地址賦值給 manualFreeList 。
比如當前的 elemsize 所代表的記憶體大小是 8KB大小:
runtime.stackcacherefill
func stackcacherefill(c *mcache, order uint8) {
var list gclinkptr
var size uintptr
lock(&stackpool[order].item.mu)
//_StackCacheSize = 32 * 1024
// 將 stackpool 分配的記憶體組成一個單向鏈表 list
for size < _StackCacheSize/2 {
x := stackpoolalloc(order)
x.ptr().next = list
list = x
// _FixedStack = 2048
size += _FixedStack << order
}
unlock(&stackpool[order].item.mu)
c.stackcache[order].list = list
c.stackcache[order].size = size
}
stackcacherefill 函數會調用 stackpoolalloc 從 stackpool 中獲取一半的空間組裝成 list 鏈表,然後放入到 stackcache 數組中。
大棧記憶體分配
func stackalloc(n uint32) stack {
thisg := getg()
var v unsafe.Pointer
if n < _FixedStack<<_NumStackOrders && n < _StackCacheSize {
...
} else {
// 申請的記憶體空間過大,從 runtime.stackLarge 中檢查是否有剩餘的空間
var s *mspan
// 計算需要分配多少個 span 頁, 8KB 為一頁
npage := uintptr(n) >> _PageShift
// 計算 npage 能夠被2整除幾次,用來作為不同大小記憶體的塊的索引
log2npage := stacklog2(npage)
lock(&stackLarge.lock)
// 如果 stackLarge 對應的鏈表不為空
if !stackLarge.free[log2npage].isEmpty() {
//獲取鏈表的頭節點,並將其從鏈表中移除
s = stackLarge.free[log2npage].first
stackLarge.free[log2npage].remove(s)
}
unlock(&stackLarge.lock)
lockWithRankMayAcquire(&mheap_.lock, lockRankMheap)
//這裡是stackLarge為空的情況
if s == nil {
// 從堆上申請新的記憶體 span
s = mheap_.allocManual(npage, &memstats.stacks_inuse)
if s == nil {
throw("out of memory")
}
// OpenBSD 6.4+ 系統需要做額外處理
osStackAlloc(s)
s.elemsize = uintptr(n)
}
v = unsafe.Pointer(s.base())
}
...
return stack{uintptr(v), uintptr(v) + uintptr(n)}
}
對於大棧記憶體分配,運行時會查看 stackLarge 中是否有剩餘的空間,如果不存在剩餘空間,它也會調用 mheap_.allocManual
從堆上申請新的記憶體。
棧的擴容
棧溢出檢測
編譯器會在目標程式碼生成的時候執行:src/cmd/internal/obj/x86/obj6.go:stacksplit 根據函數棧幀大小插入相應的指令,檢查當前 goroutine 的棧空間是否足夠。
- 當棧幀大小(FramSzie)小於等於 StackSmall(128)時,如果 SP 小於 stackguard0 那麼就執行棧擴容;
- 當棧幀大小(FramSzie)大於 StackSmall(128)時,就會根據公式
SP - FramSzie + StackSmall
和 stackguard0 比較,如果小於 stackguard0 則執行擴容; - 當棧幀大小(FramSzie)大於StackBig(4096)時,首先會檢查 stackguard0 是否已轉變成 StackPreempt 狀態了;然後根據公式
SP-stackguard0+StackGuard <= framesize + (StackGuard-StackSmall)
判斷,如果是 true 則執行擴容;
我們先來看看偽程式碼會更清楚一些:
當棧幀大小(FramSzie)小於等於 StackSmall(128)時:
CMPQ SP, stackguard
JEQ label-of-call-to-morestack
當棧幀大小(FramSzie)大於 StackSmall(128)時:
LEAQ -xxx(SP), AX
CMPQ AX, stackguard
JEQ label-of-call-to-morestack
這裡 AX = SP – framesize + StackSmall,然後執行 CMPQ 指令讓 AX 與 stackguard 比較;
當棧幀大小(FramSzie)大於StackBig(4096)時:
MOVQ stackguard, SI // SI = stackguard
CMPQ SI, $StackPreempt // compare SI ,StackPreempt
JEQ label-of-call-to-morestack
LEAQ StackGuard(SP), AX // AX = SP + StackGuard
SUBQ SI, AX // AX = AX - SI = SP + StackGuard -stackguard
CMPQ AX, $(framesize+(StackGuard-StackSmall))
這裡的偽程式碼會相對複雜一些,由於 G 裡面的 stackguard0 在搶佔的時候可能會賦值成 StackPreempt,所以明確有沒有被搶佔,那麼需要將 stackguard0 和 StackPreempt進行比較。然後將執行比較: SP-stackguard+StackGuard <= framesize + (StackGuard-StackSmall)
,兩邊都加上 StackGuard 是為了保證左邊的值是正數。
希望在理解完上面的程式碼之前不要繼續往下看。
主要注意的是,在一些函數的執行程式碼中,編譯器很智慧的加上了NOSPLIT
標記,打了這個標記之後就會禁用棧溢出檢測,可以在如下程式碼中發現這個標記的蹤影:
程式碼位置:cmd/internal/obj/x86/obj6.go
...
if ctxt.Arch.Family == sys.AMD64 && autoffset < objabi.StackSmall && !p.From.Sym.NoSplit() {
leaf := true
LeafSearch:
for q := p; q != nil; q = q.Link {
...
}
if leaf {
p.From.Sym.Set(obj.AttrNoSplit, true)
}
}
...
大致程式碼邏輯應該是:當函數處於調用鏈的葉子節點,且棧幀小於StackSmall位元組時,則自動標記為NOSPLIT。同樣的,我們在寫程式碼的時候也可以自己在函數上面加上//go:nosplit
強制指定NOSPLIT屬性。
棧溢出實例
下面我們寫一個簡單的例子:
func main() {
a, b := 1, 2
_ = add1(a, b)
_ = add2(a, b)
_ = add3(a, b)
}
func add1(x, y int) int {
_ = make([]byte, 20)
return x + y
}
func add2(x, y int) int {
_ = make([]byte, 200)
return x + y
}
func add3(x, y int) int {
_ = make([]byte, 5000)
return x + y
}
然後列印出它的彙編:
$ GOOS=linux GOARCH=amd64 go tool compile -S -N -l main.go
上面這個例子用三個方法調用解釋了上面所說的三種情況:
main 函數
0x0000 00000 (main.go:3) TEXT "".main(SB), ABIInternal, $48-0
0x0000 00000 (main.go:3) MOVQ (TLS), CX
0x0009 00009 (main.go:3) CMPQ SP, 16(CX) // SP < stackguard 則跳到 129執行
0x0009 00009 (main.go:3) CMPQ SP, 16(CX)
0x000d 00013 (main.go:3) PCDATA $0, $-2
0x000d 00013 (main.go:3) JLS 129
...
0x0081 00129 (main.go:3) CALL runtime.morestack_noctxt(SB)
首先,我們從 TLS ( thread local storage) 變數中載入一個值至 CX 暫存器,然後將 SP 和 16(CX) 進行比較,那什麼是 TLS?16(CX) 又代表什麼?
其實TLS
是一個偽暫存器,表示的是thread-local storage,它存放了 G 結構體。我們看一看 runtime 源程式碼中對於 G 的定義:
type g struct {
stack stack // offset known to runtime/cgo
stackguard0 uintptr // offset known to liblink
...
}
type stack struct {
lo uintptr
hi uintptr
}
可以看到 stack 佔了 16bytes,所以 16(CX)
對應的是 g.stackguard0
。所以 CMPQ SP, 16(CX)
這一行程式碼實際上是比較 SP 和 stackguard 大小。如果 SP 小於 stackguard ,那麼說明到了增長的閾值,會執行 JLS 跳到 129 行,調用 runtime.morestack_noctxt 執行下一步棧擴容操作。
add1
0x0000 00000 (main.go:10) TEXT "".add1(SB), NOSPLIT|ABIInternal, $32-24
我們看到 add1 的彙編函數,可以看到它的棧大小只有 32 ,沒有到達 StackSmall 128 bytes 的大小,並且它又是一個 callee 被調用者,所以可以發它加上了NOSPLIT
標記,也就印證了我上面結論。
add2
"".add2 STEXT size=148 args=0x18 locals=0xd0
0x0000 00000 (main.go:15) TEXT "".add2(SB), ABIInternal, $208-24
0x0000 00000 (main.go:15) MOVQ (TLS), CX
// AX = SP - 208 + 128 = SP -80
0x0009 00009 (main.go:15) LEAQ -80(SP), AX // 棧大小大於StackSmall =128, 計算 SP - FramSzie + StackSmall 並放入AX暫存器
0x000e 00014 (main.go:15) CMPQ AX, 16(CX) // AX < stackguard 則跳到 138 執行
0x0012 00018 (main.go:15) PCDATA $0, $-2
0x0012 00018 (main.go:15) JLS 138
...
0x008a 00138 (main.go:15) CALL runtime.morestack_noctxt(SB)
add2 函數的棧幀大小是 208,大於 StackSmall 128 bytes ,所以可以看到首先從 TLS 變數中載入一個值至 CX 暫存器。
然後執行指令 LEAQ -80(SP), AX
,但是這裡為什麼是 -80 其實當時讓我蠻疑惑的,但是需要注意的是這裡的計算公式是: SP - FramSzie + StackSmall
,直接代入之後會發現它就是 -80,然後將這個數值載入到 AX 暫存器中。
最後調用 CMPQ AX, 16(CX)
,16(CX) 我們在上面已經講過了是等於 stackguard0 ,所以這裡是比較 AX 與 stackguard0 的小大,如果小於則直接跳轉到 138 行執行 runtime.morestack_noctxt
。
add3
"".add3 STEXT size=157 args=0x18 locals=0x1390
0x0000 00000 (main.go:20) TEXT "".add3(SB), ABIInternal, $5008-24
0x0000 00000 (main.go:20) MOVQ (TLS), CX
0x0009 00009 (main.go:20) MOVQ 16(CX), SI // 將 stackguard 賦值給 SI
0x000d 00013 (main.go:20) PCDATA $0, $-2
0x000d 00013 (main.go:20) CMPQ SI, $-1314 // 將 stackguard < stackPreempt 則跳轉到 147 執行
0x0014 00020 (main.go:20) JEQ 147
0x0016 00022 (main.go:20) LEAQ 928(SP), AX // AX = SP +928
0x001e 00030 (main.go:20) SUBQ SI, AX // AX -= stackguard
0x0021 00033 (main.go:20) CMPQ AX, $5808 // framesize + 928 -128 = 5808,比較 AX < 5808,則執行147
0x0027 00039 (main.go:20) JLS 147
...
0x0093 00147 (main.go:20) CALL runtime.morestack_noctxt(SB)
add3 函數是直接分配了一個 5000 bytes 的數組在棧上,所以開頭還是一樣的,將從 TLS 變數中載入一個值至 CX 暫存器,然後將 stackguard0 賦值給 SI 暫存器;
接下來會執行指令 CMPQ SI, $-1314
,這裡實際上比較 stackguard0 和 StackPreempt 的大小,至於為啥是 -1314 其實是直接在插入彙編程式碼的時候會調用 StackPreempt 變數,這個變數是在程式碼裡面寫死的:
程式碼位置:cmd/internal/objabi/stack.go
const (
StackPreempt = -1314 // 0xfff...fade
)
如果沒有被搶佔,那麼直接往下執行LEAQ 928(SP), AX
,這句指令等於 AX = SP +_StackGuard
,在 Linux 中 _StackGuard 等於 928;
接下來執行 SUBQ SI, AX
,這一句指令等於 AX -= stackguard0
;
最後執行 CMPQ AX, $5808
,這個 5808 實際上是 framesize + _StackGuard - _StackSmall
,如果 AX 小於 5808 那麼跳轉到 147 行執行 runtime.morestack_noctxt 函數。
到這裡棧溢出檢測就講解完畢了,我看了其他的文章,應該都沒有我講解的全面,特別是棧幀大小大於 _StackBig 時的溢出檢測。
棧的擴張
runtime.morestack_noctxt 是用彙編實現的,它會調用到 runtime·morestack,下面我們看看它的實現:
程式碼位置:src/runtime/asm_amd64.s
TEXT runtime·morestack(SB),NOSPLIT,$0-0
// Cannot grow scheduler stack (m->g0).
// 無法增長調度器的棧(m->g0)
get_tls(CX)
MOVQ g(CX), BX
MOVQ g_m(BX), BX
MOVQ m_g0(BX), SI
CMPQ g(CX), SI
JNE 3(PC)
CALL runtime·badmorestackg0(SB)
CALL runtime·abort(SB)
// 省略signal stack、morebuf和sched的處理
...
// Call newstack on m->g0's stack.
// 在 m->g0 棧上調用 newstack.
MOVQ m_g0(BX), BX
MOVQ BX, g(CX)
MOVQ (g_sched+gobuf_sp)(BX), SP
CALL runtime·newstack(SB)
CALL runtime·abort(SB) // 如果 newstack 返回則崩潰 crash if newstack returns
RET
runtime·morestack 做完校驗和賦值操作後會切換到 G0 調用 runtime·newstack
來完成擴容的操作。
runtime·newstack
func newstack() {
thisg := getg()
gp := thisg.m.curg
// 初始化暫存器相關變數
morebuf := thisg.m.morebuf
thisg.m.morebuf.pc = 0
thisg.m.morebuf.lr = 0
thisg.m.morebuf.sp = 0
thisg.m.morebuf.g = 0
...
// 校驗是否被搶佔
preempt := atomic.Loaduintptr(&gp.stackguard0) == stackPreempt
// 如果被搶佔
if preempt {
// 校驗是否可以安全的被搶佔
// 如果 M 上有鎖
// 如果正在進行記憶體分配
// 如果明確禁止搶佔
// 如果 P 的狀態不是 running
// 那麼就不執行搶佔了
if !canPreemptM(thisg.m) {
// 到這裡表示不能被搶佔?
// Let the goroutine keep running for now.
// gp->preempt is set, so it will be preempted next time.
gp.stackguard0 = gp.stack.lo + _StackGuard
// 觸發調度器的調度
gogo(&gp.sched) // never return
}
}
if gp.stack.lo == 0 {
throw("missing stack in newstack")
}
// 暫存器 sp
sp := gp.sched.sp
if sys.ArchFamily == sys.AMD64 || sys.ArchFamily == sys.I386 || sys.ArchFamily == sys.WASM {
// The call to morestack cost a word.
sp -= sys.PtrSize
}
...
if preempt {
//需要收縮棧
if gp.preemptShrink {
gp.preemptShrink = false
shrinkstack(gp)
}
// 被 runtime.suspendG 函數掛起
if gp.preemptStop {
// 被動讓出當前處理器的控制權
preemptPark(gp) // never returns
}
//主動讓出當前處理器的控制權
gopreempt_m(gp) // never return
}
// 計算新的棧空間是原來的兩倍
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize * 2
...
//將 Goroutine 切換至 _Gcopystack 狀態
casgstatus(gp, _Grunning, _Gcopystack)
//開始棧拷貝
copystack(gp, newsize)
casgstatus(gp, _Gcopystack, _Grunning)
gogo(&gp.sched)
}
newstack 函數的前半部分承擔了對 Goroutine 進行搶佔的任務,對於任務搶佔還不清楚的可以看我這篇:《從源碼剖析Go語言基於訊號搶佔式調度 //www.luozhiyun.com/archives/485 》。
在開始執行棧拷貝之前會先計算新棧的大小是原來的兩倍,然後將 Goroutine 狀態切換至 _Gcopystack 狀態。
棧拷貝
func copystack(gp *g, newsize uintptr) {
old := gp.stack
// 當前已使用的棧空間大小
used := old.hi - gp.sched.sp
//分配新的棧空間
new := stackalloc(uint32(newsize))
...
// 計算調整的幅度
var adjinfo adjustinfo
adjinfo.old = old
// 新棧和舊棧的幅度來控制指針的移動
adjinfo.delta = new.hi - old.hi
// 調整 sudogs, 必要時與 channel 操作同步
ncopy := used
if !gp.activeStackChans {
...
adjustsudogs(gp, &adjinfo)
} else {
// 到這裡代表有被阻塞的 G 在當前 G 的channel 中,所以要防止並發操作,需要獲取 channel 的鎖
// 在所有 sudog 中找到地址最大的指針
adjinfo.sghi = findsghi(gp, old)
// 對所有 sudog 關聯的 channel 上鎖,然後調整指針,並且複製 sudog 指向的部分舊棧的數據到新的棧上
ncopy -= syncadjustsudogs(gp, used, &adjinfo)
}
// 將源棧中的整片記憶體拷貝到新的棧中
memmove(unsafe.Pointer(new.hi-ncopy), unsafe.Pointer(old.hi-ncopy), ncopy)
// 繼續調整棧中 txt、defer、panic 位置的指針
adjustctxt(gp, &adjinfo)
adjustdefers(gp, &adjinfo)
adjustpanics(gp, &adjinfo)
if adjinfo.sghi != 0 {
adjinfo.sghi += adjinfo.delta
}
// 將 G 上的棧引用切換成新棧
gp.stack = new
gp.stackguard0 = new.lo + _StackGuard // NOTE: might clobber a preempt request
gp.sched.sp = new.hi - used
gp.stktopsp += adjinfo.delta
// 在新棧重調整指針
gentraceback(^uintptr(0), ^uintptr(0), 0, gp, 0, nil, 0x7fffffff, adjustframe, noescape(unsafe.Pointer(&adjinfo)), 0)
if stackPoisonCopy != 0 {
fillstack(old, 0xfc)
}
//釋放原始棧的記憶體空間
stackfree(old)
}
- copystack 首先會計算一下使用棧空間大小,那麼在進行棧複製的時候只需要複製已使用的空間就好了;
- 然後調用 stackalloc 函數從堆上分配一片記憶體塊;
- 然後對比新舊棧的 hi 的值計算出兩塊記憶體之間的差值 delta,這個 delta 會在調用 adjustsudogs、adjustctxt 等函數的時候判斷舊棧的記憶體指針位置,然後加上 delta 然後就獲取到了新棧的指針位置,這樣就可以將指針也調整到新棧了;
- 調用 memmove 將源棧中的整片記憶體拷貝到新的棧中;
- 然後繼續調用調整指針的函數繼續調整棧中 txt、defer、panic 位置的指針;
- 接下來將 G 上的棧引用切換成新棧;
- 最後調用 stackfree 釋放原始棧的記憶體空間;
棧的收縮
棧的收縮發生在 GC 時對棧進行掃描的階段:
func scanstack(gp *g, gcw *gcWork) {
...
// 進行棧收縮
shrinkstack(gp)
...
}
如果還不清楚 GC 的話不妨看一下我這篇文章:《Go語言GC實現原理及源碼分析 //www.luozhiyun.com/archives/475 》。
runtime.shrinkstack
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
// 計算當前正在使用的棧數量,如果 gp 使用的當前棧少於四分之一,則對棧進行收縮
// 當前使用的棧包括到 SP 的所有內容以及棧保護空間,以確保有 nosplit 功能的空間
if used := gp.stack.hi - gp.sched.sp + _StackLimit; used >= avail/4 {
return
}
// 將舊棧拷貝到新收縮後的棧上
copystack(gp, newsize)
}
新棧的大小會縮小至原來的一半,如果小於 _FixedStack (2KB)那麼不再進行收縮。除此之外還會計算一下當前棧的使用情況是否不足 1/4 ,如果使用超過 1/4 那麼也不會進行收縮。
最後判斷確定要進行收縮則調用 copystack 函數進行棧拷貝的邏輯。
總結
如果對於沒有了解過記憶體布局的同學,理解起來可能會比較吃力,因為我們在看堆的時候記憶體增長都是從小往大增長,而棧的增長方向是相反的,導致在做棧指令操作的時候將 SP 減小反而是將棧幀增大。
除此之外就是 Go 使用的是 plan9 這種彙編,資料比較少,看起來很麻煩,想要更深入了解這種彙編的可以看我下面的 Reference 的資料。
Reference
聊一聊goroutine stack //kirk91.github.io/posts/2d571d09/
Anatomy of a Program in Memory //manybutfinite.com/post/anatomy-of-a-program-in-memory/
stack-frame-layout-on-x86-64 //eli.thegreenplace.net/2011/09/06/stack-frame-layout-on-x86-64
深入研究goroutine棧 //www.huamo.online/2019/06/25/深入研究goroutine棧/
x86-64 下函數調用及棧幀原理 //zhuanlan.zhihu.com/p/27339191
Call stack //en.wikipedia.org/wiki/Call_stack
plan9 assembly 完全解析 //github.com/cch123/golang-notes/blob/master/assembly.md
Go語言內幕(5):運行時啟動過程 //studygolang.com/articles/7211
Go 彙編入門 //github.com/go-internals-cn/go-internals/blob/master/chapter1_assembly_primer/README.md
Go Assembly by Example //davidwong.fr/goasm/