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