1. 程式人生 > 其它 >Go defer 原理和原始碼剖析

Go defer 原理和原始碼剖析

Go 語言中有一個非常有用的保留字 defer,它可以呼叫一個函式,該函式的執行被推遲到包裹它的函式返回時執行。

defer 語句呼叫的函式,要麼是因為包裹它的函式執行了 return 語句,到達了函式體的末端,要麼是因為對應的 goroutine 發生了 panic。

在實際的 go 語言程式中,defer 語句可以代替其它語言中 try…catch… 的作用,也可以用來處理釋放資源等收尾操作,比如關閉檔案控制代碼、關閉資料庫連線等。

1. 編譯器編譯 defer 過程

defer dosomething(x)

簡單來說,執行 defer 語句,實際上是註冊了一個稍後執行的函式,確定了函式名和引數,但不會立即呼叫,而是把呼叫過程推遲到當前函式 return 或者發生 panic 的時候。

我們先了解一下 defer 相關的資料結構。

1) struct _defer 資料結構

go 語言程式中每一次呼叫 defer 都生成一個 _defer 結構體。

type _defer struct {
    siz     int32 // 引數和返回值的記憶體大小
    started boul
    heap    boul    // 區分該結構是在棧上分配的,還是對上分配的
    sp        uintptr  // sp 計數器值,棧指標;
    pc        uintptr  // pc 計數器值,程式計數器;
    fn        *funcval // defer 傳入的函式地址,也就是延後執行的函式;
_panic *_panic // panic that is running defer link *_defer // 連結串列 }

我們預設使用了 go 1.13 版本的原始碼,其它版本類似。

一個函式內可以有多個 defer 呼叫,所以自然需要一個數據結構來組織這些 _defer 結構體。_defer 按照對齊規則佔用 48 位元組的記憶體。在 _defer 結構體中的 link 欄位,這個欄位把所有的 _defer 串成一個連結串列,表頭是掛在 Goroutine 的 _defer 欄位。

_defer 的鏈式結構如下:

_defer.siz 用於指定延遲函式的引數和返回值的空間,大小由 _defer.siz 指定,這塊記憶體的值在 defer 關鍵字執行的時候填充好。

defer 延遲函式的引數是預計算的,在棧上分配空間。每一個 defer 呼叫在棧上分配的記憶體佈局如下圖所示:

其中 _defer 是一個指標,指向一個 struct _defer 物件,它可能分配在棧上,也可能分配在堆上。

2) struct _defer 記憶體分配

以下是一個使用 defer 的範例,檔名為 test_defer.go:

package main

func doDeferFunc(x int) {
    println(x)
}

func doSomething() int {
    var x = 1
    defer doDeferFunc(x)
    x += 2
    return x
}

func main() {
    x := doSomething()
    println(x)
}

編譯以上程式碼,加上去除優化和內鏈選項:

go tool compile -N -l test_defer.go

匯出彙編程式碼:

go tool objdump test_defer.o

我們看下編譯成的二進位制程式碼:

從彙編指令我們看到,編譯器在遇到 defer 關鍵字的時候,添加了一些執行庫函式:deferprocStackdeferreturn

go 1.13 正式版本的釋出提升了 defer 的效能,號稱針對 defer 場景提升了 30% 的效能。

go 1.13 之前的版本 defer 語句會被編譯器翻譯成兩個過程:回撥註冊函式過程:deferprocdeferreturn

go 1.13 帶來的 deferprocStack 函式,這個函式就是這個 30% 效能提升的核心手段。deferprocStack 和 deferproc 的目的都是註冊回撥函式,但是不同的是 deferprocStatck 是在棧記憶體上分配 struct _defer 結構,而 deferproc 這個是需要去堆上分配結構記憶體的。而我們絕大部分的場景都是可以是在棧上分配的,所以自然整體效能就提升了。棧上分配記憶體自然是比對上要快太多了,只需要改變 rsp 暫存器的值就可以進行分配。

那麼什麼時候分配在棧上,什麼時候分配在堆上呢?

在編譯器相關的檔案(src/cmd/compile/internal/gc/ssa.go )裡,有個條件判斷:

func (s *state) stmt(n *Node) {
 
    case ODEFER:
        d := callDefer
        if n.Esc == EscNever {
            d = callDeferStack
        }
}

n.Esc 是 ast.Node 的逃逸分析的結果,那麼什麼時候 n.Esc 會被置成 EscNever 呢?

這個在逃逸分析的函式 esc 裡(src/cmd/compile/internal/gc/esc.go ):

func (e *EscState) esc(n *Node, parent *Node) {

    case ODEFER:
        if e.loopdepth == 1 { // top level
            n.Esc = EscNever // force stack allocation of defer record (see ssa.go)
            break
        }
}

這裡 e.loopdepth 等於 1的時候,才會設定成 EscNever ,e.loopdepth 欄位是用於檢測巢狀迴圈作用域的,換句話說,defer 如果在巢狀作用域的上下文中,那麼就可能導致 struct _defer 分配在堆上,如下:

package main

func main() {
    for i := 0; i < 10; i++ {
        defer func() {
            _ = i
        }()
    }
}

編譯器生成的則是 deferproc :

當 defer 外層出現顯式(for)或者隱式(goto)的時候,將會導致 struct _defer 結構體分配在堆上,效能就會變差,這個程式設計的時候要注意。

編譯器就能決定 _defer 結構體分配在棧上還是堆上,對應函式分別是 deferprocStatck 和 deferproc 函式,這兩個函式都很簡單,目的一致:分配出 struct _defer 的記憶體結構,把回撥函式初始化進去,掛到連結串列中。

3. deferprocStack 棧上分配

deferprocStack 函式做了哪些事情呢?

// 進入這個函式之前,就已經在棧上分配好了記憶體結構
func deferprocStack(d *_defer) {
    gp := getg()

    // siz 和 fn 在進入這個函式之前已經賦值
    d.started = false
    // 表明是棧的記憶體
    d.heap = false
    // 獲取到 caller 函式的 rsp 暫存器值,並賦值到 _defer 結構 sp 欄位中
    d.sp = getcallersp()
    // 獲取到 caller 函式的 rip 暫存器值,並賦值到 _defer 結構 pc 欄位中
    // 根據函式呼叫的原理,我們就知道 caller 的壓棧的 pc (rip) 值就是 deferprocStack 的下一條指令
    d.pc = getcallerpc()

    // 把這個 _defer 結構作為一個節點,掛到 goroutine 的連結串列中
    *(*uintptr)(unsafe.Pointer(&d._panic)) = 0
    *(*uintptr)(unsafe.Pointer(&d.link)) = uintptr(unsafe.Pointer(gp._defer))
    *(*uintptr)(unsafe.Pointer(&gp._defer)) = uintptr(unsafe.Pointer(d))
    // 注意,特殊的返回,不會觸發延遲呼叫的函式
    return0()
}

小結:

  • 由於是棧上分配記憶體的,所以呼叫到 deferprocStack 之前,編譯器就已經把 struct _defer 結構的函式準備好了;
  • _defer.heap 欄位用來標識這個結構體分配在棧上;
  • 儲存上下文,把 caller 函式的 rsp,pc(rip) 暫存器的值儲存到 _defer 結構體;
  • _defer 作為一個節點掛接到連結串列。注意:表頭是 goroutine 結構的 _defer 欄位,而在一個協程任務中大部分有多次函式呼叫的,所以這個連結串列會掛接一個呼叫棧上的 _defer 結構,執行的時候按照 rsp 來過濾區分;

4. deferproc 堆上分配

堆上分配的函式為 deferproc ,簡化邏輯如下:

func deferproc(siz int32, fn *funcval) {
  // arguments of fn fullow fn
    // 獲取 caller 函式的 rsp 暫存器值
    sp := getcallersp()
    argp := uintptr(unsafe.Pointer(&fn)) + unsafe.Sizeof(fn)
    // 獲取 caller 函式的 pc(rip) 暫存器值
    callerpc := getcallerpc()

    // 分配 struct _defer 記憶體結構
    d := newdefer(siz)
    if d._panic != nil {
        throw("deferproc: d.panic != nil after newdefer")
    }
    // _defer 結構體初始化
    d.fn = fn
    d.pc = callerpc
    d.sp = sp
    switch siz {
    case 0:
        // Do nothing.
    case sys.PtrSize:
        *(*uintptr)(deferArgs(d)) = *(*uintptr)(unsafe.Pointer(argp))
    default:
        memmove(deferArgs(d), unsafe.Pointer(argp), uintptr(siz))
    }
    // 注意,特殊的返回,不會觸發延遲呼叫的函式
    return0()
}

小結:

  • 與棧上分配不同,struct _defer 結構是在該函式裡分配的,呼叫 newdefer 分配結構體,newdefer 函式則是先去 poul 快取池裡看一眼,有就直接取用,沒有就呼叫 mallocgc 從堆上分配記憶體;
  • deferproc 接受入參 siz,fn ,這兩個引數分別標識延遲函式的引數和返回值的記憶體大小,延遲函式地址;
  • _defer.heap 欄位用來標識這個結構體分配在堆上;
  • 儲存上下文,把 caller 函式的 rsp,pc(rip) 暫存器的值儲存到 _defer 結構體;
  • _defer 作為一個節點掛接到連結串列;

5. 執行 defer 函式鏈

編譯器遇到 defer 語句,會插入兩個函式:

  • 分配函式:deferproc 或者 deferprocStack ;
  • 執行函式:deferreturn 。

包裹 defer 語句的函式退出的時候,由 deferreturn 負責執行所有的延遲呼叫鏈。

func deferreturn(arg0 uintptr) {
    gp := getg()
    // 獲取到最前的 _defer 節點
    d := gp._defer
    // 函式遞迴終止條件(d 連結串列遍歷完成)
    if d == nil {
        return
    }
    // 獲取 caller 函式的 rsp 暫存器值
    sp := getcallersp()
    if d.sp != sp {
        // 如果 _defer.sp 和 caller 的 sp 值不一致,那麼直接返回;
        // 因為,就說明這個 _defer 結構不是在該 caller 函式註冊的  
        return
    }

    switch d.siz {
    case 0:
        // Do nothing.
    case sys.PtrSize:
        *(*uintptr)(unsafe.Pointer(&arg0)) = *(*uintptr)(deferArgs(d))
    default:
        memmove(unsafe.Pointer(&arg0), deferArgs(d), uintptr(d.siz))
    }
    // 獲取到延遲迴調函式地址
    fn := d.fn
    d.fn = nil
    // 把當前 _defer 節點從連結串列中摘除
    gp._defer = d.link
    // 釋放 _defer 記憶體(主要是堆上才會需要處理,棧上的隨著函式執行完,棧收縮就回收了)
    freedefer(d)
    // 執行延遲迴調函式
    jmpdefer(fn, uintptr(unsafe.Pointer(&arg0)))
}

程式碼說明

  • 遍歷 defer 連結串列,一個個執行,順序連結串列從前往後執行,執行一個摘除一個,直到連結串列為空;
  • jmpdefer 負責跳轉到延遲迴調函式執行指令,執行結束之後,跳轉回 deferreturn 裡執行;
  • _defer.sp 的值可以用來判斷哪些是當前 caller 函式註冊的,這樣就能保證只執行自己函式註冊的延遲迴調函式;
    • 例如,a() -> b() -> c() ,a 呼叫 b,b 呼叫 c ,而 a,b,c 三個函式都有 defer 註冊延遲函式,那麼自然是 c()函式返回的時候,執行 c 的回撥;

2. defer 傳遞引數

1) 預計算引數

在前面描述 _defer 資料結構的時候說到記憶體結構如下:

_defer 在棧上作為一個 header,延遲迴調函式( defer )的引數和返回值緊接著 _defer 放置,而這個引數值是在 defer 執行的時候就設定好了,也就是預計算引數,而非等到執行 defer 函式的時候才去獲取。

舉個例子,執行 defer func(x, y) 的時候,x,y 這兩個實參是計算的出來的,Go 中的函式呼叫都是值傳遞。那麼就會把 x,y 的值拷貝到 _defer 結構體之後。再看個例子:

package main

func main() {
    var x = 1
    defer println(x)
    x += 2
    return
}

這個程式輸出是什麼呢?是 1 ,還是 3 ?答案是:1 。defer 執行的函式是 println ,println 引數是 x ,x 的值傳進去的值則是在 defer 語句執行的時候就確認了的。

2) defer 的引數準備

defer 延遲函式執行的引數已經儲存在和 _defer 一起的連續記憶體塊了。那麼執行 defer 函式的時候,引數是哪裡來呢?當然不是直接去 _defer 的地址找。因為這裡是走的標準的函式呼叫。

在 Go 語言中,一個函式的引數由 caller 函式準備好,比如說,一個 main() -> A(7) -> B(a) 形成類似以下的棧幀:

所以,deferreturn 除了跳轉到 defer 函式指令,還需要做一個事情:把 defer 延遲迴調函式需要的引數準備好(空間和值)。那麼就是如下程式碼來做的視線:

func deferreturn(arg0 uintptr) {

    switch d.siz {
    case 0:
        // Do nothing.
    case sys.PtrSize:
        *(*uintptr)(unsafe.Pointer(&arg0)) = *(*uintptr)(deferArgs(d))
    default:
        memmove(unsafe.Pointer(&arg0), deferArgs(d), uintptr(d.siz))
    }

}

arg0 就是 caller 用來放置 defer 引數和返回值的棧地址。這段程式碼的意思就是,把 _defer 預先的準備好的引數,copy 到 caller 棧幀的某個地址(arg0)。

3. 執行多條 defer

前面已經詳細說明了,_defer 是一個連結串列,表頭是 goroutine._defer 結構。一個協程的函式註冊的是掛同一個連結串列,執行的時候按照 rsp 來區分函式。並且,這個連結串列是把新元素插在表頭,而執行的時候是從前往後執行,所以這裡導致了一個 LIFO 的特性,也就是先註冊的 defer 函式後執行。

4. defer 和 return 執行順序

包含 defer 語句的函式返回時,先設定返回值還是先執行 defer 函式?

1) 函式的呼叫過程

要理解這個過程,首先要知道函式呼叫的過程:

  • go 的一行函式呼叫語句其實非原子操作,對應多行彙編指令,包括 1)引數設定,2) call 指令執行;
  • 其中 call 彙編指令的內容也有兩個:返回地址壓棧(會導致 rsp 值往下增長,rsp-0x8),callee 函式地址載入到 pc 暫存器;
  • go 的一行函式返回 return語句其實也非原子操作,對應多行彙編指令,包括 1)返回值設定和 2)ret 指令執行;
  • 其中 ret 彙編指令的內容是兩個,指令 pc 暫存器恢復為 rsp 棧頂儲存的地址,rsp 往上縮減,rsp+0x8;
  • 引數設定在 caller 函式裡,返回值設定在 callee 函式裡;
  • rsp, rbp 兩個暫存器是棧幀的最重要的兩個暫存器,這兩個值劃定了棧幀;

最重要的一點:Go 的 return 的語句呼叫是個複合操作,可以對應一下兩個操作序列:

  • 設定返回值
  • ret 指令跳轉到 caller 函式

2) return 之後是先返回值還是先執行 defer 函式?

Golang 官方文件有明確說明:

That is, if the surrounding function returns through an explicit return statement, deferred functions are executedafter any result parameters are set by that return statementbutbefore the function returns to its caller.

也就是說,defer 的函式鏈呼叫是在設定了返回值之後,但是在執行指令上下文返回到 caller 函式之前。

所以含有 defer 註冊的函式,執行 return 語句之後,對應執行三個操作序列:

  • 設定返回值
  • 執行 defer 連結串列
  • ret 指令跳轉到 caller 函式

那麼,根據這個原理我們來解析如下的行為:

func f1 () (r int) {
    t := 1
    defer func() {
        t = t + 5
    }()
    return t
}

func f2() (r int) {
    defer func(r int) {
        r = r + 5
    }(r)
    return 1
}

func f3() (r int) {
    defer func () {
        r = r + 5
    } ()
    return 1
}

這三個函式的返回值分別是多少?

答案:f1() -> 1,f2() -> 1,f3() -> 6 。

a) 函式 f1 執行 return t 語句之後:

  • 設定返回值 r = t,這個時候區域性變數 t 的值等於 1,所以 r = 1;
  • 執行 defer 函式,t = t+5 ,之後區域性變數 t 的值為 6;
  • 執行彙編 ret 指令,跳轉到 caller 函式;

所以,f1() 的返回值是 1 ;

b) 函式 f2 執行 return 1 語句之後:

  • 設定返回值 r = 1 ;
  • 執行 defer 函式,defer 函式傳入的引數是 r,r 在預計算引數的時候值為 0,Go 傳參為值傳遞,0 賦值給了匿名函式的引數變數,所以 ,r = r+5 ,匿名函式的引數變數 r 的值為 5;
  • 執行彙編 ret 指令,跳轉到 caller 函式;

所以,f2() 的返回值還是 1 ;

c) 函式 f3 執行 return 1 語句之後:

  • 設定返回值 r = 1;
  • 執行 defer 函式,r = r+5 ,之後返回值變數 r 的值為 6(這是個閉包函式,注意和 f2 區分);
  • 執行彙編 ret 指令,跳轉到 caller 函式;

所以,f1() 的返回值是 6 。

5. 總結

  • defer 關鍵字執行對應 _defer 資料結構,在 go1.1 - go1.12 期間一直是堆上分配,在 go1.13 之後優化成棧上分配 _defer 結構,效能提升明顯;
  • _defer 大部分場景是分配在棧上的,但是遇到迴圈巢狀的場景會分配到堆上,所以程式設計時要注意 defer 使用場景,否則可能出效能問題;
  • _defer 對應一個註冊的延遲迴調函式(defer),defer 函式的引數和返回值緊跟 _defer,可以理解成 header,_defer 和函式引數,返回值所在記憶體是一塊連續的空間,其中 _defer.siz 指明引數和返回值的所佔空間大小;
  • 同一個協程裡 defer 註冊的函式,都掛在一個連結串列中,表頭為 goroutine._defer;
    • 新元素插入在最前面,遍歷執行的時候則是從前往後執行。所以 defer 註冊函式具有 LIFO 的特性,也就是後註冊的先執行;
    • 不同的函式都在這個連結串列上,以 _defer.sp 區分;
    • defer 的引數是預計算的,也就是在 defer 關鍵字執行的時候,引數就確認,賦值在 _defer 的記憶體塊後面。執行的時候,copy 到棧幀對應的位置上;
    • return 對應 3 個動作的複合操作:設定返回值、執行 defer 函式連結串列、ret 指令跳轉。

參考:程式設計寶庫 go 語言教程