Go語言支持goroutine,每個goroutine需要能夠運行,所以它們都有自己的棧。假如每個goroutine分配固定棧大小并且不能增長,太小則會導致溢出,太大又會浪費空間,無法存在許多的goroutine。
為了解決這個問題,goroutine可以初始時只給棧分配很小的空間,然后隨著使用過程中的需要自動地增長。這就是為什么Go可以開千千萬萬個goroutine而不會耗盡內(nèi)存。
Go1.3版本之后則使用的是continuous stack,下面將具體分析一下這種技術(shù)。
每次執(zhí)行函數(shù)調(diào)用時Go的runtime都會進行檢測,若當前棧的大小不夠用,則會觸發(fā)“中斷”,從當前函數(shù)進入到Go的運行時庫,Go的運行時庫會保存此時的函數(shù)上下文環(huán)境,然后分配一個新的足夠大的??臻g,將舊棧的內(nèi)容拷貝到新棧中,并做一些設(shè)置,使得當函數(shù)恢復運行時,函數(shù)會在新分配的棧中繼續(xù)執(zhí)行,仿佛整個過程都沒發(fā)生過一樣,這個函數(shù)會覺得自己使用的是一塊大小“無限”的??臻g。
在研究Go的實現(xiàn)細節(jié)之前讓我們先自己思考一下應該如何實現(xiàn)。第一步肯定要有某種機制檢測到當前棧大小不夠用了,這個應該是把當前的棧寄存器SP跟棧的可用??臻g的邊界進行比較。能夠檢測到棧大小不夠用,就相當于捕捉到了“中斷”。
捕獲完“中斷”,第二步要做的,就應該是進入運行時,保存當前goroutine的上下文。別陷入如何保存上下文的細節(jié),先假如我們把函數(shù)棧增長時的上下文保存好了,那下一步就是分配新的??臻g了,我們可以將分配空間想象成就是調(diào)用一下malloc而已。
接下來怎么辦呢?我們要將舊棧中的內(nèi)容拷貝到新棧中,然后讓函數(shù)繼續(xù)在新棧中運行。這里先暫時忽略舊棧內(nèi)容拷貝到新棧中的一些技術(shù)難點,假設(shè)在新??臻g中恢復了“中斷”時的上下文,從運行時返回到函數(shù)。
函數(shù)在新的棧中繼續(xù)運行了,但是還有個問題:函數(shù)如何返回。因為函數(shù)返回后棧是要縮小的,否則就會內(nèi)存浪費空間了,所以還需要在函數(shù)返回時處理??s小的問題。
Go語言和C不同,不是使用棧指針寄存器和?;芳拇嫫鞔_定函數(shù)的棧的。在Go的運行時庫中,每個goroutine對應一個結(jié)構(gòu)體G,大致相當于進程控制塊的概念。這個結(jié)構(gòu)體中存了stackbase和stackguard,用于確定這個goroutine使用的棧空間信息。每個Go函數(shù)調(diào)用的前幾條指令,先比較棧指針寄存器跟g->stackguard,檢測是否發(fā)生棧溢出。如果棧指針寄存器值超越了stackguard就需要擴展棧空間。
為了加深理解,下面讓我們跟蹤一下代碼,并看看實際生成的匯編吧。首先寫一個test.go文件,內(nèi)容如下:
package main
func main() {
main()
}
然后生成匯編文件:
go tool 6g -S test.go | head -8
可以看以輸出是:
000000 00000 (test.go:3) TEXT "".main+0(SB),$0-0
000000 00000 (test.go:3) MOVQ (TLS),CX
0x0009 00009 (test.go:3) CMPQ SP,(CX)
0x000c 00012 (test.go:3) JHI ,21
0x000e 00014 (test.go:3) CALL ,runtime.morestack00_noctxt(SB)
0x0013 00019 (test.go:3) JMP ,0
0x0015 00021 (test.go:3) NOP ,
讓我們好好看一下這些指令。(TLS)取到的是結(jié)構(gòu)體G的第一個域,也就是g->stackguard地址,將它賦值給CX。然后CX地址的值與SP進行比較,如果SP大于g->stackguard了,則會調(diào)用runtime.morestack函數(shù)。這幾條指令的作用就是檢測棧是否溢出。
不過并不是所有函數(shù)在鏈接時都會插入這種指令。如果你讀源代碼,可能會發(fā)現(xiàn)#pragma textflag 7
,或者在匯編函數(shù)中看到TEXT reuntime.exit(SB),7,$0
,這種函數(shù)就是不會檢測棧溢出的。這個是編譯標記,控制是否生成棧溢出檢測指令。
runtime.morestack是用匯編實現(xiàn)的,做的事情大致是將一些信息存在M結(jié)構(gòu)體中,這些信息包括當前棧楨,參數(shù),當前函數(shù)調(diào)用,函數(shù)返回地址(兩個返回地址,一個是runtime.morestack的函數(shù)地址,一個是f的返回地址)。通過這些信息可以把新棧和舊棧鏈起來。
void runtime.morestack() {
if(g == g0) {
panic();
} else {
m->morebuf.gobuf_pc = getCallerCallerPC();
void *SP = getCallerSP();
m->morebuf.gobuf_sp = SP;
m->moreargp = SP;
m->morebuf.gobuf_g = g;
m->morepc = getCallerPC();
void *g0 = m->g0;
g = g0;
setSP(g0->g_sched.gobuf_sp);
runtime.newstack();
}
}
需要注意的就是newstack是切換到m->g0的棧中去調(diào)用的。m->g0是調(diào)度器棧,go的運行時庫的調(diào)度器使用的都是m->g0。
runtime.morestack會調(diào)用于runtime.newstack,newstack做的事情很好理解:分配一個足夠大的新的空間,將舊的棧中的數(shù)據(jù)復制到新的棧中,進行適當?shù)男揎?,偽裝成調(diào)用過runtime.lessstack的樣子(這樣當函數(shù)返回時就會調(diào)用runtime.lessstack再次進入runtime中做一些棧收縮的處理)。
這里有一個技術(shù)難點:舊棧數(shù)據(jù)復制到新棧的過程,要考慮指針失效問題。
比如有某個指針,引用了舊棧中的地址,如果僅僅是將舊棧內(nèi)容搬到新棧中,那么該指針就失效了,因為舊棧已被釋放,應該修改這個指針讓它指向新棧的對應地址??紤]如下代碼:
func f1() {
var a A
f(&a)
}
func f2(a *A) {
// modify a
}
如果在f2中發(fā)生了棧增長,此時分配更大的空間作為新棧,并將舊棧內(nèi)容拷貝到新棧中,僅僅這樣是不夠的,因為f2中的a還是指向舊棧中的f1的,所以必須調(diào)整。
Go實現(xiàn)了精確的垃圾回收,運行時知道每一塊內(nèi)存對應的對象的類型信息。在復制之后,會進行指針的調(diào)整。具體做法是,對當前棧幀之前的每一個棧幀,對其中的每一個指針,檢測指針指向的地址,如果指向地址是落在舊棧范圍內(nèi)的,則將它加上一個偏移使它指向新棧的相應地址。這個偏移值等于新?;刂窚p舊棧基地址。
runtime.lessstack比較簡單,它其實就是切換到m->g0棧之后調(diào)用runtime.oldstack函數(shù)。這時之前保存的那個Stktop結(jié)構(gòu)體是時候發(fā)揮作用了,從上面可以找到舊??臻g的SP和PC等信息,通過runtime.gogo跳轉(zhuǎn)過去,整個過程就完成了。
gp = m->curg; //當前g
top = (Stktop*)gp->stackbase; //取得Stktop結(jié)構(gòu)體
label = top->gobuf; //從結(jié)構(gòu)體中取出Gobuf
runtime·gogo(&label, cret); //通過Gobuf恢復上下文
整個過程有點像一次中斷,中斷處理時保存當時的現(xiàn)場,弄個新的棧,中斷恢復時恢復到新棧中運行。棧的收縮是垃圾回收的過程中實現(xiàn)的.當檢測到棧只使用了不到1/4時,棧縮小為原來的1/2.
更多建議: