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

[インデックス 17586] ファイルの概要

このコミットは、Go言語のランタイム、コンパイラ (cmd/gc)、およびリンカ (cmd/ld) における panicrecover の挙動、特にインターフェースメソッドの呼び出しや 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つ以上離れてしまう可能性がありました。これにより、recoverpanic を正しく検出できないという問題が生じていました。

変更の背景

Go言語の panicrecover は、エラーハンドリングの強力なメカニズムです。しかし、その内部実装はスタックフレームの構造に深く依存しています。特に、インターフェースメソッドの呼び出しや reflect パッケージを介した動的な呼び出しは、コンパイラやランタイムが通常の関数呼び出しとは異なる方法でスタックフレームを生成する場合があります。

このコミットの背景には、defer を用いたインターフェースメソッド呼び出しが panic を捕捉できないという具体的なバグ報告 (Issue 5406) がありました。この問題は、Goの型システムとランタイムのスタック管理の複雑な相互作用に起因していました。特に、値レシーバを持つメソッドがインターフェースを介して呼び出される際に、レシーバのコピーが必要となり、そのためにコンパイラが追加の「ラッパー」関数を生成するという挙動が、recover の期待するスタックフレームの構造を崩していました。

また、スタック分割のメカニズムも recover の挙動に影響を与えていました。Goのスタックは動的に拡張されるため、関数呼び出しの途中でスタックが不足すると、新しいスタックセグメントが割り当てられます。panic が発生した際に、defer 呼び出しがスタック分割を伴う場合、recoverpanic が発生した元のスタックセグメントを正しく特定する必要がありました。しかし、ラッパー関数が関与することで、スタック分割が複数回発生する可能性があり、既存の recover のロジックでは対応しきれない状況が生じていました。

これらの問題は、Goの堅牢なエラーハンドリングメカニズムの信頼性を損なうものであり、修正が急務でした。

前提知識の解説

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

  1. panicrecover:

    • panic は、プログラムの異常終了を引き起こすGoの組み込み関数です。通常、回復不可能なエラーやプログラマの論理的誤りを示すために使用されます。
    • recover は、defer 関数内で呼び出された場合にのみ機能し、panic からの回復を試みます。recover が呼び出されると、現在の panic の値が返され、panic の伝播が停止します。defer 関数以外で recover を呼び出すと nil が返されます。
  2. defer ステートメント:

    • defer ステートメントは、それが含まれる関数がリターンする直前(panic が発生した場合も含む)に実行される関数呼び出しをスケジュールします。defer は、リソースのクリーンアップ(ファイルのクローズ、ロックの解放など)によく使用されます。
  3. インターフェースとメソッド呼び出し:

    • Goのインターフェースは、メソッドのシグネチャの集合を定義します。型がインターフェースのすべてのメソッドを実装していれば、その型はインターフェースを実装しているとみなされます。
    • インターフェースを介してメソッドを呼び出す場合、Goランタイムは、基になる具象型のメソッドを動的にディスパッチします。
    • 値レシーバとポインタレシーバ:
      • func (T) M() のように値レシーバを持つメソッドは、T の値のコピーに対して動作します。
      • func (*T) M() のようにポインタレシーバを持つメソッドは、T のポインタに対して動作します。
    • メソッドラッパー: インターフェース値が値レシーバを持つメソッドを呼び出す場合、インターフェースは通常、基になる具象型の値のコピーを保持します。このコピーをメソッドに渡すために、コンパイラは「ラッパー」関数を生成することがあります。このラッパーは、インターフェース値からレシーバを抽出し、それをコピーして、実際のメソッドに渡します。このラッパーは、通常の関数呼び出しとは異なり、Goのソースコードには直接現れませんが、コンパイルされたバイナリには存在し、追加のスタックフレームを生成します。
  4. reflect パッケージ:

    • reflect パッケージは、Goプログラムが実行時に自身の構造を検査し、変更することを可能にします。
    • reflect.Value.Call()reflect.Value.Method() などの関数は、動的にメソッドを呼び出すために使用されます。これらの動的な呼び出しも、内部的にラッパー関数や特別なスタックフレームを生成することがあります。
  5. Goランタイムのスタック管理とスタック分割:

    • Goのgoroutineは、比較的小さなスタック(通常は数KB)で開始します。
    • 関数呼び出しによってスタックが不足すると、ランタイムは自動的に新しい、より大きなスタックセグメントを割り当て、既存のスタックの内容を新しいセグメントにコピーします。これを「スタック分割」と呼びます。
    • スタックは下方に成長します(アドレスが減少します)。g->stackbase は現在のスタックセグメントの基底アドレス(最も高いアドレス)を指し、g->stackguard はスタックオーバーフローを検出するためのガードページのアドレスを指します。
    • Stktop 構造体は、スタックセグメントの情報を保持します。特に Stktop.panic フラグは、そのスタックセグメントが panic 処理中に作成されたものであるかを示します。

これらの概念を理解することで、コミットが解決しようとしている問題と、その解決策の技術的な詳細をより深く把握することができます。

技術的詳細

このコミットは、panicrecover の挙動を修正するために、Goランタイム、コンパイラ、およびリンカにわたる広範な変更を導入しています。

1. g->panicwrap の導入 (Bug #1 の修正)

  • 目的: defer iface.M() のような呼び出しで生成されるメソッドラッパーが、recover のスタックフレームチェックを妨げないようにするため。ラッパー関数によって消費されるスタック領域を追跡し、recover がその領域を無視できるようにします。
  • runtime.h の変更: G 構造体(goroutineを表す)に uint32 panicwrap; フィールドが追加されました。これは、現在のスタックセグメント上で、recover のチェック対象から除外すべきラッパー呼び出しによるスタックバイト数をカウントします。
  • コンパイラ (cmd/gc) の変更:
    • go.hNode 構造体のフィールドとして uchar wrapper; が追加され、関数がラッパーであるかを示すフラグが導入されました。
    • pgen.c では、compile 関数内で fn->wrapper フラグが設定されている場合、生成されるテキストセクションの TEXTFLAGWRAPPER フラグが追加されるようになりました。
    • 特に、reflect パッケージ内の callReflectcallMethod 関数が、WRAPPER フラグを持つように明示的にマークされます。これは、これらの関数が reflect.MakeFuncreflect.Value.Method().Call() のような動的な呼び出しの際にラッパーとして機能するためです。
    • subr.cgenwrapper 関数では、生成されるラッパー関数に 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 のアセンブリファイルにおいて、makeFuncStubmethodValueCall 関数が (NOSPLIT|WRAPPER) フラグを持つように変更されました。これにより、これらの関数もリンカによって g->panicwrap の調整対象となります。
    • src/pkg/reflect/value.gocallReflectcallMethod 関数のコメントに、これらの関数が「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.cruntime·newproc1 関数で、新しいgoroutineが作成される際に newg->panicwrap = 0; と初期化されます。
    • src/pkg/runtime/panic.cruntime·recover 関数が大幅に簡素化されました。新しいロジックでは、recoverg->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->panictrue であり、かつ top->argpoldtop の引数フレームの直後にある場合(つまり、oldtop のスタックが分割された結果として top が作成された場合)、top->panictrue に設定されます。

3. テストケースの追加

  • test/recover.go に、新しいテストケースが多数追加されました。これらは、ポインタレシーバ、ワードサイズの値レシーバ、小さな値レシーバ、大きな値レシーバ、巨大な値レシーバ(スタック分割を伴うもの)、および reflect.MakeFunc を使用して作成された関数など、様々な種類のメソッド呼び出しとレシーバ型における panicrecover の挙動を検証します。特に、T3 から T6 までの構造体と、それらに対する testN, testNreflect1, testNreflect2 関数が追加され、ラッパー関数とスタック分割が recover に与える影響を網羅的にテストしています。

これらの変更により、Goの panicrecover メカニズムは、より複雑なスタックフレームの状況(特にインターフェースメソッドのラッパーやスタック分割)においても、期待通りに機能するようになります。

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

このコミットにおけるコアとなるコードの変更箇所は、主に以下のファイルに集中しています。

  1. src/cmd/ld/textflag.h:

    • #define WRAPPER 32 の追加。これは、リンカがラッパー関数を識別するための新しいフラグです。
  2. src/cmd/gc/go.h:

    • Node 構造体に uchar wrapper; フィールドの追加。コンパイラが関数がラッパーであるかを示すために使用します。
  3. src/cmd/gc/pgen.c:

    • compile 関数内で、fn->wrapper が設定されている場合、生成されるテキストセクションの TEXTFLAGWRAPPER フラグを追加するロジックが追加されました。
    • 特に、reflect パッケージの callReflectcallMethod 関数を明示的に WRAPPER としてマークするコードが追加されました。
  4. src/cmd/gc/subr.c:

    • genwrapper 関数内で、生成されるラッパー関数に fn->wrapper = 1; を設定する行が追加されました。
  5. 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 関数もスタック分割の対象となるように調整されました。
  6. src/pkg/reflect/asm_*.s:

    • makeFuncStubmethodValueCall 関数が (NOSPLIT|WRAPPER) フラグを持つように変更されました。
  7. src/pkg/reflect/value.go:

    • callReflectcallMethod 関数のコメントに、これらの関数が panicrecover のために「wrapper」としてマークされる必要がある旨が追記されました。
  8. src/pkg/runtime/asm_*.s:

    • CALLFN マクロで定義される関数が WRAPPER フラグを持つように変更されました。
  9. src/pkg/runtime/runtime.h:

    • G 構造体に uint32 panicwrap; フィールドが追加されました。
    • Stktop 構造体にも uint32 panicwrap; フィールドが追加されました。
  10. src/pkg/runtime/panic.c:

    • runtime·recover 関数のロジックが大幅に簡素化され、g->panicwrap の値を考慮して defer 呼び出しのスタックフレームを正確に特定するようになりました。
  11. src/pkg/runtime/proc.c:

    • runtime·newproc1 関数で、新しいgoroutineの panicwrap0 に初期化されます。
  12. src/pkg/runtime/stack.c:

    • runtime·oldstack 関数で panicwrap の値が引き継がれるようになりました。
    • runtime·newstack 関数で、スタック分割時に Stktop.panic フラグが前方へ伝播されるロジックが追加されました。
  13. test/recover.go:

    • panicrecover の挙動を検証するための多数の新しいテストケースが追加されました。

これらの変更は、Goのコンパイルパイプライン全体にわたって、panicrecover の正確な動作を保証するために連携して機能します。

コアとなるコードの解説

このコミットの核心は、g->panicwrap という新しいフィールドと、それに関連するコンパイラおよびリンカの挙動変更、そして recover 関数のロジックの修正にあります。

g->panicwrap の役割: g->panicwrap は、現在のgoroutine (g) のスタックセグメント上で、recover がスタックフレームを遡る際に無視すべきバイト数を追跡します。これは、インターフェースメソッド呼び出しや reflect を介した呼び出しによって暗黙的に生成される「ラッパー」関数が、追加のスタックフレームを作成するために導入されました。

ラッパー関数の識別と panicwrap の調整:

  1. コンパイラ (cmd/gc): reflect パッケージ内の callReflectcallMethod のような特定の関数、およびインターフェースメソッド呼び出しのために生成されるラッパー関数を「ラッパー」として識別します。これらの関数がコンパイルされる際に、生成されるアセンブリコードのテキストセクションに WRAPPER フラグを付与します。
  2. リンカ (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.cruntime·newstack 関数では、スタック分割が発生した際に、panic フラグが古いスタックセグメントから新しいスタックセグメントへと伝播されるようになりました。これにより、panic 処理中に複数のスタック分割が発生しても、recoverpanic が発生した元のスタックセグメントを正しく追跡できるようになります。

これらの変更は、Goの panicrecover メカニズムの堅牢性を高め、特に動的なメソッド呼び出しやインターフェースの使用における予期せぬ挙動を排除するために不可欠でした。

関連リンク

参考にした情報源リンク

  • 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トラッカー: 関連するバグ報告や議論。