ちりもつもればミルキーウェイ

好奇心に可処分時間が奪われる

Go の panic / defer / recover がどんな雰囲気で動いてるか知りたい part 1

はじめに

ある日無邪気に以下みたいなコードを書いた。(これは意図通りに動きません)

https://go.dev/play/p/BHPcDjPUOco

package main

import "fmt"

func main() {
    defer func() {
        recoverFunc()
    }()
    panic("panic panic panic")
}

func recoverFunc() {
    r := recover()
    // r に対してなんかいい感じの recover 処理
    fmt.Println(r)
}

これを実行するとわかるんですが recover されずに panic で終了します。

1hop あるけど一応 defer ブロックのなかで recover が呼ばれるので、ギリ動きそうな気もするんですが動きません。

たぶん仕組み的に call stack にいい感じに積んで後から取り出して〜みたいなことやってそうで、そこに関連していそうな香りがしますね。
ただぼくは panic / defer / recover の仕組みについてあまりちゃんと理解していなかったので、これを機に調べようと思った次第です。

適当に go tool objdump して雰囲気をみる

困ったら go tool objdump マンなのでとくに何も考えずに試してみます。go1.23.2 です。

挙動の確認が目的なので、以下のうまく recover できるコードで試してみる

package main

func main() {
    defer func() {
        if r := recover(); r != nil {
            // 依存ふえると objdump 読みづらくなるので builtin func つかう
            println(r.(string))
        }
    }()
    panic("panic occurred")
}

link とかされると色々くっついてきて面倒なので $ go tool compile main.go して main.o を得る。そいつをみてみるぞ〜

$ go tool compile main.go
$ go tool objdump main.o

TEXT main.main(SB) /Users/yuya.okumura/go/src/github.com/convto/playground/main.go
  main.go:3     0xe7e           f9400b90        MOVD 16(R28), R16   [0:0]R_USEIFACE:type:string 
  main.go:3     0xe82           eb3063ff        CMP R16, RSP        
  main.go:3     0xe86           540002a9        BLS 21(PC)      
  main.go:3     0xe8a           f81d0ffe        MOVD.W R30, -48(RSP)    
  main.go:3     0xe8e           f81f83fd        MOVD R29, -8(RSP)   
  main.go:3     0xe92           d10023fd        SUB $8, RSP, R29    
  main.go:3     0xe96           f90013ff        MOVD ZR, 32(RSP)    
  main.go:3     0xe9a           39007fff        MOVB ZR, 31(RSP)    
  main.go:4     0xe9e           90000002        ADRP 0(PC), R2      [0:8]R_ADDRARM64:main.main.func1·f 
  main.go:4     0xea2           91000042        ADD $0, R2, R2      
  main.go:4     0xea6           f90013e2        MOVD R2, 32(RSP)    
  main.go:4     0xeaa           b24003e2        ORR $1, ZR, R2      
  main.go:4     0xeae           39007fe2        MOVB R2, 31(RSP)    
  main.go:9     0xeb2           90000000        ADRP 0(PC), R0      [0:8]R_ADDRARM64:type:string    
  main.go:9     0xeb6           91000000        ADD $0, R0, R0      
  main.go:9     0xeba           90000001        ADRP 0(PC), R1      [0:8]R_ADDRARM64:main..stmp_0<1>  
  main.go:9     0xebe           91000021        ADD $0, R1, R1      
  main.go:9     0xec2           94000000        CALL 0(PC)      [0:4]R_CALLARM64:runtime.gopanic<1>   
  main.go:9     0xec6           d503201f        NOOP            
  main.go:9     0xeca           94000000        CALL 0(PC)      [0:4]R_CALLARM64:runtime.deferreturn<1>   
  main.go:9     0xece           a97ffbfd        LDP -8(RSP), (R29, R30) 
  main.go:9     0xed2           9100c3ff        ADD $48, RSP, RSP   
  main.go:9     0xed6           d65f03c0        RET         
  main.go:3     0xeda           aa1e03e3        MOVD R30, R3        
  main.go:3     0xede           94000000        CALL 0(PC)      [0:4]R_CALLARM64:runtime.morestack_noctxt   
  main.go:3     0xee2           17ffffe7        JMP main.main(SB)   
  main.go:3     0xee6           00000000        ?           
  main.go:3     0xeea           00000000        ?           

TEXT main.main.func1(SB) /Users/yuya.okumura/go/src/github.com/convto/playground/main.go
  main.go:4     0xeee           f9400b90        MOVD 16(R28), R16   
  main.go:4     0xef2           eb3063ff        CMP R16, RSP        
  main.go:4     0xef6           540003c9        BLS 30(PC)      
  main.go:4     0xefa           f81c0ffe        MOVD.W R30, -64(RSP)    
  main.go:4     0xefe           f81f83fd        MOVD R29, -8(RSP)   
  main.go:4     0xf02           d10023fd        SUB $8, RSP, R29    
  main.go:5     0xf06           910103e1        ADD $64, RSP, R1    
  main.go:5     0xf0a           91002020        ADD $8, R1, R0      
  main.go:5     0xf0e           94000000        CALL 0(PC)      [0:4]R_CALLARM64:runtime.gorecover<1> 
  main.go:5     0xf12           b40001e0        CBZ R0, 15(PC)      
  main.go:6     0xf16           90000003        ADRP 0(PC), R3      [0:8]R_ADDRARM64:type:string    
  main.go:6     0xf1a           91000063        ADD $0, R3, R3      
  main.go:6     0xf1e           eb03001f        CMP R3, R0      
  main.go:6     0xf22           540001c1        BNE 14(PC)      
  main.go:6     0xf26           f9400020        MOVD (R1), R0       
  main.go:6     0xf2a           f9001be0        MOVD R0, 48(RSP)    
  main.go:6     0xf2e           f9400421        MOVD 8(R1), R1      
  main.go:6     0xf32           f90017e1        MOVD R1, 40(RSP)    
  main.go:6     0xf36           94000000        CALL 0(PC)      [0:4]R_CALLARM64:runtime.printlock<1> 
  main.go:6     0xf3a           f9401be0        MOVD 48(RSP), R0    
  main.go:6     0xf3e           f94017e1        MOVD 40(RSP), R1    
  main.go:6     0xf42           94000000        CALL 0(PC)      [0:4]R_CALLARM64:runtime.printstring<1>   
  main.go:6     0xf46           94000000        CALL 0(PC)      [0:4]R_CALLARM64:runtime.printnl<1>   
  main.go:6     0xf4a           94000000        CALL 0(PC)      [0:4]R_CALLARM64:runtime.printunlock<1>   
  main.go:8     0xf4e           a97ffbfd        LDP -8(RSP), (R29, R30) 
  main.go:8     0xf52           910103ff        ADD $64, RSP, RSP   
  main.go:8     0xf56           d65f03c0        RET         
  main.go:6     0xf5a           aa0303e1        MOVD R3, R1     
  main.go:6     0xf5e           90000002        ADRP 0(PC), R2      [0:8]R_ADDRARM64:type:interface {}  
  main.go:6     0xf62           91000042        ADD $0, R2, R2      
  main.go:6     0xf66           94000000        CALL 0(PC)      [0:4]R_CALLARM64:runtime.panicdottypeE<1> 
  main.go:6     0xf6a           d503201f        NOOP            
  main.go:4     0xf6e           aa1e03e3        MOVD R30, R3        
  main.go:4     0xf72           94000000        CALL 0(PC)      [0:4]R_CALLARM64:runtime.morestack_noctxt   
  main.go:4     0xf76           17ffffde        JMP main.main.func1(SB) 
  main.go:4     0xf7a           00000000        ?           

みたかんじ main.main(SB) が main 関数で、main.main.func1(SB) が defer に登録してる関数ぽい!

ほんで main の最後で runtime.deferreturn が呼ばれてることが確認できる。多分こいつが defer stack をいいかんじに捌いたりしていそう。

defer に登録されてる関数の中でも runtime.gorecover やら runtime.panicdottypeE (名前が直球すぎてちょっと笑える) など呼ばれていて、このへんを足がかりに読んでいけばなんとかなりそう。

defereturn みる

とりあえず deferreturn から

https://github.com/golang/go/blob/go1.23.2/src/runtime/panic.go#L592-L607

// deferreturn runs deferred functions for the caller's frame.
// The compiler inserts a call to this at the end of any
// function which calls defer.
func deferreturn() {
    var p _panic
    p.deferreturn = true

    p.start(getcallerpc(), unsafe.Pointer(getcallersp()))
    for {
        fn, ok := p.nextDefer()
        if !ok {
            break
        }
        fn()
    }
}

戻す場所のプログラムカウンタとスタックポイントだけ覚えといて、あとは _panic に登録されてる defer 処理を順にぶん回すみたいな処理に見える。実装みたかんじも _panic 構造体つかっていろいろやってそうで panic / defer / recover はセットでうまいこと利用できるようにすること前提みたいな作りになっているのが面白かった。

この _panic とかいうやつの正体がわかればいろいろ理解できそう。

https://github.com/golang/go/blob/go1.23.2/src/runtime/runtime2.go#L1005-L1038

// A _panic holds information about an active panic.
//
// A _panic value must only ever live on the stack.
//
// The argp and link fields are stack pointers, but don't need special
// handling during stack growth: because they are pointer-typed and
// _panic values only live on the stack, regular stack pointer
// adjustment takes care of them.
type _panic struct {
    argp unsafe.Pointer // pointer to arguments of deferred call run during panic; cannot move - known to liblink
    arg  any            // argument to panic
    link *_panic        // link to earlier panic

    // startPC and startSP track where _panic.start was called.
    startPC uintptr
    startSP unsafe.Pointer

    // The current stack frame that we're running deferred calls for.
    sp unsafe.Pointer
    lr uintptr
    fp unsafe.Pointer

    // retpc stores the PC where the panic should jump back to, if the
    // function last returned by _panic.next() recovers the panic.
    retpc uintptr

    // Extra state for handling open-coded defers.
    deferBitsPtr *uint8
    slotsPtr     unsafe.Pointer

    recovered   bool // whether this panic has been recovered
    goexit      bool
    deferreturn bool
}

なんか panic のときにいろいろ詰められてそうで、同一スタックでのみ生存するやーつという雰囲気がある。
読み進めた感じ defer 処理とかもこいつを使って行っていそう。

runtime.gopanic みる

https://github.com/golang/go/blob/go1.23.2/src/runtime/panic.go#L722-L806

ぱっと見重要そうなのはこのへん

func gopanic(e any) {
    /* ~~ 略 ~~ */

    var p _panic
    p.arg = e

    runningPanicDefers.Add(1)

    p.start(getcallerpc(), unsafe.Pointer(getcallersp()))
    for {
        fn, ok := p.nextDefer()
        if !ok {
            break
        }
        fn()
    }

    /* ~~ 略 ~~ */

    // ran out of deferred calls - old-school panic now
    // Because it is unsafe to call arbitrary user code after freezing
    // the world, we call preprintpanics to invoke all necessary Error
    // and String methods to prepare the panic strings before startpanic.
    preprintpanics(&p)

    fatalpanic(&p)   // should not return
    *(*int)(nil) = 0 // not reached
}

panic 起きたら入力を突っ込んで start, runningPanicDefers を increment あたりが重要そう。みためは deferreturn と似てる。

runtime.gorecover みる

こいつは goroutine の panic を取り出して recovered 立てたり panic 引数取り出したりするだけっぽい。簡単

https://github.com/golang/go/blob/go1.23.2/src/runtime/panic.go#L1000-L1022

// The implementation of the predeclared function recover.
// Cannot split the stack because it needs to reliably
// find the stack segment of its caller.
//
// TODO(rsc): Once we commit to CopyStackAlways,
// this doesn't need to be nosplit.
//
//go:nosplit
func gorecover(argp uintptr) any {
    // Must be in a function running as part of a deferred call during the panic.
    // Must be called from the topmost function of the call
    // (the function used in the defer statement).
    // p.argp is the argument pointer of that topmost deferred function call.
    // Compare against argp reported by caller.
    // If they match, the caller is the one who can recover.
    gp := getg()
    p := gp._panic
    if p != nil && !p.goexit && !p.recovered && argp == uintptr(p.argp) {
        p.recovered = true
        return p.arg
    }
    return nil
}

コメントにもあるけど、ちゃんと recover されるにはいくつか条件があって

  • パニック中に defer の一部として実行されている関数内にある必要あり
  • defer func の最上位関数から直接呼びだし必要

てことらしい。冒頭の例が動かんのはこの制約によるってことやな。直接呼び出しではないから動かない。

ほいで recovered たってるときは p.nextDefer() の中でいい感じにリカバリ処理がよばれるぽい

https://github.com/golang/go/blob/go1.23.2/src/runtime/panic.go#L852-L868

// nextDefer returns the next deferred function to invoke, if any.
//
// Note: The "ok bool" result is necessary to correctly handle when
// the deferred function itself was nil (e.g., "defer (func())(nil)").
func (p *_panic) nextDefer() (func(), bool) {
    gp := getg()

    if !p.deferreturn {
        if gp._panic != p {
            throw("bad panic stack")
        }

        if p.recovered {
            mcall(recovery) // does not return
            throw("recovery failed")
        }
    }

(recovery はスタックを書き換えてしまう処理を含んでいるので、正常にリカバリできた場合は制御がここに戻ってこない。逆に戻ってきたら問題なんで throw する感じになってる)

recovery func の中身はまだちゃんと見てないけど、recover されてたら panic link をたどって解除してまわる & スタック操作してよさげな場所に戻るような処理が書いてある

https://github.com/golang/go/blob/go1.23.2/src/runtime/panic.go#L1102-L1220

_panic の機能をさっくり見る

panic / defer / recover ともに _panic の struct を中心に機能がデザインされているので、こいつの挙動が概ねわかればざっくり理解したといって良いはず!

_panic のいくつかの主要処理を見ていく

start

current goroutine の panic を取り出して p.link に入れたり必要な初期化をする

https://github.com/golang/go/blob/go1.23.2/src/runtime/panic.go#L808-L850

nextDefer

GoExit() gopanic() deferreturn で呼ばれる、次実行すべき遅延関数を返してくれるくん。利用され方はこいつが true な限り for して fn() みたいな感じ。

https://github.com/golang/go/blob/go1.23.2/src/runtime/panic.go#L852-L926

start で初期化したあと評価すべき defer func があったらクルクル回す、という感じかな。

多分こいつが高機能で、recover への分岐とかいろいろやっているが全てを読みきれていない...

雰囲気全体像

defer とか panic が連結リストになってるの面白〜そんで全員 _panic struct で表現されてるのも面白〜

以下みたいな流れになっていそう

  1. panic() すると runtime.gopanic される。ここでは _panic.arg に引数をキャプチャしつつ panic を投げる
  2. recover() すると current goroutine の _panic.recovered を立てて値を取り出す
  3. nextDefer のタイミングで recovered だったらスタックを書き換えて処理を安全に継続できる位置まで吹っ飛ばす
    • ここでスタックを取り出したり操作したりする都合で、defer に登録する関数の最上位に recover があることを要求する

宿題

外観はおおよそ理解したけど、実行時に積まれてる pc, sp, fp のイメージがまだ湧いてない!

頑張ってデバッグ埋め込むなり go tool objdump した結果をちゃんと読むなりすればもうちょいわかりそうなので、それは part 2 への宿題とします