はじめに
ある日無邪気に以下みたいなコードを書いた。(これは意図通りに動きません)
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 で表現されてるのも面白〜
以下みたいな流れになっていそう
panic()するとruntime.gopanicされる。ここでは_panic.argに引数をキャプチャしつつ panic を投げるrecover()すると current goroutine の_panic.recoveredを立てて値を取り出すnextDeferのタイミングで recovered だったらスタックを書き換えて処理を安全に継続できる位置まで吹っ飛ばす- ここでスタックを取り出したり操作したりする都合で、defer に登録する関数の最上位に recover があることを要求する
宿題
外観はおおよそ理解したけど、実行時に積まれてる pc, sp, fp のイメージがまだ湧いてない!
頑張ってデバッグ埋め込むなり go tool objdump した結果をちゃんと読むなりすればもうちょいわかりそうなので、それは part 2 への宿題とします