Go語言 連續(xù)棧

2018-07-25 17:23 更新

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

具體細節(jié)

如何捕獲到函數的??臻g不足

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

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

links


以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號