[インデックス 17586] ファイルの概要
このコミットは、Go言語のランタイム、コンパイラ (cmd/gc)、およびリンカ (cmd/ld) における panic と recover の挙動、特にインターフェースメソッドの呼び出しや reflect パッケージを介した呼び出しに関連する問題を修正するものです。主な目的は、recover 関数が、メソッドラッパーやスタック分割によって生成される追加のスタックフレームを正しく無視し、期待通りに機能するようにすることです。
コミット
commit 7276c02b4193edb19bc0d2d36a786238564db03f
Author: Russ Cox <rsc@golang.org>
Date: Thu Sep 12 14:00:16 2013 -0400
runtime, cmd/gc, cmd/ld: ignore method wrappers in recover
Bug #1:
Issue 5406 identified an interesting case:
defer iface.M()
may end up calling a wrapper that copies an indirect receiver
from the iface value and then calls the real M method. That's
two calls down, not just one, and so recover() == nil always
in the real M method, even during a panic.
[For the purposes of this entire discussion, a wrapper's
implementation is a function containing an ordinary call, not
the optimized tail call form that is somtimes possible. The
tail call does not create a second frame, so it is already
handled correctly.]
Fix this bug by introducing g->panicwrap, which counts the
number of bytes on current stack segment that are due to
wrapper calls that should not count against the recover
check. All wrapper functions must now adjust g->panicwrap up
on entry and back down on exit. This adds slightly to their
expense; on the x86 it is a single instruction at entry and
exit; on the ARM it is three. However, the alternative is to
make a call to recover depend on being able to walk the stack,
which I very much want to avoid. We have enough problems
walking the stack for garbage collection and profiling.
Also, if performance is critical in a specific case, it is already
faster to use a pointer receiver and avoid this kind of wrapper
entirely.
Bug #2:
The old code, which did not consider the possibility of two
calls, already contained a check to see if the call had split
its stack and so the panic-created segment was one behind the
current segment. In the wrapper case, both of the two calls
might split their stacks, so the panic-created segment can be
two behind the current segment.
Fix this by propagating the Stktop.panic flag forward during
stack splits instead of looking backward during recover.
Fixes #5406.
R=golang-dev, iant
CC=golang-dev
https://golang.org/cl/13367052
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/7276c02b4193edb19bc0d2d36a786238564db03f
元コミット内容
このコミットは、Go言語の panic および recover メカニズムが、特定の状況下で期待通りに機能しないという2つのバグを修正します。
バグ #1: defer iface.M() のようなインターフェースメソッド呼び出しにおいて、値レシーバを持つメソッドが呼ばれる際に、コンパイラが「ラッパー」関数を生成することがあります。このラッパーは、インターフェース値から間接的なレシーバをコピーし、その後実際のメソッド M を呼び出します。これにより、defer から実際のメソッド M が呼び出されるまでに2つの関数呼び出し(ラッパーと実際のメソッド)が発生します。しかし、recover() 関数は、panic が発生した際にスタックを遡って defer 呼び出しを見つける際に、この追加のラッパーフレームを考慮していませんでした。結果として、M メソッド内で recover() を呼び出しても常に nil が返され、panic を捕捉できないという問題が発生していました。
バグ #2: Goのランタイムは、関数呼び出しの際にスタックが不足すると、新しいスタックセグメントを割り当てる「スタック分割」を行います。panic が発生し、defer 呼び出しがスタック分割を伴う場合、古い recover の実装は、panic が発生したスタックセグメントが現在のスタックセグメントの1つ前にあると仮定していました。しかし、ラッパー関数が関与する場合、ラッパー自身と実際のメソッドの両方がスタック分割を行う可能性があり、panic が発生したセグメントが現在のセグメントから2つ以上離れてしまう可能性がありました。これにより、recover が panic を正しく検出できないという問題が生じていました。
変更の背景
Go言語の panic と recover は、エラーハンドリングの強力なメカニズムです。しかし、その内部実装はスタックフレームの構造に深く依存しています。特に、インターフェースメソッドの呼び出しや reflect パッケージを介した動的な呼び出しは、コンパイラやランタイムが通常の関数呼び出しとは異なる方法でスタックフレームを生成する場合があります。
このコミットの背景には、defer を用いたインターフェースメソッド呼び出しが panic を捕捉できないという具体的なバグ報告 (Issue 5406) がありました。この問題は、Goの型システムとランタイムのスタック管理の複雑な相互作用に起因していました。特に、値レシーバを持つメソッドがインターフェースを介して呼び出される際に、レシーバのコピーが必要となり、そのためにコンパイラが追加の「ラッパー」関数を生成するという挙動が、recover の期待するスタックフレームの構造を崩していました。
また、スタック分割のメカニズムも recover の挙動に影響を与えていました。Goのスタックは動的に拡張されるため、関数呼び出しの途中でスタックが不足すると、新しいスタックセグメントが割り当てられます。panic が発生した際に、defer 呼び出しがスタック分割を伴う場合、recover は panic が発生した元のスタックセグメントを正しく特定する必要がありました。しかし、ラッパー関数が関与することで、スタック分割が複数回発生する可能性があり、既存の recover のロジックでは対応しきれない状況が生じていました。
これらの問題は、Goの堅牢なエラーハンドリングメカニズムの信頼性を損なうものであり、修正が急務でした。
前提知識の解説
このコミットを理解するためには、以下のGo言語の概念とランタイムの内部動作に関する知識が必要です。
-
panicとrecover:panicは、プログラムの異常終了を引き起こすGoの組み込み関数です。通常、回復不可能なエラーやプログラマの論理的誤りを示すために使用されます。recoverは、defer関数内で呼び出された場合にのみ機能し、panicからの回復を試みます。recoverが呼び出されると、現在のpanicの値が返され、panicの伝播が停止します。defer関数以外でrecoverを呼び出すとnilが返されます。
-
deferステートメント:deferステートメントは、それが含まれる関数がリターンする直前(panicが発生した場合も含む)に実行される関数呼び出しをスケジュールします。deferは、リソースのクリーンアップ(ファイルのクローズ、ロックの解放など)によく使用されます。
-
インターフェースとメソッド呼び出し:
- Goのインターフェースは、メソッドのシグネチャの集合を定義します。型がインターフェースのすべてのメソッドを実装していれば、その型はインターフェースを実装しているとみなされます。
- インターフェースを介してメソッドを呼び出す場合、Goランタイムは、基になる具象型のメソッドを動的にディスパッチします。
- 値レシーバとポインタレシーバ:
func (T) M()のように値レシーバを持つメソッドは、Tの値のコピーに対して動作します。func (*T) M()のようにポインタレシーバを持つメソッドは、Tのポインタに対して動作します。
- メソッドラッパー: インターフェース値が値レシーバを持つメソッドを呼び出す場合、インターフェースは通常、基になる具象型の値のコピーを保持します。このコピーをメソッドに渡すために、コンパイラは「ラッパー」関数を生成することがあります。このラッパーは、インターフェース値からレシーバを抽出し、それをコピーして、実際のメソッドに渡します。このラッパーは、通常の関数呼び出しとは異なり、Goのソースコードには直接現れませんが、コンパイルされたバイナリには存在し、追加のスタックフレームを生成します。
-
reflectパッケージ:reflectパッケージは、Goプログラムが実行時に自身の構造を検査し、変更することを可能にします。reflect.Value.Call()やreflect.Value.Method()などの関数は、動的にメソッドを呼び出すために使用されます。これらの動的な呼び出しも、内部的にラッパー関数や特別なスタックフレームを生成することがあります。
-
Goランタイムのスタック管理とスタック分割:
- Goのgoroutineは、比較的小さなスタック(通常は数KB)で開始します。
- 関数呼び出しによってスタックが不足すると、ランタイムは自動的に新しい、より大きなスタックセグメントを割り当て、既存のスタックの内容を新しいセグメントにコピーします。これを「スタック分割」と呼びます。
- スタックは下方に成長します(アドレスが減少します)。
g->stackbaseは現在のスタックセグメントの基底アドレス(最も高いアドレス)を指し、g->stackguardはスタックオーバーフローを検出するためのガードページのアドレスを指します。 Stktop構造体は、スタックセグメントの情報を保持します。特にStktop.panicフラグは、そのスタックセグメントがpanic処理中に作成されたものであるかを示します。
これらの概念を理解することで、コミットが解決しようとしている問題と、その解決策の技術的な詳細をより深く把握することができます。
技術的詳細
このコミットは、panic と recover の挙動を修正するために、Goランタイム、コンパイラ、およびリンカにわたる広範な変更を導入しています。
1. g->panicwrap の導入 (Bug #1 の修正)
- 目的:
defer iface.M()のような呼び出しで生成されるメソッドラッパーが、recoverのスタックフレームチェックを妨げないようにするため。ラッパー関数によって消費されるスタック領域を追跡し、recoverがその領域を無視できるようにします。 runtime.hの変更:G構造体(goroutineを表す)にuint32 panicwrap;フィールドが追加されました。これは、現在のスタックセグメント上で、recoverのチェック対象から除外すべきラッパー呼び出しによるスタックバイト数をカウントします。- コンパイラ (
cmd/gc) の変更:go.hにNode構造体のフィールドとしてuchar wrapper;が追加され、関数がラッパーであるかを示すフラグが導入されました。pgen.cでは、compile関数内でfn->wrapperフラグが設定されている場合、生成されるテキストセクションのTEXTFLAGにWRAPPERフラグが追加されるようになりました。- 特に、
reflectパッケージ内のcallReflectとcallMethod関数が、WRAPPERフラグを持つように明示的にマークされます。これは、これらの関数がreflect.MakeFuncやreflect.Value.Method().Call()のような動的な呼び出しの際にラッパーとして機能するためです。 subr.cのgenwrapper関数では、生成されるラッパー関数にfn->wrapper = 1;が設定されるようになりました。
- リンカ (
cmd/ld) の変更:textflag.hに新しいテキストフラグ#define WRAPPER 32が定義されました。これは、関数がラッパー関数であることを示します。- リンカは、
WRAPPERフラグが設定された関数のプロローグとエピローグに、g->panicwrapを調整するコードを挿入します。- プロローグ (関数エントリ時):
g->panicwrapに、そのラッパー関数が使用するスタックフレームのサイズ(autosizeまたはautoffset + PtrSize)を加算します。これにより、ラッパーフレームがrecoverのチェックから除外されます。 - エピローグ (関数終了時):
g->panicwrapから、プロローグで加算したスタックフレームサイズを減算します。
- プロローグ (関数エントリ時):
- この調整は、
src/cmd/5l/noop.c(386アーキテクチャ用)、src/cmd/6l/pass.c(amd64アーキテクチャ用)、src/cmd/8l/pass.c(armアーキテクチャ用) のdostkoff関数内で実装されています。具体的には、AMOVW(386) やAADDL/ASUBL(amd64/arm) 命令を用いてg->panicwrapの値を操作します。
reflectパッケージの変更:src/pkg/reflect/asm_386.s,src/pkg/reflect/asm_amd64.s,src/pkg/reflect/asm_arm.sのアセンブリファイルにおいて、makeFuncStubとmethodValueCall関数が(NOSPLIT|WRAPPER)フラグを持つように変更されました。これにより、これらの関数もリンカによってg->panicwrapの調整対象となります。src/pkg/reflect/value.goのcallReflectとcallMethod関数のコメントに、これらの関数が「wrapper」としてマークされる必要がある旨が追記されました。
runtimeパッケージの変更:src/pkg/runtime/asm_386.s,src/pkg/runtime/asm_amd64.s,src/pkg/runtime/asm_arm.sのアセンブリファイルにおいて、CALLFNマクロで定義される関数(例:runtime·call)もWRAPPERフラグを持つように変更されました。src/pkg/runtime/proc.cのruntime·newproc1関数で、新しいgoroutineが作成される際にnewg->panicwrap = 0;と初期化されます。src/pkg/runtime/panic.cのruntime·recover関数が大幅に簡素化されました。新しいロジックでは、recoverはg->panicwrapの値を考慮して、defer呼び出しのスタックフレームのトップを正確に特定します。具体的には、argp == (byte*)top - top->argsize - g->panicwrapという条件で、panicが発生したスタックフレームを識別します。
2. Stktop.panic フラグの伝播 (Bug #2 の修正)
- 目的: スタック分割が複数回発生した場合でも、
panicが発生したスタックセグメントをrecoverが正しく特定できるようにするため。 runtime.hの変更:Stktop構造体にuint32 panicwrap;フィールドが追加されました。これは、スタック分割時にg->panicwrapの値を新しいStktopに引き継ぐために使用されます。src/pkg/runtime/stack.cの変更:runtime·oldstack関数では、古いスタックセグメントから新しいスタックセグメントに切り替える際に、gp->panicwrap = top->panicwrap;とpanicwrapの値が引き継がれます。runtime·newstack関数では、スタック分割が発生した際に、Stktop.panicフラグが前方(新しいスタックセグメント)に伝播されるようになりました。- 以前は
recoverが後方(古いスタックセグメント)を見てpanicフラグをチェックしていましたが、この変更により、panic発生時に作成されたスタックセグメントのpanicフラグが、その後のスタック分割によって生成される新しいセグメントにも引き継がれるようになります。 - 具体的には、
oldtop->panicがtrueであり、かつtop->argpがoldtopの引数フレームの直後にある場合(つまり、oldtopのスタックが分割された結果としてtopが作成された場合)、top->panicもtrueに設定されます。
- 以前は
3. テストケースの追加
test/recover.goに、新しいテストケースが多数追加されました。これらは、ポインタレシーバ、ワードサイズの値レシーバ、小さな値レシーバ、大きな値レシーバ、巨大な値レシーバ(スタック分割を伴うもの)、およびreflect.MakeFuncを使用して作成された関数など、様々な種類のメソッド呼び出しとレシーバ型におけるpanicとrecoverの挙動を検証します。特に、T3からT6までの構造体と、それらに対するtestN,testNreflect1,testNreflect2関数が追加され、ラッパー関数とスタック分割がrecoverに与える影響を網羅的にテストしています。
これらの変更により、Goの panic と recover メカニズムは、より複雑なスタックフレームの状況(特にインターフェースメソッドのラッパーやスタック分割)においても、期待通りに機能するようになります。
コアとなるコードの変更箇所
このコミットにおけるコアとなるコードの変更箇所は、主に以下のファイルに集中しています。
-
src/cmd/ld/textflag.h:#define WRAPPER 32の追加。これは、リンカがラッパー関数を識別するための新しいフラグです。
-
src/cmd/gc/go.h:Node構造体にuchar wrapper;フィールドの追加。コンパイラが関数がラッパーであるかを示すために使用します。
-
src/cmd/gc/pgen.c:compile関数内で、fn->wrapperが設定されている場合、生成されるテキストセクションのTEXTFLAGにWRAPPERフラグを追加するロジックが追加されました。- 特に、
reflectパッケージのcallReflectとcallMethod関数を明示的にWRAPPERとしてマークするコードが追加されました。
-
src/cmd/gc/subr.c:genwrapper関数内で、生成されるラッパー関数にfn->wrapper = 1;を設定する行が追加されました。
-
src/cmd/{5l,6l,8l}/pass.cおよびsrc/cmd/5l/noop.c:- リンカのコード生成部分で、
WRAPPERフラグを持つ関数のプロローグとエピローグに、g->panicwrapを調整するアセンブリ命令が挿入されるようになりました。- エントリ時:
g->panicwrap += autosize;(またはautoffset + PtrSize) - リターン時:
g->panicwrap -= autosize;(またはautoffset + PtrSize)
- エントリ時:
dostkoff関数内のスタック分割チェックのロジックも変更され、WRAPPER関数もスタック分割の対象となるように調整されました。
- リンカのコード生成部分で、
-
src/pkg/reflect/asm_*.s:makeFuncStubとmethodValueCall関数が(NOSPLIT|WRAPPER)フラグを持つように変更されました。
-
src/pkg/reflect/value.go:callReflectとcallMethod関数のコメントに、これらの関数がpanicとrecoverのために「wrapper」としてマークされる必要がある旨が追記されました。
-
src/pkg/runtime/asm_*.s:CALLFNマクロで定義される関数がWRAPPERフラグを持つように変更されました。
-
src/pkg/runtime/runtime.h:G構造体にuint32 panicwrap;フィールドが追加されました。Stktop構造体にもuint32 panicwrap;フィールドが追加されました。
-
src/pkg/runtime/panic.c:runtime·recover関数のロジックが大幅に簡素化され、g->panicwrapの値を考慮してdefer呼び出しのスタックフレームを正確に特定するようになりました。
-
src/pkg/runtime/proc.c:runtime·newproc1関数で、新しいgoroutineのpanicwrapが0に初期化されます。
-
src/pkg/runtime/stack.c:runtime·oldstack関数でpanicwrapの値が引き継がれるようになりました。runtime·newstack関数で、スタック分割時にStktop.panicフラグが前方へ伝播されるロジックが追加されました。
-
test/recover.go:panicとrecoverの挙動を検証するための多数の新しいテストケースが追加されました。
これらの変更は、Goのコンパイルパイプライン全体にわたって、panic と recover の正確な動作を保証するために連携して機能します。
コアとなるコードの解説
このコミットの核心は、g->panicwrap という新しいフィールドと、それに関連するコンパイラおよびリンカの挙動変更、そして recover 関数のロジックの修正にあります。
g->panicwrap の役割:
g->panicwrap は、現在のgoroutine (g) のスタックセグメント上で、recover がスタックフレームを遡る際に無視すべきバイト数を追跡します。これは、インターフェースメソッド呼び出しや reflect を介した呼び出しによって暗黙的に生成される「ラッパー」関数が、追加のスタックフレームを作成するために導入されました。
ラッパー関数の識別と panicwrap の調整:
- コンパイラ (
cmd/gc):reflectパッケージ内のcallReflectやcallMethodのような特定の関数、およびインターフェースメソッド呼び出しのために生成されるラッパー関数を「ラッパー」として識別します。これらの関数がコンパイルされる際に、生成されるアセンブリコードのテキストセクションにWRAPPERフラグを付与します。 - リンカ (
cmd/ld):WRAPPERフラグを持つ関数を見つけると、その関数のプロローグ(関数エントリ時)とエピローグ(関数終了時)に、g->panicwrapを調整するアセンブリ命令を自動的に挿入します。- プロローグ: ラッパー関数がスタックフレームを割り当てる際に、そのフレームサイズ分だけ
g->panicwrapを増加させます。これにより、recoverがスタックを遡る際に、このラッパーフレームがpanicの発生源とは無関係な「ノイズ」として認識され、無視されるようになります。 - エピローグ: ラッパー関数が終了する際に、プロローグで増加させた分だけ
g->panicwrapを減少させ、スタックの状態を元に戻します。
- プロローグ: ラッパー関数がスタックフレームを割り当てる際に、そのフレームサイズ分だけ
recover 関数の修正:
src/pkg/runtime/panic.c にある runtime·recover 関数は、panic を捕捉するGoの組み込み関数 recover() のランタイム実装です。このコミットでは、recover のロジックが大幅に簡素化され、g->panicwrap の値を考慮に入れるようになりました。
変更後の recover の主要な条件は以下のようになります。
if(p != nil && !p->recovered && top->panic && argp == (byte*)top - top->argsize - g->panicwrap) {
p->recovered = 1;
ret = p->arg;
} else {
ret.type = nil;
ret.data = nil;
}
ここで、
p != nil && !p->recovered: 未回復のpanicが進行中であること。top->panic: 現在のスタックセグメントがpanic処理中に作成されたものであること。argp == (byte*)top - top->argsize - g->panicwrap: これが最も重要な変更点です。top: 現在のスタックセグメントのStktop構造体へのポインタ。(byte*)top - top->argsize: これは、現在のスタックセグメントのトップにあるdefer呼び出しの引数フレームの開始アドレスを示します。- g->panicwrap: ここでg->panicwrapの値が引かれます。これにより、ラッパー関数によって追加されたスタックバイトがオフセットとして考慮され、recoverは実際のdefer呼び出しのスタックフレームのトップを正確に特定できるようになります。
この修正により、recover はラッパー関数によって生成された余分なスタックフレームを透過的に無視し、panic が発生した真のコンテキストを正しく識別できるようになります。
Stktop.panic フラグの伝播:
src/pkg/runtime/stack.c の runtime·newstack 関数では、スタック分割が発生した際に、panic フラグが古いスタックセグメントから新しいスタックセグメントへと伝播されるようになりました。これにより、panic 処理中に複数のスタック分割が発生しても、recover は panic が発生した元のスタックセグメントを正しく追跡できるようになります。
これらの変更は、Goの panic と recover メカニズムの堅牢性を高め、特に動的なメソッド呼び出しやインターフェースの使用における予期せぬ挙動を排除するために不可欠でした。
関連リンク
- Go Issue 5406: https://code.google.com/p/go/issues/detail?id=5406 (元のバグ報告)
- Go CL 13367052: https://golang.org/cl/13367052 (このコミットに対応する変更リスト)
参考にした情報源リンク
- Go言語の公式ドキュメント:
defer,panic,recoverの基本的な概念について。 - Goのランタイムソースコード: 特に
src/pkg/runtime/panic.c,src/pkg/runtime/stack.c,src/pkg/runtime/runtime.hなど。 - Goのコンパイラおよびリンカのソースコード:
src/cmd/gc,src/cmd/ldディレクトリ内のファイル。 - Goの
reflectパッケージのドキュメントとソースコード。 - Goのスタック管理に関する技術記事やブログポスト。
- Goのインターフェースの内部実装に関する技術解説。
- GoのIssueトラッカー: 関連するバグ報告や議論。