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 = t,這個時候局部變量 t 的值等於 1,所以 r = 1;
  • 執行 defer 函數,t = t+5 ,之後局部變量 t 的值為 6;
  • 執行匯編 ret 指令,跳轉到 caller 函數;

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

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

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

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

  • 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 語言教程。

到此這篇關於Go defer 原理和源碼剖析的文章就介紹到這瞭,更多相關Go defer 原理 內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: