Go語言 連續(xù)棧

2018-07-25 17:23 更新

Go語言支持goroutine,每個(gè)goroutine需要能夠運(yùn)行,所以它們都有自己的棧。假如每個(gè)goroutine分配固定棧大小并且不能增長,太小則會(huì)導(dǎo)致溢出,太大又會(huì)浪費(fèi)空間,無法存在許多的goroutine。

為了解決這個(gè)問題,goroutine可以初始時(shí)只給棧分配很小的空間,然后隨著使用過程中的需要自動(dòng)地增長。這就是為什么Go可以開千千萬萬個(gè)goroutine而不會(huì)耗盡內(nèi)存。

Go1.3版本之后則使用的是continuous stack,下面將具體分析一下這種技術(shù)。

基本原理

每次執(zhí)行函數(shù)調(diào)用時(shí)Go的runtime都會(huì)進(jìn)行檢測,若當(dāng)前棧的大小不夠用,則會(huì)觸發(fā)“中斷”,從當(dāng)前函數(shù)進(jìn)入到Go的運(yùn)行時(shí)庫,Go的運(yùn)行時(shí)庫會(huì)保存此時(shí)的函數(shù)上下文環(huán)境,然后分配一個(gè)新的足夠大的??臻g,將舊棧的內(nèi)容拷貝到新棧中,并做一些設(shè)置,使得當(dāng)函數(shù)恢復(fù)運(yùn)行時(shí),函數(shù)會(huì)在新分配的棧中繼續(xù)執(zhí)行,仿佛整個(gè)過程都沒發(fā)生過一樣,這個(gè)函數(shù)會(huì)覺得自己使用的是一塊大小“無限”的棧空間。

實(shí)現(xiàn)過程

在研究Go的實(shí)現(xiàn)細(xì)節(jié)之前讓我們先自己思考一下應(yīng)該如何實(shí)現(xiàn)。第一步肯定要有某種機(jī)制檢測到當(dāng)前棧大小不夠用了,這個(gè)應(yīng)該是把當(dāng)前的棧寄存器SP跟棧的可用棧空間的邊界進(jìn)行比較。能夠檢測到棧大小不夠用,就相當(dāng)于捕捉到了“中斷”。

捕獲完“中斷”,第二步要做的,就應(yīng)該是進(jìn)入運(yùn)行時(shí),保存當(dāng)前goroutine的上下文。別陷入如何保存上下文的細(xì)節(jié),先假如我們把函數(shù)棧增長時(shí)的上下文保存好了,那下一步就是分配新的??臻g了,我們可以將分配空間想象成就是調(diào)用一下malloc而已。

接下來怎么辦呢?我們要將舊棧中的內(nèi)容拷貝到新棧中,然后讓函數(shù)繼續(xù)在新棧中運(yùn)行。這里先暫時(shí)忽略舊棧內(nèi)容拷貝到新棧中的一些技術(shù)難點(diǎn),假設(shè)在新??臻g中恢復(fù)了“中斷”時(shí)的上下文,從運(yùn)行時(shí)返回到函數(shù)。

函數(shù)在新的棧中繼續(xù)運(yùn)行了,但是還有個(gè)問題:函數(shù)如何返回。因?yàn)楹瘮?shù)返回后棧是要縮小的,否則就會(huì)內(nèi)存浪費(fèi)空間了,所以還需要在函數(shù)返回時(shí)處理?xiàng)?s小的問題。

具體細(xì)節(jié)

如何捕獲到函數(shù)的棧空間不足

Go語言和C不同,不是使用棧指針寄存器和棧基址寄存器確定函數(shù)的棧的。在Go的運(yùn)行時(shí)庫中,每個(gè)goroutine對應(yīng)一個(gè)結(jié)構(gòu)體G,大致相當(dāng)于進(jìn)程控制塊的概念。這個(gè)結(jié)構(gòu)體中存了stackbase和stackguard,用于確定這個(gè)goroutine使用的棧空間信息。每個(gè)Go函數(shù)調(diào)用的前幾條指令,先比較棧指針寄存器跟g->stackguard,檢測是否發(fā)生棧溢出。如果棧指針寄存器值超越了stackguard就需要擴(kuò)展??臻g。

為了加深理解,下面讓我們跟蹤一下代碼,并看看實(shí)際生成的匯編吧。首先寫一個(gè)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è)域,也就是g->stackguard地址,將它賦值給CX。然后CX地址的值與SP進(jìn)行比較,如果SP大于g->stackguard了,則會(huì)調(diào)用runtime.morestack函數(shù)。這幾條指令的作用就是檢測棧是否溢出。

不過并不是所有函數(shù)在鏈接時(shí)都會(huì)插入這種指令。如果你讀源代碼,可能會(huì)發(fā)現(xiàn)#pragma textflag 7,或者在匯編函數(shù)中看到TEXT reuntime.exit(SB),7,$0,這種函數(shù)就是不會(huì)檢測棧溢出的。這個(gè)是編譯標(biāo)記,控制是否生成棧溢出檢測指令。

runtime.morestack是用匯編實(shí)現(xiàn)的,做的事情大致是將一些信息存在M結(jié)構(gòu)體中,這些信息包括當(dāng)前棧楨,參數(shù),當(dāng)前函數(shù)調(diào)用,函數(shù)返回地址(兩個(gè)返回地址,一個(gè)是runtime.morestack的函數(shù)地址,一個(gè)是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的運(yùn)行時(shí)庫的調(diào)度器使用的都是m->g0。

舊棧數(shù)據(jù)復(fù)制到新棧

runtime.morestack會(huì)調(diào)用于runtime.newstack,newstack做的事情很好理解:分配一個(gè)足夠大的新的空間,將舊的棧中的數(shù)據(jù)復(fù)制到新的棧中,進(jìn)行適當(dāng)?shù)男揎棧瑐窝b成調(diào)用過runtime.lessstack的樣子(這樣當(dāng)函數(shù)返回時(shí)就會(huì)調(diào)用runtime.lessstack再次進(jìn)入runtime中做一些棧收縮的處理)。

這里有一個(gè)技術(shù)難點(diǎn):舊棧數(shù)據(jù)復(fù)制到新棧的過程,要考慮指針失效問題。

比如有某個(gè)指針,引用了舊棧中的地址,如果僅僅是將舊棧內(nèi)容搬到新棧中,那么該指針就失效了,因?yàn)榕f棧已被釋放,應(yīng)該修改這個(gè)指針讓它指向新棧的對應(yīng)地址。考慮如下代碼:

func f1() {
    var a A
    f(&a)
}
func f2(a *A) {
    // modify a
}

如果在f2中發(fā)生了棧增長,此時(shí)分配更大的空間作為新棧,并將舊棧內(nèi)容拷貝到新棧中,僅僅這樣是不夠的,因?yàn)閒2中的a還是指向舊棧中的f1的,所以必須調(diào)整。

Go實(shí)現(xiàn)了精確的垃圾回收,運(yùn)行時(shí)知道每一塊內(nèi)存對應(yīng)的對象的類型信息。在復(fù)制之后,會(huì)進(jìn)行指針的調(diào)整。具體做法是,對當(dāng)前棧幀之前的每一個(gè)棧幀,對其中的每一個(gè)指針,檢測指針指向的地址,如果指向地址是落在舊棧范圍內(nèi)的,則將它加上一個(gè)偏移使它指向新棧的相應(yīng)地址。這個(gè)偏移值等于新棧基地址減舊?;刂贰?/p>

runtime.lessstack比較簡單,它其實(shí)就是切換到m->g0棧之后調(diào)用runtime.oldstack函數(shù)。這時(shí)之前保存的那個(gè)Stktop結(jié)構(gòu)體是時(shí)候發(fā)揮作用了,從上面可以找到舊棧空間的SP和PC等信息,通過runtime.gogo跳轉(zhuǎn)過去,整個(gè)過程就完成了。

gp = m->curg; //當(dāng)前g
top = (Stktop*)gp->stackbase; //取得Stktop結(jié)構(gòu)體
label = top->gobuf; //從結(jié)構(gòu)體中取出Gobuf
runtime·gogo(&label, cret); //通過Gobuf恢復(fù)上下文

小結(jié)

  1. 使用分段棧的函數(shù)頭幾個(gè)指令檢測SP和stackguard,調(diào)用runtime.morestack
  2. runtime.morestack函數(shù)的主要功能是保存當(dāng)前的棧的一些信息,然后轉(zhuǎn)換成調(diào)度器的棧調(diào)用runtime.newstack
  3. runtime.newstack函數(shù)的主要功能是分配空間,裝飾此空間,將舊的frame和arg弄到新空間
  4. 使用gogocall的方式切換到新分配的棧,gogocall使用的JMP返回到被中斷的函數(shù)
  5. 繼續(xù)執(zhí)行遇到RET指令時(shí)會(huì)返回到runtime.lessstack,lessstack做的事情跟morestack相反,它要準(zhǔn)備好從new stack到old stack

整個(gè)過程有點(diǎn)像一次中斷,中斷處理時(shí)保存當(dāng)時(shí)的現(xiàn)場,弄個(gè)新的棧,中斷恢復(fù)時(shí)恢復(fù)到新棧中運(yùn)行。棧的收縮是垃圾回收的過程中實(shí)現(xiàn)的.當(dāng)檢測到棧只使用了不到1/4時(shí),??s小為原來的1/2.

links


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號