💋

Goのdeferについて

2020/12/11に公開
$ go version
go version 1.12

この記事はGo: How Does defer Statement Work?の翻訳記事です。

defer文は関数のreturn前にコードを実行する便利な手段です。

次のようにdefer文はLIFO(last-in-first-out)のスタック実装になっています。

つまり、後に定義されたdeferメソッド(defer文で実行される関数)のほうが先に実行されます。

func main() {
   defer func() {
      println(`defer 1`)
   }()
   defer func() {
      println(`defer 2`)
   }()
}
defer 2 <- Last in, first to go out
defer 1

内部実装やもっと複雑なコードのときどうなるのかみていきましょう。

内部実装

GoはLIFOをリンクリストを使って実装しています。実際にdefer構造体の中には次のdefer構造体へのリンクを表すlinkフィールドが含まれています。

type _defer struct {
   siz     int32
   started bool
   sp      uintptr
   pc      uintptr
   fn      *funcval
   _panic  *_panic
   link    *_defer // next deferred function to be executed
}

新しいdeferメソッドが作られた時、それは現在のGoroutineにアタッチされ、前のdeferメソッドを次に実行するようにリンクします。

func newdefer(siz int32) *_defer {
   var d *_defer
   gp := getg() // get the current goroutine
   
   // ...
   
   // deferred list is now attached to the new _defer struct
   d.link = gp._defer 
   gp._defer = d // the new struct is now the first to be called
   return d
}

deferメソッドはスタックの上から順次実行されていきます。

func deferreturn(arg0 uintptr) {
   gp := getg() // get the current goroutine
   d:= gp._defer // copy the deferred function to a variable
   if d == nil { // if there is not deferred func, just return
      return
   }

    // ...
   
   fn := d.fn // get the function to call
   d.fn = nil // reset the function
   gp._defer = d.link // attach the next one to the goroutine
   freedefer(d) // freeing the _defer struc
   jmpdefer(fn, uintptr(unsafe.Pointer(&arg0))) // call the func
}
// first deferred func
0x001d 00029 (main.go:6)   MOVL   $0, (SP)
0x0024 00036 (main.go:6)   PCDATA $2, $1
0x0024 00036 (main.go:6)   LEAQ   "".main.func1·f(SB), AX
0x002b 00043 (main.go:6)   PCDATA $2, $0
0x002b 00043 (main.go:6)   MOVQ   AX, 8(SP)
0x0030 00048 (main.go:6)   CALL   runtime.deferproc(SB)
0x0035 00053 (main.go:6)   TESTL  AX, AX
0x0037 00055 (main.go:6)   JNE    117

// second deferred func
0x0039 00057 (main.go:10)  MOVL   $0, (SP)
0x0040 00064 (main.go:10)  PCDATA $2, $1
0x0040 00064 (main.go:10)  LEAQ   "".main.func2·f(SB), AX
0x0047 00071 (main.go:10)  PCDATA $2, $0
0x0047 00071 (main.go:10)  MOVQ   AX, 8(SP)
0x004c 00076 (main.go:10)  CALL   runtime.deferproc(SB)
0x0051 00081 (main.go:10)  TESTL  AX, AX
0x0053 00083 (main.go:10)  JNE    101

// end of main func
0x0055 00085 (main.go:18)  XCHGL  AX, AX
0x0056 00086 (main.go:18)  CALL   runtime.deferreturn(SB)
0x005b 00091 (main.go:18)  MOVQ   16(SP), BP
0x0060 00096 (main.go:18)  ADDQ   $24, SP
0x0064 00100 (main.go:18)  RET
0x0065 00101 (main.go:10)  XCHGL  AX, AX
0x0066 00102 (main.go:10)  CALL   runtime.deferreturn(SB)
0x006b 00107 (main.go:10)  MOVQ   16(SP), BP
0x0070 00112 (main.go:10)  ADDQ   $24, SP
0x0074 00116 (main.go:10)  RET

deferprocが各defer文でそれぞれ呼ばれています。これは先ほど紹介した新しくメソッドをdeferメソッドとして登録する関数newdeferを内部で呼び出しています。

その後、main関数の終了時に、deferメソッドがdeferreturn関数によって次々と呼び出されていきます。

先ほど紹介した_defer構造体には_panic *_panicフィールドもありました。

これがどのような役割を果たすのか別の例を使ってみていきましょう。

deferメソッドの戻り値

deferメソッドの戻り値にアクセスする唯一の方法は、名前付き戻り値を使うことです。

deferメソッドが関数リテラルであり、deferメソッドを含む関数がそのスコープ内に名前付き戻り値を持っている場合、その名前付き戻り値が返される前に、 deferメソッドは戻り値にアクセスして修正することができます。

これだけではわかりにくいので例とともにみていきましょう。

func main() {
   fmt.Printf("with named param, x: %d\n", namedParam())
   fmt.Printf("without named param, x: %d\n", notNamedParam())
}
func namedParam() (x int) {
   x = 1
   defer func() { x = 2 }()
   return x
}

func notNamedParam() (int) {
   x := 1
   defer func() { x = 2 }()
   return x
}
$ go run main.go
with named param, x: 2
without named param, x: 1

この挙動を知っていれば、recover関数への理解ももっと深まります。詳細はこちらの記事を参照してください。

_defer構造体は_panicフィールドを持っています。このフィールドはpanic時に設定されます。

func gopanic(e interface{}) {
   [...]
   var p _panic
   [...]
   d := gp._defer // current attached defer on the goroutine
   [...]
   d._panic = (*_panic)(noescape(unsafe.Pointer(&p)))
   [...]
}

実際にgopanicはdeferメソッドを呼び出す前にpanicになった場合に呼び出されています。

0x0067 00103 (main.go:21)   CALL   runtime.gopanic(SB)
0x006c 00108 (main.go:21)  UNDEF
0x006e 00110 (main.go:16)  XCHGL  AX, AX
0x006f 00111 (main.go:16)  CALL   runtime.deferreturn(SB)

実際に名前付き戻り値を使ってpanicからrecoverする関数の例を見てみましょう。

func main() {
   fmt.Printf("error from err1: %v\n", err1())
   fmt.Printf("error from err2: %v\n", err2())
}

func err1() error {
   var err error

   defer func() {
      if r := recover(); r != nil {
         err = errors.New("recovered")
      }
   }()
   panic(`foo`)

   return err
}

func err2() (err error) {
   defer func() {
      if r := recover(); r != nil {
         err = errors.New("recovered")
      }
   }()
   panic(`foo`)

   return err
}
error from err1: <nil>
error from err2: recovered

err2ではerrが名前付き戻り値なためerr = errors.New("recovered")が反映されます。

両方を組み合わせることで、recover関数と呼び出し元に返したいエラーを正しく動作させることができます。

パフォーマンスの改善

Go1.8でdeferのパフォーマンスが改善されました。

試しに改善前(1.7)と改善後(1.8)でベンチマークを比較してみました。

name         old time/op  new time/op  delta
Defer-4      99.0ns ± 9%  52.4ns ± 5%  -47.04%  (p=0.000 n=9+10)
Defer10-4    90.6ns ±13%  45.0ns ± 3%  -50.37%  (p=0.000 n=10+10)

50%近く速くなっています!

改善の内容について詳細を知りたい人はこちらを参照ください。

メモリコピーが行われない引数なしのdeferも最適化されています。

ここでは、引数なしでdefer文を実行した場合のベンチマークを紹介します。

name     old time/op  new time/op  delta
Defer-4  51.3ns ± 3%  45.8ns ± 1%  -10.72%  (p=0.000 n=10+10)

こちらも10%ほど速くなっています。

References

Discussion