概要
goroutineを使った時のpanicのハンドリングについて調べてみたのでまとめ。
Goはgoroutineで簡単に並列処理を書けますが、エラーハンドリングをきちんとしていないと後で困ることになるという話です。
環境
- golang 1.9.2
通常
recoverがどうなるか確認します。
同じ関数内でのpanic
func main() { fmt.Println("Calling f.") f() fmt.Println("Returned normally from f.") } func f() { defer func() { if r := recover(); r != nil { fmt.Println("Recovered in f", r) } }() panic("panic") }
結果
Calling f. Recovered in f panic Returned normally from f.
問題なくrecoverできます。
子の関数内でのpanic
func main() { fmt.Println("Calling f.") f() fmt.Println("Returned normally from f.") } func f() { defer func() { if r := recover(); r != nil { fmt.Println("Recovered from", r) } }() fmt.Println("Calling g.") g() fmt.Println("Returned normally from g.") // ここは呼ばれない } func g() { panic("g panic") }
結果
Calling f. Calling g. Recovered from g panic Returned normally from f.
こちらもちゃんとrecoverされます。注意としてg()
の後の処理は実行されなくなります。
goroutineの場合
では別goroutineを用意して、そこでpanicが起きたらどうなるでしょうか。
同じ関数内でのpanic
func main() { fmt.Println("Calling f.") f() fmt.Println("Returned normally from f.") } func f() { defer func() { if r := recover(); r != nil { fmt.Println("Recovered in f", r) } }() go func() { panic("panic") }() time.Sleep(time.Second * 2) fmt.Println("f has finished") }
結果
Calling f. panic: panic goroutine 5 [running]: main.f.func2() /tmp/sandbox158058757/main.go:22 +0x40 created by main.f /tmp/sandbox158058757/main.go:21 +0x60
このようにpanicをrecoverできません。
PanicAndRecover · golang/go Wiki · GitHubの中で
A panic cannot be recovered by a different goroutine.
と書かれている通りですね。
子の関数内でのpanic
func main() { fmt.Println("Calling f.") f() fmt.Println("Returned normally from f.") } func f() { defer func() { if r := recover(); r != nil { fmt.Println("Recovered from", r) } }() fmt.Println("Calling g.") g() time.Sleep(time.Second * 2) fmt.Println("f has finished") } func g() { go func() { panic("g panic") fmt.Println("g has finished") }() }
結果
Calling f. Calling g. panic: g panic goroutine 5 [running]: main.g.func1() /tmp/sandbox500739411/main.go:30 +0x40 created by main.g /tmp/sandbox500739411/main.go:29 +0x40
こちらもNGです。親子の関係ならもしかしたら、と感じそうですが、別goroutineになった時点でdeferは機能しなくなります。
対応方法
エラーハンドリングをきちんとする
そもそもGoでは実装側がエラーハンドリングをきちんとする前提の言語であるため、基本的にpanicが起きる事はありません。
「エラーのハンドリングが冗長だから〜」という理由で安易に削るのは止めましょう。
goroutineの中にrecoverを埋め込む
↑の対応は当然である一方、sliceの処理であったり、外部ライブラリの使用によってpanicが起きる事は可能性としてあります。
その上でgoroutineを使う場合はその関数を実行する時にrecoverを埋め込む形で対応が可能です。
func main() { fmt.Println("Calling f.") f() fmt.Println("Returned normally from f.") } func f() { go func() { // recoverをgoroutineの内部に埋め込む defer func() { if r := recover(); r != nil { fmt.Println("Recovered from", r) } }() fmt.Println("Calling g.") // 実行したい関数 g() }() time.Sleep(time.Second * 1) } func g() { panic("g panic") }
以下のように汎用的な関数を用意するのも1つです。
func main() { recoverer(f) time.Sleep(time.Second * 1) } func recoverer(f func()) { fmt.Println("Calling argument f.") go func() { defer func() { if r := recover(); r != nil { fmt.Println("Recovered from", r) } }() f() }() } func f() { panic("f panic") }
例えば有名な
github.com
というフレームワークでは、gin.Recovery()
というミドルウェアを用意しており、各リクエストをgoroutineで捌く際にdeferでrecover()
を埋め込むことで、そのリクエストでpanicが起きてもmain関数が死なないようにしています。
※もちろん前述の通り、リクエスト内で更にgoroutineを増やしてそこでpanicした場合はハンドリングできません。
まとめ
別goroutineの場合はpanicをrecoverできなくなるため、捕捉できずmain関数が落ちることもありえるので注意しましょう。