[インデックス 19479] ファイルの概要
このコミットは、Goランタイムにおけるトレースバック処理の正確性テストに関する修正です。特に、defer
スタックとpanic
スタックの処理において、panic
スタックに「残余のエントリ」が存在する場合でもそれが正当な状況であると認識し、誤ったエラー(throw("traceback has leftover defers or panics")
)が発生しないようにする変更が含まれています。
コミット
commit bcfe519d58e0ef99faad76b7d13108cd48d75e32
Author: Russ Cox <rsc@golang.org>
Date: Sun Jun 1 13:57:46 2014 -0400
runtime: fix correctness test at end of traceback
We were requiring that the defer stack and the panic stack
be completely processed, thinking that if any were left over
the stack scan and the defer stack/panic stack must be out
of sync. It turns out that the panic stack may well have
leftover entries in some situations, and that's okay.
Fixes #8132.
LGTM=minux, r
R=golang-codereviews, minux, r
CC=golang-codereviews, iant, khr
https://golang.org/cl/100900044
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/bcfe519d58e0ef99faad76b7d13108cd48d75e32
元コミット内容
Goランタイムのトレースバック処理において、defer
スタックとpanic
スタックが完全に処理されることを要求する正確性テストが存在しました。このテストは、もしこれらのスタックに何らかのエントリが残っている場合、スタックスキャンとdefer
/panic
スタックの状態が同期していないと判断し、エラーを発生させていました。しかし、実際にはpanic
スタックには特定の状況下で残余のエントリが残ることがあり、それは問題ない状態であることが判明しました。このコミットは、その誤った前提に基づくテストを修正し、panic
スタックの残余エントリを許容するように変更します。
変更の背景
この変更の背景には、Goランタイムのスタックトレースバック処理におけるdefer
とpanic
の複雑な相互作用があります。Goのdefer
は関数の終了時に実行される処理を登録し、panic
はプログラムの異常終了を引き起こします。panic
が発生すると、登録されたdefer
関数が順次実行され、最終的にrecover
によってpanic
が捕捉されない限り、プログラムは終了します。
Goのガベージコレクション(GC)やスタックの拡張(stack growth)といったランタイムの内部処理では、現在のゴルーチンのスタックを正確にスキャンし、その状態を把握する必要があります。このスキャン処理の一部として、defer
スタックとpanic
スタックの状態も確認されます。
元々の実装では、トレースバックの完了時にdefer
スタックとpanic
スタックが完全に空になっていることを「正確性の検証」としていました。これは、もしエントリが残っていれば、スタックの状態がランタイムの認識と食い違っている、つまりバグがあるという前提に基づいています。しかし、panic
が複数回発生したり、defer
関数内でさらにpanic
が発生したりするような複雑なシナリオでは、panic
スタックの構造が必ずしもフレームの順序と一致しないことがあり、結果としてトレースバック処理が完了してもpanic
スタックにエントリが残ることがありました。
特に、panic
スタック上のdefer
エントリは、通常のdefer
スタックとは異なり、フレームの順序でネストしない場合があります。これは、panic
がdefer
関数内で発生し、そのdefer
関数がさらに別のdefer
を登録するような状況で顕著になります。このような状況で、トレースバックが完了した際にpanic
スタックにエントリが残っていても、それは必ずしもランタイムのバグを示すものではなく、むしろ正常な状態であると判断されるべきでした。
この誤った「正確性テスト」が、issue #8132
として報告されたバグの原因でした。このバグは、特定のpanic
とdefer
の組み合わせによって、ランタイムが不必要にthrow("traceback has leftover defers or panics")
を発生させ、プログラムをクラッシュさせるというものでした。このコミットは、この誤った前提を修正し、より堅牢なトレースバック処理を実現することを目的としています。
前提知識の解説
このコミットを理解するためには、以下のGoランタイムの概念とC言語の知識が必要です。
-
Goランタイム (Go Runtime): Goプログラムの実行を管理する低レベルのシステム。ガベージコレクション、スケジューリング、スタック管理、
defer
/panic
/recover
の実装など、Go言語の並行性やメモリ管理の根幹を担っています。Goランタイムの多くはC言語とアセンブリ言語で記述されています。 -
ゴルーチン (Goroutine): Goにおける軽量な実行スレッド。OSのスレッドよりもはるかに軽量で、数百万のゴルーチンを同時に実行できます。各ゴルーチンは独自のスタックを持ちます。
-
スタック (Stack): 関数呼び出しの情報を格納するメモリ領域。ローカル変数、引数、戻りアドレスなどが積まれます。Goのゴルーチンは可変長のスタックを持ち、必要に応じて自動的に拡張・縮小されます。
-
トレースバック (Traceback): プログラムの実行中に、現在の関数呼び出しの連鎖(コールスタック)を遡って表示する処理。デバッグやエラー報告の際に用いられます。Goランタイムでは、ガベージコレクションやスタックの拡張時にも、スタックの状態を正確に把握するために内部的にトレースバックに似た処理が行われます。
-
defer
: Goのキーワードで、関数の実行が終了する直前に実行される関数を登録します。defer
された関数は、関数の正常終了時だけでなく、panic
が発生した場合でも実行されます。defer
関数はLIFO(後入れ先出し)の順序で実行されます。ランタイム内部では、defer
構造体のリンクリスト(defer
スタック)として管理されます。 -
panic
: Goの組み込み関数で、プログラムの異常終了を引き起こします。panic
が呼び出されると、現在の関数の実行が中断され、defer
関数が順次実行されながらコールスタックを遡ります。 -
recover
:panic
を捕捉し、プログラムの異常終了を防ぐ組み込み関数。recover
はdefer
関数内でのみ有効です。recover
が呼び出されると、panic
の状態が解除され、defer
関数が実行された後、panic
が発生した地点の次のステートメントから実行が再開されます。 -
panic
スタック: ランタイム内部でpanic
の状態を管理するためのデータ構造。panic
が発生するたびに、関連情報(どのdefer
が実行されるべきかなど)がこのスタックに積まれます。defer
スタックとは異なり、panic
スタックはpanic
の連鎖やdefer
内でのpanic
再発生といった複雑なシナリオを扱うため、その構造はより複雑になります。 -
runtime·gentraceback
: Goランタイムの内部関数で、スタックトレースバックを実行します。この関数は、デバッグ目的だけでなく、GCやスタック拡張といったランタイムの内部処理でも利用されます。 -
C言語のポインタと構造体: Goランタイムの低レベル部分はC言語で書かれているため、ポインタ操作や構造体の理解が不可欠です。特に、
defer
やpanic
の情報を保持する構造体(例:Defer
、Panic
)がリンクリストとしてどのように管理されているかを理解することが重要です。
技術的詳細
このコミットの技術的詳細は、主にsrc/pkg/runtime/traceback_arm.c
とsrc/pkg/runtime/traceback_x86.c
におけるruntime·gentraceback
関数の修正にあります。これらのファイルは、それぞれARMアーキテクチャとx86アーキテクチャにおけるトレースバック処理を実装しています。
変更の核心は、トレースバックの「正確性テスト」の条件緩和です。以前は、callback != nil && n < max && (defer != nil || panic != nil && panic->defer != nil)
という条件で、defer
スタックまたはpanic
スタックにエントリが残っている場合にthrow("traceback has leftover defers or panics")
を呼び出していましたが、これが変更されました。
新しい条件は、callback != nil && n < max && defer != nil
です。これにより、panic
スタックにエントリが残っている場合でも、それが直ちにエラーとは見なされなくなりました。
traceback_x86.c
のコードには、この変更の理由を詳細に説明する長いコメントが追加されています。以下はその要点です。
-
callback != nil
の重要性:callback != nil
の場合、runtime·gentraceback
はガベージコレクションやスタック拡張といった「正確でなければならない」コンテキストで呼び出されています。この場合、defer
スタックが完全に消費されることが要求されます。もしdefer
スタックにエントリが残っていれば、それはスタックの状態がランタイムの認識と食い違っていることを意味し、GCやスタックコピーが誤った情報に基づいて行われる可能性があるため、クラッシュさせるのが適切です。 -
panic != nil
が許容される理由:panic
スタックにエントリが残っていても問題ないのは、panic
スタック上のdefer
エントリが、通常のdefer
スタックのようにフレームの順序でネストしないためです。- 例:
frame 1 defers d1 frame 2 defers d2 frame 3 defers d3 frame 4 panics frame 4's panic starts running defers frame 5, running d3, defers d4 frame 5 panics frame 5's panic starts running defers frame 6, running d4, garbage collects frame 6, running d2, garbage collects
d4
の実行中、panic
スタックはd4 -> d3
と適切にネストしています。この場合、フレーム3は再開可能と見なされます。- しかし、
d2
の実行中、panic
スタックはd2 -> d3
と逆転しています。スタックスキャンはd2
をフレーム2にマッチさせますが、d3
はフレーム3にマッチしません。これは問題ありません。なぜなら、d2
が実行されているということは、d2
以降のすべてのdefer
が完了しており、それに対応するフレームは「デッド」(不要)だからです。d3
がフレーム3に見つからない場合、フレーム3のcontinpc
(継続PC)が0に設定されますが、これはフレーム3がデッドであることを正しく示しています。 - したがって、ウォークの終わりに
panic
スタックに残っているのは、正確に(そして唯一)デッドなフレームに対応するエントリです。この逆転は常にデッドフレームを示し、スキャンへの影響はこれらのデッドフレームを隠すことなので、スキャンは依然として正しいです。
- 例:
-
callback != nil
の再強調: この厳密なチェックは、callback != nil
の場合にのみ適用されます。これは、callback != nil
の場合にのみ、gentraceback
が「正確でなければならない」コンテキスト(GCなど)で呼び出されていることが保証されるためです。プロファイリングシグナルによるスタック収集やクラッシュ時のトレースバックなど、他の状況では、すべてが「きれいに停止」しているわけではないため、スタックウォークが完全に完了できない場合があります。そのような状況では、不完全な情報でも何もないよりはましであるため、defer
スタックが完全に消費されなくても許容されます。
この修正により、Goランタイムはpanic
とdefer
のより複雑な相互作用を正しく処理できるようになり、誤ったクラッシュを防ぎ、ランタイムの堅牢性が向上しました。
コアとなるコードの変更箇所
src/pkg/runtime/traceback_arm.c
--- a/src/pkg/runtime/traceback_arm.c
+++ b/src/pkg/runtime/traceback_arm.c
@@ -261,11 +261,20 @@ runtime·gentraceback(uintptr pc0, uintptr sp0, uintptr lr0, G *gp, int32 skip,
if(pcbuf == nil && callback == nil)
n = nprint;
- if(callback != nil && n < max && (defer != nil || panic != nil && panic->defer != nil)) {
+ // For rationale, see long comment in traceback_x86.c.
+ if(callback != nil && n < max && defer != nil) {
if(defer != nil)
runtime·printf("runtime: g%D: leftover defer argp=%p pc=%p\n", gp->goid, defer->argp, defer->pc);
- if(panic != nil && panic->defer != nil)
+ if(panic != nil)
runtime·printf("runtime: g%D: leftover panic argp=%p pc=%p\n", gp->goid, panic->defer->argp, panic->defer->pc);
+ for(defer = gp->defer; defer != nil; defer = defer->link)
+ runtime·printf("\tdefer %p argp=%p pc=%p\n", defer, defer->argp, defer->pc);
+ for(panic = gp->panic; panic != nil; panic = panic->link) {
+ runtime·printf("\tpanic %p defer %p", panic, panic->defer);
+ if(panic->defer != nil)
+ runtime·printf(" argp=%p pc=%p", panic->defer->argp, panic->defer->pc);
+ runtime·printf("\n");
+ }
runtime·throw("traceback has leftover defers or panics");
}
src/pkg/runtime/traceback_x86.c
--- a/src/pkg/runtime/traceback_x86.c
+++ b/src/pkg/runtime/traceback_x86.c
@@ -304,11 +304,68 @@ runtime·gentraceback(uintptr pc0, uintptr sp0, uintptr lr0, G *gp, int32 skip,
if(pcbuf == nil && callback == nil)
n = nprint;
- if(callback != nil && n < max && (defer != nil || panic != nil)) {
+ // If callback != nil, we're being called to gather stack information during
+ // garbage collection or stack growth. In that context, require that we used
+ // up the entire defer stack. If not, then there is a bug somewhere and the
+ // garbage collection or stack growth may not have seen the correct picture
+ // of the stack. Crash now instead of silently executing the garbage collection
+ // or stack copy incorrectly and setting up for a mysterious crash later.
+ //
+ // Note that panic != nil is okay here: there can be leftover panics,
+ // because the defers on the panic stack do not nest in frame order as
+ // they do on the defer stack. If you have:
+ //
+ // frame 1 defers d1
+ // frame 2 defers d2
+ // frame 3 defers d3
+ // frame 4 panics
+ // frame 4's panic starts running defers
+ // frame 5, running d3, defers d4
+ // frame 5 panics
+ // frame 5's panic starts running defers
+ // frame 6, running d4, garbage collects
+ // frame 6, running d2, garbage collects
+ //
+ // During the execution of d4, the panic stack is d4 -> d3, which
+ // is nested properly, and we'll treat frame 3 as resumable, because we
+ // can find d3. (And in fact frame 3 is resumable. If d4 recovers
+ // and frame 5 continues running, d3, d3 can recover and we'll
+ // resume execution in (returning from) frame 3.)
+ //
+ // During the execution of d2, however, the panic stack is d2 -> d3,
+ // which is inverted. The scan will match d2 to frame 2 but having
+ // d2 on the stack until then means it will not match d3 to frame 3.
+ // This is okay: if we're running d2, then all the defers after d2 have
+ // completed and their corresponding frames are dead. Not finding d3
+ // for frame 3 means we'll set frame 3's continpc == 0, which is correct
+ // (frame 3 is dead). At the end of the walk the panic stack can thus
+ // contain defers (d3 in this case) for dead frames. The inversion here
+ // always indicates a dead frame, and the effect of the inversion on the
+ // scan is to hide those dead frames, so the scan is still okay:
+ // what's left on the panic stack are exactly (and only) the dead frames.
+ //
+ // We require callback != nil here because only when callback != nil
+ // do we know that gentraceback is being called in a "must be correct"
+ // context as opposed to a "best effort" context. The tracebacks with
+ // callbacks only happen when everything is stopped nicely.
+ // At other times, such as when gathering a stack for a profiling signal
+ // or when printing a traceback during a crash, everything may not be
+ // stopped nicely, and the stack walk may not be able to complete.
+ // It's okay in those situations not to use up the entire defer stack:
+ // incomplete information then is still better than nothing.
+ if(callback != nil && n < max && defer != nil) {
if(defer != nil)
runtime·printf("runtime: g%D: leftover defer argp=%p pc=%p\n", gp->goid, defer->argp, defer->pc);
- if(panic != nil)
+ if(panic != nil)
runtime·printf("runtime: g%D: leftover panic argp=%p pc=%p\n", gp->goid, panic->defer->argp, panic->defer->pc);
+ for(defer = gp->defer; defer != nil; defer = defer->link)
+ runtime·printf("\tdefer %p argp=%p pc=%p\n", defer, defer->argp, defer->pc);
+ for(panic = gp->panic; panic != nil; panic = panic->link) {
+ runtime·printf("\tpanic %p defer %p", panic, panic->defer);
+ if(panic->defer != nil)
+ runtime·printf(" argp=%p pc=%p", panic->defer->argp, panic->defer->pc);
+ runtime·printf("\n");
+ }
runtime·throw("traceback has leftover defers or panics");
}
test/fixedbugs/issue8132.go
--- /dev/null
+++ b/test/fixedbugs/issue8132.go
@@ -0,0 +1,32 @@
+// run
+
+// Copyright 2014 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// issue 8132. stack walk handling of panic stack was confused
+// about what was legal.
+
+package main
+
+import "runtime"
+
+var p *int
+
+func main() {
+ func() {
+ defer func() {
+ runtime.GC()
+ recover()
+ }()
+ var x [8192]byte
+ func(x [8192]byte) {
+ defer func() {
+ if err := recover(); err != nil {
+ println(*p)
+ }
+ }()
+ println(*p)
+ }(x)
+ }()
+}
コアとなるコードの解説
traceback_arm.c
および traceback_x86.c
の変更
両方のファイルで、runtime·gentraceback
関数内の正確性チェックの条件が変更されています。
変更前:
if(callback != nil && n < max && (defer != nil || panic != nil && panic->defer != nil)) {
// ...
runtime·throw("traceback has leftover defers or panics");
}
変更後:
// For rationale, see long comment in traceback_x86.c. (ARM版のみ)
if(callback != nil && n < max && defer != nil) {
// ...
runtime·throw("traceback has leftover defers or panics");
}
この変更により、panic != nil && panic->defer != nil
という条件が削除され、panic
スタックにエントリが残っていても、それが直ちにエラーとは見なされなくなりました。つまり、defer
スタックが空でない場合にのみ、runtime·throw
が呼び出されるようになりました。
また、デバッグ目的で、defer
スタックとpanic
スタックの残余エントリを詳細に出力するruntime·printf
のループが追加されています。これにより、throw
が呼び出される前に、残っているdefer
やpanic
の情報を確認できるようになります。
test/fixedbugs/issue8132.go
の追加
この新しいテストファイルは、issue #8132
で報告されたバグを再現し、修正が正しく機能することを確認するために追加されました。
テストケースの構造は以下の通りです。
main
関数内で匿名関数を呼び出します。- この匿名関数内で
defer
を登録し、その中でruntime.GC()
を呼び出し、recover()
を試みます。 - 大きな配列
x
(8192バイト)を宣言し、スタックを消費させます。 - 別の匿名関数を呼び出し、
x
を値渡しで渡します。これにより、スタックフレームがさらに深くなります。 - この内部の匿名関数内で
defer
を登録し、その中でrecover()
を試みます。recover
が成功した場合、println(*p)
(p
はnilポインタ)を実行し、意図的にpanic
を再発生させます。 - 内部の匿名関数内で
println(*p)
を実行し、最初のpanic
を発生させます。
この複雑なdefer
とpanic
のネスト、そしてruntime.GC()
の呼び出しが、以前のランタイムのトレースバック処理で誤ったエラーを引き起こしていました。このテストは、修正されたランサムがこのようなシナリオを正しく処理し、不必要なクラッシュが発生しないことを保証します。
関連リンク
- Go issue #8132: https://github.com/golang/go/issues/8132
- Go CL 100900044: https://golang.org/cl/100900044
参考にした情報源リンク
- Go言語の公式ドキュメント (defer, panic, recover): https://go.dev/tour/flowcontrol/12
- Goランタイムのソースコード (traceback.c): https://github.com/golang/go/blob/master/src/runtime/traceback.go (Goのバージョンによってファイル名やパスが異なる場合がありますが、概念は共通です)
- Goのガベージコレクションに関する資料 (一般的なGCの仕組み): https://go.dev/doc/gc-guide
- Goのスタック管理に関する資料 (一般的なスタックの仕組み): https://go.dev/doc/go1.2#stack (Go 1.2のリリースノートですが、スタック管理の基本的な考え方が説明されています)