[インデックス 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トラッカー: 関連するバグ報告や議論。