Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 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ランタイムのスタックトレースバック処理におけるdeferpanicの複雑な相互作用があります。Goのdeferは関数の終了時に実行される処理を登録し、panicはプログラムの異常終了を引き起こします。panicが発生すると、登録されたdefer関数が順次実行され、最終的にrecoverによってpanicが捕捉されない限り、プログラムは終了します。

Goのガベージコレクション(GC)やスタックの拡張(stack growth)といったランタイムの内部処理では、現在のゴルーチンのスタックを正確にスキャンし、その状態を把握する必要があります。このスキャン処理の一部として、deferスタックとpanicスタックの状態も確認されます。

元々の実装では、トレースバックの完了時にdeferスタックとpanicスタックが完全に空になっていることを「正確性の検証」としていました。これは、もしエントリが残っていれば、スタックの状態がランタイムの認識と食い違っている、つまりバグがあるという前提に基づいています。しかし、panicが複数回発生したり、defer関数内でさらにpanicが発生したりするような複雑なシナリオでは、panicスタックの構造が必ずしもフレームの順序と一致しないことがあり、結果としてトレースバック処理が完了してもpanicスタックにエントリが残ることがありました。

特に、panicスタック上のdeferエントリは、通常のdeferスタックとは異なり、フレームの順序でネストしない場合があります。これは、panicdefer関数内で発生し、そのdefer関数がさらに別のdeferを登録するような状況で顕著になります。このような状況で、トレースバックが完了した際にpanicスタックにエントリが残っていても、それは必ずしもランタイムのバグを示すものではなく、むしろ正常な状態であると判断されるべきでした。

この誤った「正確性テスト」が、issue #8132として報告されたバグの原因でした。このバグは、特定のpanicdeferの組み合わせによって、ランタイムが不必要にthrow("traceback has leftover defers or panics")を発生させ、プログラムをクラッシュさせるというものでした。このコミットは、この誤った前提を修正し、より堅牢なトレースバック処理を実現することを目的としています。

前提知識の解説

このコミットを理解するためには、以下のGoランタイムの概念とC言語の知識が必要です。

  1. Goランタイム (Go Runtime): Goプログラムの実行を管理する低レベルのシステム。ガベージコレクション、スケジューリング、スタック管理、defer/panic/recoverの実装など、Go言語の並行性やメモリ管理の根幹を担っています。Goランタイムの多くはC言語とアセンブリ言語で記述されています。

  2. ゴルーチン (Goroutine): Goにおける軽量な実行スレッド。OSのスレッドよりもはるかに軽量で、数百万のゴルーチンを同時に実行できます。各ゴルーチンは独自のスタックを持ちます。

  3. スタック (Stack): 関数呼び出しの情報を格納するメモリ領域。ローカル変数、引数、戻りアドレスなどが積まれます。Goのゴルーチンは可変長のスタックを持ち、必要に応じて自動的に拡張・縮小されます。

  4. トレースバック (Traceback): プログラムの実行中に、現在の関数呼び出しの連鎖(コールスタック)を遡って表示する処理。デバッグやエラー報告の際に用いられます。Goランタイムでは、ガベージコレクションやスタックの拡張時にも、スタックの状態を正確に把握するために内部的にトレースバックに似た処理が行われます。

  5. defer: Goのキーワードで、関数の実行が終了する直前に実行される関数を登録します。deferされた関数は、関数の正常終了時だけでなく、panicが発生した場合でも実行されます。defer関数はLIFO(後入れ先出し)の順序で実行されます。ランタイム内部では、defer構造体のリンクリスト(deferスタック)として管理されます。

  6. panic: Goの組み込み関数で、プログラムの異常終了を引き起こします。panicが呼び出されると、現在の関数の実行が中断され、defer関数が順次実行されながらコールスタックを遡ります。

  7. recover: panicを捕捉し、プログラムの異常終了を防ぐ組み込み関数。recoverdefer関数内でのみ有効です。recoverが呼び出されると、panicの状態が解除され、defer関数が実行された後、panicが発生した地点の次のステートメントから実行が再開されます。

  8. panicスタック: ランタイム内部でpanicの状態を管理するためのデータ構造。panicが発生するたびに、関連情報(どのdeferが実行されるべきかなど)がこのスタックに積まれます。deferスタックとは異なり、panicスタックはpanicの連鎖やdefer内でのpanic再発生といった複雑なシナリオを扱うため、その構造はより複雑になります。

  9. runtime·gentraceback: Goランタイムの内部関数で、スタックトレースバックを実行します。この関数は、デバッグ目的だけでなく、GCやスタック拡張といったランタイムの内部処理でも利用されます。

  10. C言語のポインタと構造体: Goランタイムの低レベル部分はC言語で書かれているため、ポインタ操作や構造体の理解が不可欠です。特に、deferpanicの情報を保持する構造体(例: DeferPanic)がリンクリストとしてどのように管理されているかを理解することが重要です。

技術的詳細

このコミットの技術的詳細は、主にsrc/pkg/runtime/traceback_arm.csrc/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ランタイムはpanicdeferのより複雑な相互作用を正しく処理できるようになり、誤ったクラッシュを防ぎ、ランタイムの堅牢性が向上しました。

コアとなるコードの変更箇所

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が呼び出される前に、残っているdeferpanicの情報を確認できるようになります。

test/fixedbugs/issue8132.go の追加

この新しいテストファイルは、issue #8132で報告されたバグを再現し、修正が正しく機能することを確認するために追加されました。

テストケースの構造は以下の通りです。

  1. main関数内で匿名関数を呼び出します。
  2. この匿名関数内でdeferを登録し、その中でruntime.GC()を呼び出し、recover()を試みます。
  3. 大きな配列x(8192バイト)を宣言し、スタックを消費させます。
  4. 別の匿名関数を呼び出し、xを値渡しで渡します。これにより、スタックフレームがさらに深くなります。
  5. この内部の匿名関数内でdeferを登録し、その中でrecover()を試みます。recoverが成功した場合、println(*p)pはnilポインタ)を実行し、意図的にpanicを再発生させます。
  6. 内部の匿名関数内でprintln(*p)を実行し、最初のpanicを発生させます。

この複雑なdeferpanicのネスト、そしてruntime.GC()の呼び出しが、以前のランタイムのトレースバック処理で誤ったエラーを引き起こしていました。このテストは、修正されたランサムがこのようなシナリオを正しく処理し、不必要なクラッシュが発生しないことを保証します。

関連リンク

参考にした情報源リンク