Skip to content

defer

defer在 go 的日常開發中是一個出現頻率非常高的關鍵字,它會以先進後出的方式來執行defer關聯的函數,很多時候我們利用這種機制來進行一些資源的釋放操作,比如文件關閉之類的操作。

go
fd, err := os.Open("/dev/stdin")
if err != nil{
    return err
}
defer fd.Close()
...

如此高頻出現的關鍵字,使得我們有必要去了解一下它背後的結構。

結構

defer關鍵字對應runtime._defer結構體,它的結構並不復雜

go
type _defer struct {
  started bool
  heap    bool
  openDefer bool
  sp        uintptr // sp at time of defer
  pc        uintptr // pc at time of defer
  fn        func()  // can be nil for open-coded defers
  _panic    *_panic // panic that is running defer
  link      *_defer // next defer on G; can point to either heap or stack!
  fd   unsafe.Pointer // funcdata for the function associated with the frame
  varp uintptr        // value of varp for the stack frame
  framepc uintptr
}

其中的fn字段是defer關鍵字對應的函數,link表示下一個鏈接的defersppc記錄了調用方的函數信息,用於判斷defer屬於哪一個函數。defer 在運行時以鏈表的形式存在,鏈表的頭部就在協程 G 上,所以defer實際上是與協程直接關聯的。

go
type g struct {
    ...
  _panic    *_panic // innermost panic - offset known to liblink
  _defer    *_defer // innermost defer
    ...
}

當協程執行函數時,就會按照順序將函數中的defer從鏈表的頭部加入

go
defer fn1()
defer fn2()
defer fn3()

上面那段代碼就對應這幅圖

除了協程之外,P 也跟defer有一定的關聯,在 P 的結構體中,有一個deferpool字段,如所示。

go
type p struct {
  ...
  deferpool    []*_defer // pool of available defer structs (see panic.go)
  deferpoolbuf [32]*_defer
    ...
}

deferpool中存放著預分配好的defer結構,用於給與 P 關聯的協程 G 分配新的defer結構,可以減少開銷。

分配

在語法上對defer關鍵字的使用,編譯器會將其轉為為對runtime.deferproc函數的調用。比如 go 代碼是這樣寫的

go
defer fn1(x, y)

而編譯後實際上的代碼是這樣的

go
deferproc(func(){
  fn1(x, y)
})

所以實際上defer傳入的函數是沒有參數也沒有返回值的,deferproc函數代碼如下所示

go
func deferproc(fn func()) {
  gp := getg()
  d := newdefer()
  d.link = gp._defer
  gp._defer = d
  d.fn = fn
  d.pc = getcallerpc()
  d.sp = getcallersp()
  return0()
}

該函數負責創建defer結構並將其加入協程 G 鏈表的頭部,其中的runtime.newdefer函數就會嘗試從 P 中的deferpool來獲取預分配的defer結構。

go
if len(pp.deferpool) == 0 && sched.deferpool != nil {
    lock(&sched.deferlock)
    for len(pp.deferpool) < cap(pp.deferpool)/2 && sched.deferpool != nil {
        d := sched.deferpool
        sched.deferpool = d.link
        d.link = nil
        pp.deferpool = append(pp.deferpool, d)
    }
    unlock(&sched.deferlock)
}

它首先會從全局的sched.deferpool向局部的deferpool裝填一半的defer結構,然後再從 P 中的deferpool嘗試去獲取

go
if n := len(pp.deferpool); n > 0 {
    d = pp.deferpool[n-1]
    pp.deferpool[n-1] = nil
    pp.deferpool = pp.deferpool[:n-1]
}

if d == nil {
    // Allocate new defer.
    d = new(_defer)
}
d.heap = true

最後實在找不到才會使用手動分配的方式。最後可以看到有這麼一段代碼

go
d.heap = true

這表示defer在堆上分配,相應的當其為false時,就會在棧上分配,棧上分配的內存會在返回時自動回收,其內存管理效率要比在堆上更高,而決定是否在棧上分配的因素就是循環層數,這部分邏輯可以追溯到cmd/compile/ssagen中的escape.goDeferStmt方法的這一小段,如下所示

go
func (e *escape) goDeferStmt(n *ir.GoDeferStmt) {
  ...
  if n.Op() == ir.ODEFER && e.loopDepth == 1 {
    k = e.later(e.discardHole())
    n.SetEsc(ir.EscNever)
  }
    ...
}

e.loopDepth表示的就是當前語句的循環層數,如果當前defer語句不在循環中,就會將其分配到棧上。

go
case ir.ODEFER:
    n := n.(*ir.GoDeferStmt)
    if s.hasOpenDefers {
      s.openDeferRecord(n.Call.(*ir.CallExpr))
    } else {
      d := callDefer
      if n.Esc() == ir.EscNever {
        d = callDeferStack
      }
      s.callResult(n.Call.(*ir.CallExpr), d)
    }

如果是在棧上分配的話,就會直接在棧上創建defer結構體,最終會由runtime.deferprocStack函數來完成defer結構的創建。

go
if k == callDeferStack {
    // Make a defer struct d on the stack.
    if stksize != 0 {
      s.Fatalf("deferprocStack with non-zero stack size %d: %v", stksize, n)
    }

    t := deferstruct()
    ...
    // Call runtime.deferprocStack with pointer to _defer record.
    ACArgs = append(ACArgs, types.Types[types.TUINTPTR])
    aux := ssa.StaticAuxCall(ir.Syms.DeferprocStack, s.f.ABIDefault.ABIAnalyzeTypes(nil, ACArgs, ACResults))
    callArgs = append(callArgs, addr, s.mem())
    call = s.newValue0A(ssa.OpStaticLECall, aux.LateExpansionResultType(), aux)
    call.AddArgs(callArgs...)
    call.AuxInt = int64(types.PtrSize) // deferprocStack takes a *_defer arg

deferprocStack函數的簽名如下

go
func deferprocStack(d *_defer)

其具體的創建邏輯與deferproc並無太大區別,主要的區別在於,在棧上分配時是defer結構的來源是直接創建的結構體,在堆上分配的defer來源是new函數。

執行

當函數將要返回或者發生panic時,便會進入runtime.deferreturn函數,它負責從協程的鏈表中取出defer並執行。

go
func deferreturn() {
  gp := getg()
  for {
    d := gp._defer
    sp := getcallersp()
    if d.sp != sp {
      return
    }
    fn := d.fn
    d.fn = nil
    gp._defer = d.link
    freedefer(d)
    fn()
  }
}

首先會通過getcallersp()獲取當前函數的棧幀並與defer結構中的sp做比較來判斷defer是否屬於當前函數,然後將defer結構從鏈表頭部取出,並使用gp._defer = d.link執行下一個defer,再通過runtuime.freedefer函數將defer結構釋放回池中,最後再調用fn執行,就這樣一直循環到執行完屬於當前函數的所有defer結束為止。

開放編碼

defer的使用並非毫無成本,雖然它在語法上給我們提供了便利,但畢竟它不是直接進行函數調用,中間會進行經過一系列的過程,所以還是會造成性能損耗,所以後來 go 官方設計了一種優化方——開放編碼,它是一種對defer的優化方式,其原英文名叫 open-coded,國內基本上都給翻譯成了開放編碼,這裡的 open 指的是展開的意思,就是將defer函數的代碼展開到當前函數代碼中,就像函數內聯一樣。這種優化方式有以下幾個限制條件

  1. 函數中的defer數量不能超過 8 個
  2. deferreturn兩者數量的乘積不能超過 15
  3. defer不能出現在循環中
  4. 未禁用編譯優化
  5. 沒有手動調用os.Exit()
  6. 不需要從堆上復制參數

這部分判斷邏輯可以追溯到cmd/compile/ssagen.buildssa函數的下面這部分代碼

go
s.hasOpenDefers = base.Flag.N == 0 && s.hasdefer && !s.curfn.OpenCodedDeferDisallowed()
if s.hasOpenDefers && len(s.curfn.Exit) > 0 {
    s.hasOpenDefers = false
}
if s.hasOpenDefers {
    for _, f := range s.curfn.Type().Results().FieldSlice() {
        if !f.Nname.(*ir.Name).OnStack() {
            s.hasOpenDefers = false
            break
        }
    }
}
if s.hasOpenDefers && s.curfn.NumReturns*s.curfn.NumDefers > 15 {
    s.hasOpenDefers = false
}

然後 go 會在當前函數創建一個 8 位整數變量deferBits來當作 bitmap 用於標記defer,每一位標記一個,8 位整數uint8最多表示 8 個,如果對應位為 1,那麼對應的開放編碼優化後的defer就會在函數要返回時執行。

Golang學習網由www.golangdev.cn整理維護