【Go進階—基礎特性】defer

與昊發表於2022-04-12

defer 是我們經常會使用的一個關鍵字,它會在當前函式返回前執行傳入的函式,常用於關閉檔案描述符、關閉資料庫連線以及解鎖資源。

使用場景

釋放資源

這是 defer 最常見的用法,包括釋放互斥鎖、關閉檔案控制程式碼、關閉網路連線、關閉管道和停止定時器等,如:

m.mutex.Lock()
defer m.mutex.Unlock()

異常處理

defer 第二個重要用途就是處理異常,與 recover 搭配一起處理 panic,讓程式從異常中恢復。例如 gin 框架中 recovery 中介軟體的原始碼:

return func(c *Context) {
    defer func() {
        if err := recover(); err != nil {
            // Check for a broken connection, as it is not really a
            // condition that warrants a panic stack trace.
            var brokenPipe bool
            if ne, ok := err.(*net.OpError); ok {
                if se, ok := ne.Err.(*os.SyscallError); ok {
                    if strings.Contains(strings.ToLower(se.Error()), "broken pipe") || strings.Contains(strings.ToLower(se.Error()), "connection reset by peer") {
                        brokenPipe = true
                    }
                }
            }
// ...

修改命名返回值

// $GOROOT/src/fmt/scan.go
func (s *ss) Token(skipSpace bool, f func(rune) bool) (tok []byte, err error) {
    defer func() {
        if e := recover(); e != nil {
            if se, ok := e.(scanError); ok {
                err = se.err
            } else {
                panic(e)
            }
        }
    }()
...
}                        

列印除錯資訊

// $GOROOT/src/net/conf.go
func (c *conf) hostLookupOrder(r *Resolver, hostname string) (ret hostLookupOrder) {
    if c.dnsDebugLevel > 1 {
        defer func() {
            print("go package net: hostLookupOrder(", hostname, ") = ", ret.String(), "\n")
        }()
    }
    ...
}                        

行為規則

defer 的語法很簡單,不過衍生出的用法很多,有時讓人迷惑,在這裡我們總結一下 defer 的幾個基本使用規則。

同一函式內部不同 defer 關鍵字後面的函式是逆序執行的

後面我們會討論,defer 關鍵字後面的延遲函式需要註冊到一個 deferred 函式棧中(本質上是一個連結串列),因此遵循棧的後進先出規則,多個 defer 後面的函式是逆序執行的。

defer 關鍵字後面的函式引數是在 defer 關鍵字出現時預計算的

defer 關鍵字後面的函式是在註冊到 deferred 函式棧的時候進行求值的。下面看一個例子:

func test1() {
    for i := 0; i <= 3; i++ {
        defer func(n int) {
            fmt.Println(n)
        }(i)
    }
}

func test2() {
    for i := 0; i <= 3; i++ {
        defer func() {
            fmt.Println(i)
        }()
    }
}

func main() {
    test1()
    test2()
}

在 test1 中,defer 後面接的是一個帶有一個引數的匿名函式。每當 defer 將匿名函式註冊到 deferred 函式棧的時候,都會對該匿名函式的引數進行求值。因此,deferred 函式棧中匿名函式的引數依次是 0,1,2,3,列印出來的結果就是 3,2,1,0。

在 test2 中,defer 後面接的是一個不帶引數的匿名函式。當程式碼執行的時候,deferred 棧中彈出的函式會以閉包的方式訪問外部變數 i,而此時 i 的值已經變為了 4,因此列印結果為 4,4,4,4。

實現原理

資料結構

// src/runtime/runtime2.go
type _defer struct {
    siz       int32
    started   bool
    heap    bool
    openDefer bool
    sp        uintptr
    pc        uintptr
    fn        *funcval
    _panic    *_panic
    link      *_defer
}
  • siz 是引數和結果的記憶體大小;
  • heap 表示該結構體是否存於堆中;
  • sp 和 pc 分別代表棧指標和呼叫方的程式計數器;
  • fn 是 defer 關鍵字中傳入的函式;
  • _panic 是觸發延遲呼叫的結構體,可能為空;
  • openDefer 表示當前 defer 是否經過開放編碼的優化。

可以看出,每個 _defer 例項實際上是對一個函式的封裝,擁有執行函式的必要資訊,如棧指標等。多個 _defer 例項使用指標 link 連線起來形成一個單向連結串列,儲存到當前 goroutine 的資料結構中,待當前函式執行結束再逐個取出執行。

type g struct {
    // ...
    _defer    *_defer
    // ...
}

執行機制

在中間程式碼生成階段會處理程式中的 defer,根據不同的條件,會有三種不同的機制來處理該關鍵字:堆分配、棧分配和開放編碼。早期的 Go 語言會在堆上分配 _defer 結構體,不過該實現的效能較差,在 1.13 版本中引入棧上分配的結構體,減少了 30% 的額外開銷,然後在 1.14 中引入了基於開放編碼的 defer,使得效能大幅度提升。

堆分配

堆分配是預設的兜底方案,採用本方案時,在編譯期間會將 defer 關鍵字轉換成 runtime.deferproc 函式,並且為所有呼叫 defer 的函式末尾插入 runtime.deferreturn 函式。簡而言之,就是 deferproc 生成 _defer 結構體並插入連結串列,deferreturn 取出 _defer 並執行。

來簡單看一下原始碼:

func deferproc(siz int32, fn *funcval) {
    sp := getcallersp()
    argp := uintptr(unsafe.Pointer(&fn)) + unsafe.Sizeof(fn)
    callerpc := getcallerpc()

    d := newdefer(siz)
    if d._panic != nil {
        throw("deferproc: d.panic != nil after newdefer")
    }
    d.fn = fn
    d.pc = callerpc
    d.sp = sp
    switch siz {
    case 0:
    case sys.PtrSize:
        *(*uintptr)(deferArgs(d)) = *(*uintptr)(unsafe.Pointer(argp))
    default:
        memmove(deferArgs(d), unsafe.Pointer(argp), uintptr(siz))
    }

    return0()
}

deferproc 會為 defer 建立一個新的 _defer 結構體、設定它的函式指標 fn、程式計數器 pc 和棧指標 sp 並將相關的引數拷貝到相鄰的記憶體空間中。

newdefer 的作用是獲取 _defer 結構體,這裡包含三種路徑:

  1. 從排程器的延遲呼叫快取池 sched.deferpool 中取出結構體並將該結構體追加到當前 Goroutine 的快取池中;
  2. 從 Goroutine 的延遲呼叫快取池 pp.deferpool 中取出結構體;
  3. 通過 runtime.mallocgc 在堆上建立一個新的結構體。

無論使用哪種方式,獲取到 _defer 結構體之後,都會被追加到所在 Goroutine 的 _defer 連結串列的最前面。

棧分配

棧分配 defer 是為了提高堆分配 defer 的記憶體使用效率而引入的,當 defer 關鍵字在函式中最多執行一次時,編譯器就會將 defer 編譯成 deferprocStack 函式將 _defer 結構體分配到棧上。

在編譯期間已經建立了 _defer 結構體,所以 deferprocStack 只需要設定一些未初始化的欄位,然後將棧上的 _defer 追加到連結串列上。

func deferprocStack(d *_defer) {
    gp := getg()
    d.started = false
    d.heap = false
    d.openDefer = false
    d.sp = getcallersp()
    d.pc = getcallerpc()
    d.framepc = 0
    d.varp = 0
    *(*uintptr)(unsafe.Pointer(&d._panic)) = 0
    *(*uintptr)(unsafe.Pointer(&d.fd)) = 0
    *(*uintptr)(unsafe.Pointer(&d.link)) = uintptr(unsafe.Pointer(gp._defer))
    *(*uintptr)(unsafe.Pointer(&gp._defer)) = uintptr(unsafe.Pointer(d))

    return0()
}
開放編碼

無論是堆分配 defer 還是棧分配 defer,編譯器都只能把 defer 轉換成相應的建立 _defer 結構體的函式,最後通過 deferreturn 函式取出結構體再執行。如果編譯器不這麼麻煩,直接把 defer 語句轉換成相應的程式碼插入函式尾部,是不是就可以節省很多步驟,提高儲存效率和效能?開放編碼使用的就是這種思路。

但並不是所有的情況下都可以使用開放編碼方式,在一下場景下 defer 語句不能被處理成開放編碼型別:

  • 編譯時禁用了編譯器優化;
  • defer 出現在迴圈中;
  • 單個函式中 defer 出現了 8 個以上;
  • 單個函式中 return 語句的個數乘以 defer 語句的個數超過了 15。

相關文章