Leapcell:Golangホスティング、非同期タスク、およびRedisの次世代サーバレスプラットフォーム
Go言語におけるpanicとrecoverキーワードの詳細解説
Go言語には、しばしばペアで登場する2つのキーワードがあります — panicとrecoverです。この2つのキーワードはdeferと密接に関係しており、どちらもGo言語の組み込み関数であり、互補的な機能を提供しています。
1. panicとrecoverの基本機能
- panic: これはプログラムの制御フローを変更することができます。panicを呼び出した後、現在の関数の残りのコードの実行は即座に停止され、現在のGoroutineにおいて呼び出し元のdeferが再帰的に実行されます。
- recover: これはpanicによって引き起こされるプログラムのクラッシュを止めることができます。これはdefer内でのみ有効な関数であり、他のスコープで呼び出しても何の効果もありません。
2. panicとrecoverを使用する際の現象
(1) panicは現在のGoroutineのdeferのみをトリガーする
次のコードはこの現象を示しています:
func main() {
defer println("in main")
go func() {
defer println("in goroutine")
panic("")
}()
time.Sleep(1 * time.Second)
}
実行結果は以下の通りです:
$ go run main.go
in goroutine
panic:
...
このコードを実行すると、main関数内のdefer文が実行されず、現在のGoroutine内のdeferのみが実行されることがわかります。deferキーワードに対応するruntime.deferprocは、延期実行する呼び出し関数を呼び出し元が属するGoroutineと関連付けるため、プログラムがクラッシュしたときには、現在のGoroutineの延期実行関数のみが呼び出されます。
(2) recoverはdefer内で呼び出されたときのみ有効である
次のコードはこの特徴を反映しています:
func main() {
defer fmt.Println("in main")
if err := recover(); err != nil {
fmt.Println(err)
}
panic("unknown err")
}
実行結果は以下の通りです:
$ go run main.go
in main
panic: unknown err
goroutine 1 [running]:
main.main()
...
exit status 2
このプロセスを詳細に分析すると、recoverはpanicが発生した後に呼び出されたときにのみ有効になることがわかります。しかし、上記の制御フローでは、panicの前にrecoverが呼び出されており、有効になる条件を満たしていません。したがって、recoverキーワードはdefer内で使用する必要があります。
(3) panicはdefer内で複数回のネスト呼び出しを許容する
次のコードは、defer関数内でpanicを複数回呼び出す方法を示しています:
func main() {
defer fmt.Println("in main")
defer func() {
defer func() {
panic("panic again and again")
}()
panic("panic again")
}()
panic("panic once")
}
実行結果は以下の通りです:
$ go run main.go
in main
panic: panic once
panic: panic again
panic: panic again and again
goroutine 1 [running]:
...
exit status 2
上記のプログラムの出力結果から、プログラム内でpanicを複数回呼び出しても、defer関数の正常な実行には影響しないことがわかります。したがって、一般的にdeferを終了処理に使用することは安全です。
3. panicのデータ構造
Go言語のソースコードにおけるpanicキーワードは、データ構造runtime._panicによって表されます。panicが呼び出されるたびに、以下のようなデータ構造が作成され、関連する情報が格納されます:
type _panic struct {
argp unsafe.Pointer
arg interface{}
link *_panic
recovered bool
aborted bool
pc uintptr
sp unsafe.Pointer
goexit bool
}
- argp: これはdeferが呼び出されたときのパラメータへのポインタです。
- arg: これはpanicが呼び出されたときに渡されるパラメータです。
- link: これは以前に呼び出されたruntime._panic構造体を指します。
- recovered: これは現在のruntime._panicがrecoverによって回復されたかどうかを示します。
- aborted: これは現在のpanicが強制的に終了されたかどうかを示します。
データ構造内のlinkフィールドから、panic関数は連続して複数回呼び出されることができ、それらはlinkを通じて連結リストを形成することが推測できます。
構造体内のpc、sp、goexitの3つのフィールドはすべて、runtime.Goexitによってもたらされる問題を修正するために導入されています。runtime.Goexitは、この関数を呼び出したGoroutineのみを終了させ、他のGoroutineには影響を与えません。しかし、この関数はdefer内のpanicとrecoverによってキャンセルされる可能性があります。これら3つのフィールドの導入は、この関数が必ず有効になるようにするためです。
4. プログラムクラッシュの原理
コンパイラはpanicキーワードをruntime.gopanicに変換します。この関数の実行プロセスには以下のステップが含まれます:
- 新しいruntime._panicを作成し、それが属するGoroutineの_panic連結リストの先頭に追加します。
- 現在のGoroutineの_defer連結リストからruntime._deferを繰り返し取得し、runtime.reflectcallを呼び出して延期実行する呼び出し関数を実行します。
- runtime.fatalpanicを呼び出して、全体のプログラムを中止します。
func gopanic(e interface{}) {
gp := getg()
...
var p _panic
p.arg = e
p.link = gp._panic
gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))
for {
d := gp._defer
if d == nil {
break
}
d._panic = (*_panic)(noescape(unsafe.Pointer(&p)))
reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz))
d._panic = nil
d.fn = nil
gp._defer = d.link
freedefer(d)
if p.recovered {
...
}
}
fatalpanic(gp._panic)
*(*int)(nil) = 0
}
上記の関数では、3つの比較的重要なコード部分が省略されています:
- プログラムを回復するためのrecoverブランチ内のコード。
- インライン展開によってdefer呼び出しのパフォーマンスを最適化するためのコード。
- runtime.Goexitの異常な状況を修正するためのコード。
バージョン1.14では、Go言語はruntime: ensure that Goexit cannot be aborted by a recursive panic/recoverの提出を通じて、再帰的なpanicとrecoverとruntime.Goexitの間の競合を解決しました。
runtime.fatalpanicは、回復不可能なプログラムクラッシュを実装しています。プログラムを中止する前に、runtime.printpanicsを通じてすべてのpanicメッセージと呼び出し時に渡されたパラメータを出力します:
func fatalpanic(msgs *_panic) {
pc := getcallerpc()
sp := getcallersp()
gp := getg()
if startpanic_m() && msgs != nil {
atomic.Xadd(&runningPanicDefers, -1)
printpanics(msgs)
}
if dopanic_m(gp, pc, sp) {
crash()
}
exit(2)
}
クラッシュメッセージを出力した後、runtime.exitを呼び出して現在のプログラムを終了し、エラーコード2を返します。プログラムの正常な終了もruntime.exitを通じて実装されています。
5. クラッシュ回復の原理
コンパイラはrecoverキーワードをruntime.gorecoverに変換します:
func gorecover(argp uintptr) interface{} {
gp := getg()
p := gp._panic
if p != nil &&!p.recovered && argp == uintptr(p.argp) {
p.recovered = true
return p.arg
}
return nil
}
この関数の実装は非常にシンプルです。現在のGoroutineがpanicを呼び出していない場合、この関数は直接nilを返します。これも、非defer内で呼び出されたときにクラッシュ回復が失敗する理由です。通常の状況では、これはruntime._panicのrecoveredフィールドを修正し、プログラムの回復はruntime.gopanic関数によって処理されます:
func gopanic(e interface{}) {
...
for {
// 延期実行する呼び出し関数を実行し、これによりp.recovered = trueが設定される可能性があります
...
pc := d.pc
sp := unsafe.Pointer(d.sp)
...
if p.recovered {
gp._panic = p.link
for gp._panic != nil && gp._panic.aborted {
gp._panic = gp._panic.link
}
if gp._panic == nil {
gp.sig = 0
}
gp.sigcode0 = uintptr(sp)
gp.sigcode1 = pc
mcall(recovery)
throw("recovery failed")
}
}
...
}
上記のコードでは、deferのインライン展開最適化が省略されています。これはruntime._deferからプログラムカウンタpcとスタックポインタspを取り出し、runtime.recovery関数を呼び出してGoroutineのスケジューリングをトリガーします。スケジューリングの前に、sp、pc、および関数の戻り値を準備します:
func recovery(gp *g) {
sp := gp.sigcode0
pc := gp.sigcode1
gp.sched.sp = sp
gp.sched.pc = pc
gp.sched.lr = 0
gp.sched.ret = 1
gogo(&gp.sched)
}
deferキーワードが呼び出されたとき、呼び出し時のスタックポインタspとプログラムカウンタpcはすでにruntime._defer構造体に格納されています。ここでのruntime.gogo関数は、deferキーワードが呼び出された位置に戻ります。
runtime.recoveryは、スケジューリングプロセス中に関数の戻り値を1に設定します。runtime.deferprocのコメントから、runtime.deferproc関数の戻り値が1のとき、コンパイラによって生成されるコードは直接呼び出し元関数の戻り前にジャンプし、runtime.deferreturnを実行することがわかります:
func deferproc(siz int32, fn *funcval) {
...
return0()
}
runtime.deferreturn関数にジャンプした後、プログラムはpanicから回復し、通常のロジックを実行し、runtime.gorecover関数はまた、runtime._panic構造体からpanicを呼び出したときに渡されたargパラメータを取り出し、呼び出し元に返すことができます。
6. まとめ
プログラムのクラッシュと回復のプロセスを分析することはかなり難しく、コードも特に理解しにくいものです。ここで、プログラムのクラッシュと回復のプロセスを簡単にまとめます:
-
コンパイラはキーワードの変換作業を担当します。panicとrecoverをそれぞれruntime.gopanicとruntime.gorecoverに変換し、deferをruntime.deferproc関数に変換し、deferを呼び出す関数の末尾でruntime.deferreturn関数を呼び出します。
-
実行中にruntime.gopanicメソッドに遭遇すると、Goroutineの連結リストから順にruntime._defer構造体を取り出して実行します。
-
延期実行関数を呼び出すときにruntime.gorecoverに遭遇すると、_panic.recoveredをtrueにマークし、panicのパラメータを返します。
-
この呼び出しが終了した後、runtime.gopanicはruntime._defer構造体からプログラムカウンタpcとスタックポインタspを取り出し、runtime.recovery関数を呼び出してプログラ
-
runtime.recoveryは渡されたpcとspに基づいてruntime.deferprocに戻ります。
-
コンパイラによって自動生成されたコードは、runtime.deferprocの戻り値が0でないことに気づきます。このとき、それはruntime.deferreturnに戻り、通常の実行フローに復帰します。
-
runtime.gorecoverに遭遇しない場合、それは順番にすべてのruntime._deferを走査し、最後にruntime.fatalpanicを呼び出してプログラムを中止し、panicのパラメータを表示し、エラーコード2を返します。
この分析プロセスは、言語の底層に関する多くの知識を含んでおり、ソースコードも比較的に難解で読みにくいものです。それは通常とは異なる制御フローであり、プログラムカウンタを使って前後にジャンプします。しかし、それでもプログラムの実行フローを理解する上で非常に役立ちます。
Leapcell:Golangホスティング、非同期タスク、およびRedisの次世代サーバレスプラットフォーム
最後に、最適なデプロイメントプラットフォームをおすすめします:Leapcell
1. 多言語対応
- JavaScript、Python、Go、またはRustで開発できます。
2. 無料で無制限のプロジェクトをデプロイ
- 使用分のみ課金 — リクエストがなければ、課金はありません。
3. 抜群のコスト効率
- 使った分だけ課金で、アイドル時には課金されません。
- 例:平均応答時間60msで、$25で694万件のリクエストをサポートできます。
4. ストリームライン化された開発者体験
- 直感的なUIで簡単にセットアップできます。
- 完全自動化されたCI/CDパイプラインとGitOpsの統合。
- リアルタイムのメトリクスとロギングにより、実行可能なインサイトを得られます。
5. 簡単なスケーラビリティと高パフォーマンス
- 高い同時実行性を簡単に処理できる自動スケーリング。
- オペレーション上のオーバーヘッドはゼロ — 構築に集中できます。
LeapcellのTwitter:https://x.com/LeapcellHQ