一文教你搞懂 Go 中棧操作

轉載請聲明出處哦~,本篇文章發佈於luozhiyun的部落格://www.luozhiyun.com/archives/513

本文使用的go的源碼15.7

知識點

LInux 進程在記憶體布局

Linux_stack

多任務作業系統中的每個進程都在自己的記憶體沙盒中運行。在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。

調用者與被調用者的棧幀結構如下圖所示:

Stack layout

Go 語言的彙編程式碼中棧暫存器解釋的非常模糊,我們大概只要知道兩個暫存器 BP 和 SP 的作用就可以了:

BP:基準指針暫存器,維護當前棧幀的基準地址,以便用來索引變數和參數,就像一個錨點一樣,在其它架構中它等價於幀指針FP,只是在x86架構下,變數和參數都可以通過SP來索引;

SP:棧指針暫存器,總是指向棧頂;

Goroutine 棧操作

G Stack

在 Goroutine 中有一個 stack 數據結構,裡面有兩個屬性 lo 與 hi,描述了實際的棧記憶體地址:

  • stack.lo:棧空間的低地址;
  • stack.hi:棧空間的高地址;

在 Goroutine 中會通過 stackguard0 來判斷是否要進行棧增長:

  • stackguard0:stack.lo + StackGuard, 用於stack overlow的檢測;
  • StackGuard:保護區大小,常量Linux上為 928 位元組;
  • StackSmall:常量大小為 128 位元組,用於小函數調用的優化;
  • StackBig:常量大小為 4096 位元組;

根據被調用函數棧幀的大小來判斷是否需要擴容:

  1. 當棧幀大小(FramSzie)小於等於 StackSmall(128)時,如果 SP 小於 stackguard0 那麼就執行棧擴容;
  2. 當棧幀大小(FramSzie)大於 StackSmall(128)時,就會根據公式 SP - FramSzie + StackSmall 和 stackguard0 比較,如果小於 stackguard0 則執行擴容;
  3. 當棧幀大小(FramSzie)大於StackBig(4096)時,首先會檢查 stackguard0 是否已轉變成 StackPreempt 狀態了;然後根據公式 SP-stackguard0+StackGuard <= framesize + (StackGuard-StackSmall)判斷,如果是 true 則執行擴容;

需要注意的是,由於棧是由高地址向低地址增長的,所以對比的時候,都是小於才執行擴容,這裡需要大家品品。

當執行棧擴容時,會在記憶體空間中分配更大的棧記憶體空間,然後將舊棧中的所有內容複製到新棧中,並修改指向舊棧對應變數的指針重新指向新棧,最後銷毀並回收舊棧的記憶體空間,從而實現棧的動態擴容。

彙編

這裡簡單講解一下後面分析中會用到的一些 Go 語言使用的 Plan 9 彙編,以免看不太明白。

彙編函數

我們先來看看 plan9 的彙編函數的定義:

function

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大小:

stackpool

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 的棧空間是否足夠。

  1. 當棧幀大小(FramSzie)小於等於 StackSmall(128)時,如果 SP 小於 stackguard0 那麼就執行棧擴容;
  2. 當棧幀大小(FramSzie)大於 StackSmall(128)時,就會根據公式 SP - FramSzie + StackSmall 和 stackguard0 比較,如果小於 stackguard0 則執行擴容;
  3. 當棧幀大小(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)
}
  1. copystack 首先會計算一下使用棧空間大小,那麼在進行棧複製的時候只需要複製已使用的空間就好了;
  2. 然後調用 stackalloc 函數從堆上分配一片記憶體塊;
  3. 然後對比新舊棧的 hi 的值計算出兩塊記憶體之間的差值 delta,這個 delta 會在調用 adjustsudogs、adjustctxt 等函數的時候判斷舊棧的記憶體指針位置,然後加上 delta 然後就獲取到了新棧的指針位置,這樣就可以將指針也調整到新棧了;

new_stack

  1. 調用 memmove 將源棧中的整片記憶體拷貝到新的棧中;
  2. 然後繼續調用調整指針的函數繼續調整棧中 txt、defer、panic 位置的指針;
  3. 接下來將 G 上的棧引用切換成新棧;
  4. 最後調用 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/

//golang.org/doc/asm

luozhiyun很酷

Tags: