Skip to content

defer

defer は Go の日常開発で非常に頻繁に出現するキーワードで、FIFO 方式で 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 は次のリンクされた defer を表し、sppc は呼び出し元の関数情報を記録し、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))
    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 を実行し、runtime.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 をマークします。各ビットが 1 つの defer をマークし、8 ビット整数 uint8 は最大 8 個を表せます。対応するビットが 1 の場合、対応するオープンコーディング最適化後の defer が関数の返却時に実行されます。

Golang学习网由www.golangdev.cn整理维护