Go における recover() 関数は、パニックによるアンワインディングプロセスの一部として実行されている遅延関数内で直接呼び出される場合にのみ、パニックを停止します。ヘルパー関数内で recover() を呼び出すと、そのヘルパー関数が遅延閉鎖によって呼び出されたものであるため、ランタイムは現在のゴルーチンの実行フレームがアクティブなパニックに関連付けられたトップレベルの遅延フレームでないことを検出します。
// このパターンは回復に失敗します: func handlePanic() { if r := recover(); r != nil { log.Println("Recovered:", r) } } func risky() { defer handlePanic() // recover() はここで nil を返します panic("error") }
ランタイムは、このチェックを g.recover フィールドを介して維持します。これは、回復権限を持つ遅延関数のスタックフレームポインタを保存します。recover() が実行されると、現在のスタックポインタとこの保存された値を比較します。一致しない場合、recover() は nil を返し、パニックはスタックを上に伝播し続けます。このアーキテクチャ制約により、回復ロジックが明示的かつ局所化され、深く入れ子になったヘルパー関数がパニックを間違って飲み込むのを防ぎます。
数千の同時 goroutines を処理する高スループットのマイクロサービスで、誤ったリクエストによるサーバークラッシュを防ぐための中央集権的なパニック回復メカニズムを実装しました。最初の実装は、ログ記録とメトリクスをカプセル化したユーティリティ関数 SafeRecover() を使用しており、開発者はハンドラーの開始時に defer SafeRecover() を使用してこの関数を遅延しました。しかし、リクエストハンドラーでのゼロ除算エラーに関するプロダクションインシデント中、明らかな回復メカニズムにもかかわらずサービスがクラッシュし、パニックがヘルパー内でネストされていて直接呼び出されていなかったためにインターセプトされていないことが示されました。
最初に、開発者が毎回関数エントリポイントで defer func() { if r := recover(); r != nil { ... } }() を手動で書くことを義務付けることを検討しました。このアプローチは、recover() に直接アクセスでき、ランタイムの遵守を確保しましたが、大量のボイラープレートが必要になり、人間の一貫性に依存し、大規模チームではエラーを引き起こしやすく、コードレビュー中の強制が困難になりました。
2 番目のアプローチは、SafeRecover() を変更してクロージャを引数として受け取り、その渡された関数内で recover() を実行することを含んでいました。ただし、これは技術的には recover() を遅延フレームに置く要件を満たしましたが、ハンドラーはコールバックとして回復ロジックを渡す必要があるため、API は不格好になり、制御フローが複雑になり、可読性が低下し、不必要な間接性が追加されました。
最終的に、私たちは第三のアプローチを選択しました。HTTP ルーターのレベルでミドルウェアラッパーを実装し、defer func() { if r := recover(); r != nil { logAndMetrics(r) } }() をミドルウェアの遅延閉鎖内で直接実行しました。このソリューションは、正しいスタック深度で recover() を呼び出すことを保証し、関心の分離を維持しました。その結果、次回の混沌テスト中に 100% のパニックインターセプト率を達成し、次の四半期中にクラッシュループがゼロになりました。
なぜ recover() が、アクティブなパニックがない場合でも遅延関数の外部から呼び出されると nil を返すのか?
遅延実行コンテキストの外では、recover() は現在のゴルーチンのパニック状態を照会し、アクティブなパニックレコードがないことを見つけるため、即座に nil を返します。この微妙な点は、recover() が現在の関数が遅延スタックのアンワインディングの一部として実行されているかどうかをチェックすることであり、プログラム内のどこかにパニックが存在するかどうかだけではありません。通常の実行パスから呼び出されると、ランタイムはゴルーチン構造体の _panic フィールドが nil であることを見つけ、サイドエフェクトなしで nil を返し、通常のエラーハンドリングが誤って回復メカニズムをトリガーするのを防ぎます。
同じゴルーチン内の複数の遅延関数が recover() を呼び出す場合、なぜ最初のものだけが成功するのか?
パニックが発生すると、Go は遅延関数を LIFO 順で実行し、recover() を呼び出す最初の遅延関数がゴルーチンの内部 _panic リンクリストからアクティブなパニック状態を原子的にクリアします。その後の遅延関数が recover() を呼び出すと、パニックがすでに解決されているため、元のパニック値の代わりに nil を受け取ります。この設計は、最も内側の回復スコープが優先される、決定論的なパニック処理を提供し、スタックが通常の実行を再開したときにエラーの伝播ロジックが混乱する可能性のある冗長な回復試行を防ぎます。
panic(nil) は panic("nil") や panic(0) とどのように異なって動作し、なぜ Go 1.21 でこの動作が変更されたのか?
Go 1.21 より前は、panic(nil) を呼び出すと、ランタイムはそのパニック値を特別なセンチネルとして扱い、recover() が nil として返すことにより、パニックを処理するための recover() 呼び出しと区別できない状態を作成し、有害な曖昧さを生み出していました。Go 1.21 以降、ランタイムは nil パニック値を "runtime error: panic called with nil argument" という文字列を含む非 nil ランタイムエラーに自動的に変換し、recover() がパニックを成功裏にインターセプトしたときには常に非 nil 値を返すことを保証します。この変更により、エラーハンドリングコードの曖昧さが排除され、開発者は if r := recover(); r != nil を自信を持ってチェックできるようになり、返された nil が実際にパニックが発生しなかったことを示すことを確認できるようになりました。