Go語言 連續(xù)棧

2018-07-25 17:23 更新

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。

實現(xiàn)過程

在研究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小的問題。

具體細節(jié)

如何捕獲到函數(shù)的??臻g不足

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。

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

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恢復上下文

小結(jié)

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

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

links


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

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號