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

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

このコミットでは、Goのreflectパッケージとランタイムにおけるガベージコレクション(GC)のクラッシュを修正しています。主に以下のファイルが変更されています。

  • src/pkg/reflect/all_test.go: 問題を再現するための新しいテストケースが追加されています。
  • src/pkg/reflect/export_test.go: テスト用のエクスポートが追加されています。
  • src/pkg/reflect/value.go: reflect.call関数の引数と内部ロジックが変更され、GCのトリガーをテストするためのフラグが追加されています。
  • src/pkg/runtime/asm_386.s, src/pkg/runtime/asm_amd64.s, src/pkg/runtime/asm_arm.s: 各アーキテクチャのアセンブリコードが変更され、reflect.callが返す値のみをコピーするように修正されています。
  • src/pkg/runtime/mgc0.c: ガベージコレクタのデバッグ出力が追加・修正されています。
  • src/pkg/runtime/panic.c: reflect.callの呼び出し箇所が修正されています。
  • src/pkg/runtime/runtime.h: reflect.call関数のシグネチャが更新されています。

コミット

commit 72c5d5e7567a67335db1c6ffcbe1a8fe90b72422
Author: Russ Cox <rsc@golang.org>
Date:   Tue Apr 8 11:11:35 2014 -0400

    reflect, runtime: fix crash in GC due to reflect.call + precise GC
    
    Given
            type Outer struct {
                    *Inner
                    ...
            }
    the compiler generates the implementation of (*Outer).M dispatching to
    the embedded Inner. The implementation is logically:
            func (p *Outer) M() {
                    (p.Inner).M()
            }
    but since the only change here is the replacement of one pointer
    receiver with another, the actual generated code overwrites the
    original receiver with the p.Inner pointer and then jumps to the M
    method expecting the *Inner receiver.
    
    During reflect.Value.Call, we create an argument frame and the
    associated data structures to describe it to the garbage collector,
    populate the frame, call reflect.call to run a function call using
    that frame, and then copy the results back out of the frame. The
    reflect.call function does a memmove of the frame structure onto the
    stack (to set up the inputs), runs the call, and the memmoves the
    stack back to the frame structure (to preserve the outputs).
    
    Originally reflect.call did not distinguish inputs from outputs: both
    memmoves were for the full stack frame. However, in the case where the
    called function was one of these wrappers, the rewritten receiver is
    almost certainly a different type than the original receiver. This is
    not a problem on the stack, where we use the program counter to
    determine the type information and understand that during (*Outer).M
    the receiver is an *Outer while during (*Inner).M the receiver in the
    same memory word is now an *Inner. But in the statically typed
    argument frame created by reflect, the receiver is always an *Outer.
    Copying the modified receiver pointer off the stack into the frame
    will store an *Inner there, and then if a garbage collection happens
    to scan that argument frame before it is discarded, it will scan the
    *Inner memory as if it were an *Outer. If the two have different
    memory layouts, the collection will intepret the memory incorrectly.
    
    Fix by only copying back the results.
    
    Fixes #7725.
    
    LGTM=khr
    R=khr
    CC=dave, golang-codereviews
    https://golang.org/cl/85180043

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/72c5d5e7567a67335db1c6ffcbe1a8fe90b72422

元コミット内容

このコミットは、reflect.callと正確なガベージコレクション(GC)の組み合わせによって引き起こされるクラッシュを修正します。具体的には、Goのコンパイラが埋め込みフィールドを持つ構造体のメソッドをディスパッチする際に生成するラッパー関数と、reflect.Value.Callが引数フレームを処理する方法に問題がありました。

reflect.callは、関数呼び出しのために引数フレームをスタックにコピーし、呼び出し後に結果をスタックからフレームにコピーします。元々、このコピーはスタックフレーム全体に対して行われていました。しかし、埋め込みフィールドのメソッドを呼び出すラッパー関数では、レシーバの型が呼び出し中に変更される可能性があります(例: *Outerから*Innerへ)。スタック上ではプログラムカウンタに基づいて型情報が決定されるため問題ありませんが、reflectによって作成された静的に型付けされた引数フレームでは、レシーバは常に元の型(*Outer)として扱われます。

このため、変更されたレシーバポインタ(*Inner)がスタックから引数フレームにコピーされると、GCがその引数フレームをスキャンする際に、*Innerのメモリを*Outerとして解釈してしまう可能性がありました。もし両者のメモリレイアウトが異なる場合、GCはメモリを誤って解釈し、クラッシュを引き起こす可能性がありました。

この修正は、reflect.callが結果のみをコピーするように変更することで、この問題を解決します。

変更の背景

この変更の背景には、Goのreflectパッケージとガベージコレクタ(GC)の連携における、特定のコーナーケースでのメモリ安全性問題がありました。具体的には、Goの言語機能である「埋め込みフィールド(Embedded Fields)」と、reflect.Value.Callメソッドによる動的なメソッド呼び出しが組み合わさった際に、GCが誤ったメモリ解釈を行い、クラッシュが発生するというバグ(Issue 7725)が報告されていました。

問題の核心は、コンパイラが埋め込みフィールドのメソッド呼び出しを最適化する際に生成する「ラッパー関数」の挙動と、reflect.Value.Callが引数と戻り値を処理する方法のミスマッチにありました。

  1. 埋め込みフィールドとメソッドのディスパッチ: Goでは、構造体に別の構造体を埋め込むことができます。埋め込まれた構造体のメソッドは、外側の構造体のメソッドとして「昇格」されます。例えば、Outer構造体が*Innerを埋め込み、InnerM()メソッドを持つ場合、OuterのインスタンスからM()を直接呼び出すことができます。コンパイラは、この呼び出しを効率化するために、(*Outer).M()(*Inner).M()を呼び出すようなラッパー関数を生成します。このラッパー関数は、論理的にはfunc (p *Outer) M() { (p.Inner).M() }のようになりますが、実際にはレシーバポインタを*Outerから*Innerに書き換えてから、*InnerMメソッドにジャンプするという最適化が行われます。

  2. reflect.Value.Callの動作: reflect.Value.Callは、動的に関数やメソッドを呼び出すための強力な機能です。このメソッドが呼び出されると、Goランタイムは以下の処理を行います。

    • 呼び出しに必要な引数を格納するための「引数フレーム」をメモリ上に作成します。このフレームはGCによってスキャンされる対象となります。
    • 引数フレームに引数を設定します。
    • reflect.callという内部関数を呼び出し、実際の関数呼び出しを実行します。
    • reflect.callは、引数フレームの内容をスタックにmemmove(メモリコピー)して関数呼び出しの入力とし、関数実行後にスタックから引数フレームに結果をmemmoveして出力として保存します。
  3. 問題の発生: 元々、reflect.callは入力と出力の区別なく、スタックフレーム全体をmemmoveしていました。ここで、埋め込みフィールドのメソッドを呼び出すラッパー関数が関わってきます。ラッパー関数内でレシーバポインタが*Outerから*Innerに書き換えられた後、その*Innerポインタがスタックから引数フレームにコピーされると問題が発生します。

    • スタック上では、プログラムカウンタ(PC)に基づいて型情報が正確に追跡されるため、(*Outer).Mの実行中はレシーバが*Outerとして、(*Inner).Mの実行中は同じメモリワードが*Innerとして正しく認識されます。
    • しかし、reflectによって作成された引数フレームは静的に型付けされており、レシーバは常に元の型(*Outer)として扱われます。
    • このため、*Inner型のポインタが*Outer型のスロットにコピーされることになります。
    • もし、この引数フレームが破棄される前にGCが実行され、このフレームをスキャンした場合、GCは*Innerのメモリを*Outerとして解釈しようとします。
    • *Outer*Innerのメモリレイアウトが異なる場合(特にポインタフィールドの配置が異なる場合)、GCはメモリを誤って解釈し、存在しないポインタをデリファレンスしようとしたり、不正なメモリアドレスにアクセスしようとしたりして、クラッシュを引き起こす可能性がありました。

この問題は、特にインターフェースの内部表現(itabポインタのデリファレンス)と関連して顕在化し、GCが不正なメモリアドレスを読み取ろうとすることでパニックを引き起こしていました。このコミットは、reflect.callが結果のみを引数フレームにコピーするように変更することで、この型ミスマッチによるGCの誤解釈を防ぎ、クラッシュを回避することを目的としています。

前提知識の解説

このコミットを理解するためには、以下のGo言語およびランタイムの概念について理解しておく必要があります。

1. Goのreflectパッケージ

reflectパッケージは、Goプログラムが実行時に自身の構造を検査・操作するための機能を提供します。これにより、型情報(構造体のフィールド、メソッドなど)を動的に取得したり、未知の型の値を操作したり、関数を動的に呼び出したりすることが可能になります。

  • reflect.Value: Goのあらゆる値(変数、定数、関数の結果など)を抽象的に表現する型です。reflect.ValueOf(x)でGoの値をreflect.Valueに変換できます。
  • reflect.Type: Goのあらゆる型の情報を抽象的に表現する型です。reflect.TypeOf(x)でGoの型の情報をreflect.Typeに変換できます。
  • reflect.Value.Call(in []Value) []Value: reflect.Valueが関数やメソッドを表す場合、このメソッドを使ってその関数/メソッドを動的に呼び出すことができます。引数は[]reflect.Valueとして渡し、戻り値も[]reflect.Valueとして受け取ります。

reflectパッケージは、Goの静的型付けシステムを迂回して動的な操作を可能にするため、非常に強力ですが、その分、内部的には複雑なランタイムの仕組みと連携しています。特に、動的な関数呼び出しは、コンパイラが生成する通常の関数呼び出しとは異なる特殊な処理を必要とします。

2. Goの埋め込みフィールド(Embedded Fields)とメソッドの昇格

Goの構造体は、他の構造体を「埋め込む」ことができます。これは、埋め込まれた構造体のフィールドやメソッドが、外側の構造体のフィールドやメソッドであるかのようにアクセスできる機能です。

type Inner struct {
    X int
}

func (i *Inner) M() {
    fmt.Println("Inner.M called")
}

type Outer struct {
    *Inner // Inner構造体をポインタとして埋め込む
    Y string
}

func main() {
    o := &Outer{Inner: &Inner{X: 10}, Y: "hello"}
    o.M() // OuterのインスタンスからInnerのM()メソッドを直接呼び出せる
    fmt.Println(o.X) // OuterのインスタンスからInnerのXフィールドを直接アクセスできる
}

コンパイラは、o.M()のような呼び出しを処理する際に、実際にはo.Inner.M()を呼び出すための「ラッパー関数」を生成します。このラッパー関数は、レシーバの型を*Outerから*Innerに変換し、その後Innerのメソッドにジャンプするという最適化を行うことがあります。

3. Goのガベージコレクション(GC)と正確なGC(Precise GC)

Goは自動メモリ管理(ガベージコレクション)を採用しています。GCは、プログラムがもはや到達できないメモリ領域(オブジェクト)を自動的に解放し、再利用可能にします。GoのGCは「正確なGC(Precise GC)」です。

  • 正確なGC: プログラムの実行中に、メモリ上のどの値がポインタであり、どの値がポインタでないかをGCが正確に識別できるGCのことです。これにより、GCはポインタが指すオブジェクトのみを追跡し、誤って非ポインタデータをポインタとして解釈して不正なメモリアドレスにアクセスする(そしてクラッシュする)ことを防ぎます。
  • スタックのスキャン: GCは、ヒープ上のオブジェクトだけでなく、各ゴルーチンのスタックもスキャンして、スタック上に存在するポインタ(ローカル変数や引数など)を識別し、それらが指すヒープ上のオブジェクトを「生きている」ものとしてマークします。
  • 型情報とPC: スタック上のポインタを正確に識別するためには、その時点でのプログラムカウンタ(PC)に基づいて、スタックフレーム内のどの位置にどのような型の値(ポインタか非ポインタか)が存在するかという型情報が必要です。Goランタイムは、コンパイル時に生成されたPCごとの型情報(スタックマップなど)を利用して、これを実現しています。

4. メモリレイアウトとインターフェースの内部表現

Goのインターフェースは、内部的には2つのワードで構成されます。

  • 型情報(itabまたはtype descriptor): インターフェースが保持している具体的な値の型に関する情報へのポインタ。
  • データポインタ: インターフェースが保持している具体的な値へのポインタ。

GCがインターフェースをスキャンする際、これらのポインタを追跡して、参照先のオブジェクトが生きているかどうかを判断します。もしGCが誤って非ポインタデータをインターフェースとして解釈した場合、存在しない型情報ポインタをデリファレンスしようとして、不正なメモリアクセスやクラッシュを引き起こす可能性があります。

これらの概念が複雑に絡み合い、今回のGCクラッシュ問題を引き起こしていました。

技術的詳細

このコミットが修正する問題は、reflect.Value.Callが内部的に使用するreflect.call関数と、Goの正確なガベージコレクション(GC)の相互作用に起因します。特に、埋め込みフィールドを持つ構造体のメソッド呼び出しが関与する点が重要です。

問題のメカニズム

  1. コンパイラによるレシーバの書き換え: Goのコンパイラは、埋め込みフィールドのメソッド呼び出しを最適化する際に、特殊なラッパー関数を生成します。例えば、type Outer struct { *Inner }という構造体があり、InnerM()メソッドを持つ場合、(*Outer).M()の呼び出しは、論理的には(*Outer).Inner.M()となります。しかし、コンパイラが生成するアセンブリコードでは、(*Outer).M()のレシーバである*Outerポインタを、直接*Innerポインタに書き換えてから、(*Inner).M()の実装にジャンプするという最適化が行われます。これは、レシーバの型が*Outerから*Innerへと、メモリ上の同じ位置で「変化」することを意味します。

  2. reflect.Value.Callの引数フレーム処理: reflect.Value.Callメソッドが呼び出されると、Goランタイムは以下のステップを実行します。

    • 引数フレームの作成: 呼び出される関数/メソッドの引数と戻り値を格納するための一時的なメモリ領域(「引数フレーム」)がヒープ上に確保されます。このフレームはGCの対象となります。
    • 引数のコピー(memmove: reflect.call関数は、この引数フレームの内容をスタック上の適切な位置にmemmove(メモリコピー)します。これにより、通常の関数呼び出しと同様に、引数がスタック経由で関数に渡されます。
    • 関数呼び出しの実行: 実際の関数/メソッドがスタック上で実行されます。この際、前述のコンパイラ最適化により、レシーバポインタが*Outerから*Innerに書き換えられる可能性があります。
    • 戻り値のコピー(memmove: 関数実行後、reflect.callはスタック上の戻り値を、ヒープ上の引数フレームの対応する位置にmemmoveでコピーし戻します。
  3. GCの誤解釈とクラッシュ: 問題は、この「戻り値のコピー」の段階で発生しました。元々、reflect.callは入力(引数)と出力(戻り値)を区別せず、スタックフレーム全体を引数フレームにコピーし戻していました。

    • 型情報の不一致: スタック上では、プログラムカウンタ(PC)に基づいて、レシーバが*Outerから*Innerに変化したことがGCによって正確に認識されます。しかし、ヒープ上の引数フレームは、reflectによって静的に型付けされており、レシーバのスロットは常に*Outer型として定義されています。
    • 誤ったポインタのコピー: ラッパー関数によって*Innerに書き換えられたレシーバポインタが、スタックから引数フレームの*Outerスロットにコピーされます。
    • GCスキャン時の問題: もし、この引数フレームが破棄される前にGCが実行され、このフレームをスキャンした場合、GCは*Inner型のポインタが格納されているにもかかわらず、そのスロットを*Outer型として解釈しようとします。
    • メモリレイアウトの不一致: *Outer*Innerのメモリレイアウトが異なる場合(特にポインタフィールドのオフセットや数が異なる場合)、GCは*Outerの型情報に基づいてメモリをスキャンするため、*Innerの内部にある非ポインタデータを誤ってポインタとして解釈したり、存在しないポインタをデリファレンスしようとしたりします。
    • インターフェースの例: コミットメッセージの例では、Outerio.Readerインターフェースを持ち、Inneruintptr型のフィールドを持つ場合に問題が顕在化しました。GCが*Inneruintptrフィールドを*Outerio.Readerインターフェースの型情報ポインタとして誤って解釈し、不正なアドレスをデリファレンスしようとしてクラッシュを引き起こしました。これは、インターフェースの型情報ポインタがnilでない場合、GCはそれをデリファレンスして具体的な型情報を取得しようとするためです。

修正内容

このコミットの修正は、reflect.callが「結果のみ」を引数フレームにコピーし戻すように変更することで、この問題を解決します。

  • reflect.call関数に、戻り値の開始オフセットを示す新しい引数retoffsetが追加されました。
  • アセンブリコード(runtime/asm_*.s)内のCALLFNマクロが変更され、戻り値をコピーするmemmoveの範囲が、引数フレーム全体ではなく、retoffsetから始まる戻り値の領域のみに限定されるようになりました。
  • これにより、関数呼び出し中にレシーバポインタが書き換えられたとしても、その変更されたレシーバポインタが引数フレームの「入力」部分にコピーし戻されることがなくなります。引数フレームの「入力」部分は、GCがスキャンする際に元の型情報(*Outer)に基づいて正しく解釈されるため、型ミスマッチによるGCの誤解釈が回避されます。

この修正により、reflect.Value.Callを使用した場合でも、埋め込みフィールドのメソッド呼び出しが原因でGCがクラッシュする問題が解消されました。

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

このコミットのコアとなるコード変更は、主にsrc/pkg/reflect/value.goと、各アーキテクチャのアセンブリファイル(src/pkg/runtime/asm_386.s, src/pkg/runtime/asm_amd64.s, src/pkg/runtime/asm_arm.s)に集中しています。また、ガベージコレクタのデバッグ出力に関する変更がsrc/pkg/runtime/mgc0.cにも見られます。

src/pkg/reflect/value.go

  • reflect.call関数のシグネチャが変更されました。 変更前: func call(fn, arg unsafe.Pointer, n uint32) 変更後: func call(fn, arg unsafe.Pointer, n uint32, retoffset uint32) 新しい引数retoffsetは、引数フレーム内で戻り値が始まるオフセットを示します。

  • Value.callメソッド内でreflect.callを呼び出す箇所が変更されました。 変更前: call(fn, args, uint32(frametype.size)) 変更後: call(fn, args, uint32(frametype.size), uint32(retOffset)) retOffsetは、frametypeから計算される戻り値の開始オフセットです。

  • callMethod関数内でreflect.callを呼び出す箇所も同様に変更されました。

  • テスト目的で、runtime.GC()reflect.callの直後に強制的に実行するためのcallGCというグローバル変数が追加されました。これはTestCallMethodJumpテストで使用されます。

src/pkg/runtime/asm_*.s (例: src/pkg/runtime/asm_amd64.s)

各アーキテクチャのアセンブリファイルでは、reflect.callの実装と、CALLFNマクロが変更されています。

  • reflect.callのスタックフレームサイズが、新しい引数retoffsetを考慮して増加しています(例: TEXT reflect·call(SB), NOSPLIT, $0-20 から $0-24)。

  • CALLFNマクロ(これはruntime·call16, runtime·call32などの実体を生成するマクロ)の内部ロジックが変更されました。 変更前は、引数フレーム全体をスタックにコピーし(入力)、その後スタック全体を引数フレームにコピーし戻していました(出力)。 変更後、戻り値をコピーする部分が以下のように変更されました。

    	/* copy return values back */
    	MOVQ	argptr+8(FP), DI        ; DI = 引数フレームの開始アドレス
    	MOVLQZX	argsize+16(FP), CX      ; CX = 引数フレームのサイズ
    	MOVLQZX retoffset+20(FP), BX    ; BX = 戻り値のオフセット
    	MOVQ	SP, SI                  ; SI = スタック上のフレームの開始アドレス
    	ADDQ	BX, DI                  ; DIを戻り値の開始アドレスに移動
    	ADDQ	BX, SI                  ; SIをスタック上の戻り値の開始アドレスに移動
    	SUBQ	BX, CX                  ; CXを戻り値のサイズに調整
    	REP;MOVSB;                      ; 戻り値のみをコピー
    

    この変更により、memmoveの対象が引数フレーム全体から、戻り値が格納される領域のみに限定されます。

src/pkg/runtime/mgc0.c

  • scanblock関数内のデバッグ出力(runtime·printf)が多数追加・修正されています。これは、GCがメモリをスキャンする際の詳細な挙動をトレースするために使用されます。例えば、scanblockがどのメモリブロックをスキャンしているか、どのような型のオブジェクトを検出したか、ポインタ、スライス、文字列、インターフェースなどの内部情報が表示されるようになっています。
  • 特に、GC_IFACEGC_EFACE(インターフェース)のスキャン時に、その内部ポインタ(tabdata)の値を出力するデバッグ情報が追加されています。これは、インターフェースの誤解釈がクラッシュの原因であったため、そのデバッグを容易にするための変更と考えられます。

src/pkg/runtime/panic.c

  • rundefer関数とrunfinq関数内でreflect.callを呼び出す箇所が、新しいシグネチャに合わせてframesz(フレームサイズ)をretoffsetとしても渡すように修正されています。これらのケースでは、引数フレーム全体が戻り値として扱われるため、frameszがそのままretoffsetとして機能します。

これらの変更により、reflect.callがスタックから引数フレームにデータをコピーする際に、不要な領域(特にレシーバが書き換えられた可能性のある入力部分)をコピーしなくなり、GCが誤った型情報に基づいてメモリをスキャンするリスクが排除されました。

コアとなるコードの解説

このコミットの核心は、reflect.call関数がスタックから引数フレームへデータをコピーする際の挙動の変更にあります。

reflect.callの変更点とretoffsetの導入

元々、reflect.callは関数呼び出しの入力(引数)をスタックにmemmoveし、呼び出し後にスタック上の出力(戻り値)を引数フレームにmemmoveし戻していました。この際、両方のmemmoveがスタックフレーム全体に対して行われていました。

問題は、埋め込みフィールドのメソッド呼び出しの際に、コンパイラがレシーバポインタを*Outerから*Innerに書き換える最適化を行う点にありました。この書き換えられた*Innerポインタが、スタックから引数フレームの*Outerスロットにコピーし戻されると、GCがその引数フレームをスキャンする際に、*Innerのメモリを*Outerとして誤って解釈し、クラッシュを引き起こす可能性がありました。

この問題を解決するため、reflect.call関数に新しい引数retoffsetが導入されました。 func call(fn, arg unsafe.Pointer, n uint32, retoffset uint32)

  • fn: 呼び出す関数のポインタ。
  • arg: ヒープ上の引数フレームのポインタ。
  • n: 引数フレームの合計サイズ。
  • retoffset: 引数フレーム内で戻り値が始まるオフセット。

このretoffsetを使用することで、アセンブリコード内のCALLFNマクロ(runtime·call16, runtime·call32などの実体を生成)は、戻り値を引数フレームにコピーし戻す際に、retoffsetから始まる領域のみを対象とするようになりました。

具体的には、アセンブリコードの/* copy return values back */セクションで、コピー元(スタック)とコピー先(引数フレーム)のアドレスにretoffsetを加算し、コピーするサイズからretoffsetを減算することで、戻り値の領域のみを正確にコピーするように変更されています。

	/* copy return values back */
	MOVL	argptr+4(FP), DI        ; DI = 引数フレームの開始アドレス
	MOVL	argsize+8(FP), CX       ; CX = 引数フレームのサイズ
	MOVL	retoffset+12(FP), BX    ; BX = 戻り値のオフセット (retoffset)
	MOVL	SP, SI                  ; SI = スタック上のフレームの開始アドレス
	ADDL	BX, DI                  ; DIを戻り値の開始アドレスに移動 (DI = DI + BX)
	ADDL	BX, SI                  ; SIをスタック上の戻り値の開始アドレスに移動 (SI = SI + BX)
	SUBL	BX, CX                  ; CXを戻り値のサイズに調整 (CX = CX - BX)
	REP;MOVSB;                      ; 戻り値のみをコピー

(上記は386アーキテクチャの例ですが、amd64やarmでも同様のロジックが適用されています。)

この変更により、関数呼び出し中にレシーバポインタが書き換えられたとしても、その変更が引数フレームの「入力」部分にコピーし戻されることがなくなります。引数フレームの「入力」部分は、GCがスキャンする際に元の型情報に基づいて正しく解釈されるため、型ミスマッチによるGCの誤解釈が回避され、クラッシュが防止されます。

mgc0.cにおけるデバッグ出力の追加

src/pkg/runtime/mgc0.cでは、ガベージコレクタのscanblock関数に多数のデバッグ出力(runtime·printf)が追加されています。これは直接的なバグ修正ではありませんが、GCがメモリをスキャンする際の挙動を詳細にトレースし、将来的なデバッグやGCの理解を深めるために非常に役立ちます。

特に、GC_PTR, GC_SLICE, GC_APTR, GC_STRING, GC_EFACE, GC_IFACEなどの様々な型のオブジェクトをGCが検出した際に、そのアドレスや内部構造に関する情報が出力されるようになっています。これにより、GCがどのようにメモリ上のポインタを識別し、追跡しているかを詳細に確認できます。

今回のバグがインターフェースの誤解釈によって引き起こされたことを考えると、GC_EFACEGC_IFACEのスキャン時に、インターフェースの内部ポインタ(型情報ポインタとデータポインタ)の値を出力するデバッグ情報が追加されたことは、問題の根本原因を特定し、修正を検証する上で重要であったと考えられます。

これらの変更は、Goのランタイムがreflectのような動的な機能と、正確なGCのような低レベルのメモリ管理機能をいかに慎重に連携させているかを示しています。

関連リンク

参考にした情報源リンク

  • コミットメッセージ: 72c5d5e7567a67335db1c6ffcbe1a8fe90b72422
  • Go言語の公式ドキュメント (reflectパッケージ, GCに関する情報など)
  • Goのソースコード (特にsrc/pkg/reflectsrc/pkg/runtimeディレクトリ)
  • GoのIssueトラッカー (Issue 7725)
  • Goのコードレビューシステム (CL 85180043)
  • Goのガベージコレクションに関する一般的な情報源 (ブログ記事、論文など)

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

このコミットでは、Goのreflectパッケージとランタイムにおけるガベージコレクション(GC)のクラッシュを修正しています。主に以下のファイルが変更されています。

  • src/pkg/reflect/all_test.go: 問題を再現するための新しいテストケースが追加されています。
  • src/pkg/reflect/export_test.go: テスト用のエクスポートが追加されています。
  • src/pkg/reflect/value.go: reflect.call関数の引数と内部ロジックが変更され、GCのトリガーをテストするためのフラグが追加されています。
  • src/pkg/runtime/asm_386.s, src/pkg/runtime/asm_amd64.s, src/pkg/runtime/asm_arm.s: 各アーキテクチャのアセンブリコードが変更され、reflect.callが返す値のみをコピーするように修正されています。
  • src/pkg/runtime/mgc0.c: ガベージコレクタのデバッグ出力が追加・修正されています。
  • src/pkg/runtime/panic.c: reflect.callの呼び出し箇所が修正されています。
  • src/pkg/runtime/runtime.h: reflect.call関数のシグネチャが更新されています。

コミット

commit 72c5d5e7567a67335db1c6ffcbe1a8fe90b72422
Author: Russ Cox <rsc@golang.org>
Date:   Tue Apr 8 11:11:35 2014 -0400

    reflect, runtime: fix crash in GC due to reflect.call + precise GC
    
    Given
            type Outer struct {
                    *Inner
                    ...
            }
    the compiler generates the implementation of (*Outer).M dispatching to
    the embedded Inner. The implementation is logically:
            func (p *Outer) M() {
                    (p.Inner).M()
            }
    but since the only change here is the replacement of one pointer
    receiver with another, the actual generated code overwrites the
    original receiver with the p.Inner pointer and then jumps to the M
    method expecting the *Inner receiver.
    
    During reflect.Value.Call, we create an argument frame and the
    associated data structures to describe it to the garbage collector,
    populate the frame, call reflect.call to run a function call using
    that frame, and then copy the results back out of the frame. The
    reflect.call function does a memmove of the frame structure onto the
    stack (to set up the inputs), runs the call, and the memmoves the
    stack back to the frame structure (to preserve the outputs).
    
    Originally reflect.call did not distinguish inputs from outputs: both
    memmoves were for the full stack frame. However, in the case where the
    called function was one of these wrappers, the rewritten receiver is
    almost certainly a different type than the original receiver. This is
    not a problem on the stack, where we use the program counter to
    determine the type information and understand that during (*Outer).M
    the receiver is an *Outer while during (*Inner).M the receiver in the
    same memory word is now an *Inner. But in the statically typed
    argument frame created by reflect, the receiver is always an *Outer.
    Copying the modified receiver pointer off the stack into the frame
    will store an *Inner there, and then if a garbage collection happens
    to scan that argument frame before it is discarded, it will scan the
    *Inner memory as if it were an *Outer. If the two have different
    memory layouts, the collection will intepret the memory incorrectly.
    
    Fix by only copying back the results.
    
    Fixes #7725.
    
    LGTM=khr
    R=khr
    CC=dave, golang-codereviews
    https://golang.org/cl/85180043

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/72c5d5e7567a67335db1c6ffcbe1a8fe90b72422

元コミット内容

このコミットは、reflect.callと正確なガベージコレクション(GC)の組み合わせによって引き起こされるクラッシュを修正します。具体的には、Goのコンパイラが埋め込みフィールドを持つ構造体のメソッドをディスパッチする際に生成するラッパー関数と、reflect.Value.Callが引数フレームを処理する方法に問題がありました。

reflect.callは、関数呼び出しのために引数フレームをスタックにコピーし、呼び出し後に結果をスタックからフレームにコピーします。元々、このコピーはスタックフレーム全体に対して行われていました。しかし、埋め込みフィールドのメソッドを呼び出すラッパー関数では、レシーバの型が呼び出し中に変更される可能性があります(例: *Outerから*Innerへ)。スタック上ではプログラムカウンタに基づいて型情報が決定されるため問題ありませんが、reflectによって作成された静的に型付けされた引数フレームでは、レシーバは常に元の型(*Outer)として扱われます。

このため、変更されたレシーバポインタ(*Inner)がスタックから引数フレームにコピーされると、GCがその引数フレームをスキャンする際に、*Innerのメモリを*Outerとして解釈してしまう可能性がありました。もし両者のメモリレイアウトが異なる場合、GCはメモリを誤って解釈し、クラッシュを引き起こす可能性がありました。

この修正は、reflect.callが結果のみをコピーするように変更することで、この問題を解決します。

変更の背景

この変更の背景には、Goのreflectパッケージとガベージコレクタ(GC)の連携における、特定のコーナーケースでのメモリ安全性問題がありました。具体的には、Goの言語機能である「埋め込みフィールド(Embedded Fields)」と、reflect.Value.Callメソッドによる動的なメソッド呼び出しが組み合わさった際に、GCが誤ったメモリ解釈を行い、クラッシュが発生するというバグ(Issue 7725)が報告されていました。

問題の核心は、コンパイラが埋め込みフィールドのメソッド呼び出しを最適化する際に生成する「ラッパー関数」の挙動と、reflect.Value.Callが引数と戻り値を処理する方法のミスマッチにありました。

  1. 埋め込みフィールドとメソッドのディスパッチ: Goでは、構造体に別の構造体を埋め込むことができます。埋め込まれた構造体のメソッドは、外側の構造体のメソッドとして「昇格」されます。例えば、Outer構造体が*Innerを埋め込み、InnerM()メソッドを持つ場合、OuterのインスタンスからM()を直接呼び出すことができます。コンパイラは、この呼び出しを効率化するために、(*Outer).M()(*Inner).M()を呼び出すようなラッパー関数を生成します。このラッパー関数は、論理的にはfunc (p *Outer) M() { (p.Inner).M() }のようになりますが、実際にはレシーバポインタを*Outerから*Innerに書き換えてから、*InnerMメソッドにジャンプするという最適化が行われます。

  2. reflect.Value.Callの動作: reflect.Value.Callは、動的に関数やメソッドを呼び出すための強力な機能です。このメソッドが呼び出されると、Goランタイムは以下の処理を行います。

    • 呼び出しに必要な引数を格納するための「引数フレーム」をメモリ上に作成します。このフレームはGCによってスキャンされる対象となります。
    • 引数フレームに引数を設定します。
    • reflect.callという内部関数を呼び出し、実際の関数呼び出しを実行します。
    • reflect.callは、引数フレームの内容をスタックにmemmove(メモリコピー)して関数呼び出しの入力とし、関数実行後にスタックから引数フレームに結果をmemmoveして出力として保存します。
  3. 問題の発生: 元々、reflect.callは入力と出力の区別なく、スタックフレーム全体をmemmoveしていました。ここで、埋め込みフィールドのメソッドを呼び出すラッパー関数が関わってきます。ラッパー関数内でレシーバポインタが*Outerから*Innerに書き換えられた後、その*Innerポインタがスタックから引数フレームにコピーされると問題が発生します。

    • スタック上では、プログラムカウンタ(PC)に基づいて型情報が正確に追跡されるため、(*Outer).Mの実行中はレシーバが*Outerとして、(*Inner).Mの実行中は同じメモリワードが*Innerとして正しく認識されます。
    • しかし、reflectによって作成された引数フレームは静的に型付けされており、レシーバは常に元の型(*Outer)として扱われます。
    • このため、*Inner型のポインタが*Outer型のスロットにコピーされることになります。
    • もし、この引数フレームが破棄される前にGCが実行され、このフレームをスキャンした場合、GCは*Innerのメモリを*Outerとして解釈しようとします。
    • もし両者のメモリレイアウトが異なる場合(特にポインタフィールドの配置が異なる場合)、GCは*Outerの型情報に基づいてメモリをスキャンするため、*Innerの内部にある非ポインタデータを誤ってポインタとして解釈したり、存在しないポインタをデリファレンスしようとしたりします。
    • インターフェースの例: コミットメッセージの例では、Outerio.Readerインターフェースを持ち、Inneruintptr型のフィールドを持つ場合に問題が顕在化しました。GCが*Inneruintptrフィールドを*Outerio.Readerインターフェースの型情報ポインタとして誤って解釈し、不正なアドレスをデリファレンスしようとしてクラッシュを引き起こしました。これは、インターフェースの型情報ポインタがnilでない場合、GCはそれをデリファレンスして具体的な型情報を取得しようとするためです。

この問題は、特にインターフェースの内部表現(itabポインタのデリファレンス)と関連して顕在化し、GCが不正なメモリアドレスを読み取ろうとすることでパニックを引き起こしていました。このコミットは、reflect.callが結果のみを引数フレームにコピーするように変更することで、この型ミスマッチによるGCの誤解釈を防ぎ、クラッシュを回避することを目的としています。

前提知識の解説

このコミットを理解するためには、以下のGo言語およびランタイムの概念について理解しておく必要があります。

1. Goのreflectパッケージ

reflectパッケージは、Goプログラムが実行時に自身の構造を検査・操作するための機能を提供します。これにより、型情報(構造体のフィールド、メソッドなど)を動的に取得したり、未知の型の値を操作したり、関数を動的に呼び出したりすることが可能になります。

  • reflect.Value: Goのあらゆる値(変数、定数、関数の結果など)を抽象的に表現する型です。reflect.ValueOf(x)でGoの値をreflect.Valueに変換できます。
  • reflect.Type: Goのあらゆる型の情報を抽象的に表現する型です。reflect.TypeOf(x)でGoの型の情報をreflect.Typeに変換できます。
  • reflect.Value.Call(in []Value) []Value: reflect.Valueが関数やメソッドを表す場合、このメソッドを使ってその関数/メソッドを動的に呼び出すことができます。引数は[]reflect.Valueとして渡し、戻り値も[]reflect.Valueとして受け取ります。

reflectパッケージは、Goの静的型付けシステムを迂回して動的な操作を可能にするため、非常に強力ですが、その分、内部的には複雑なランタイムの仕組みと連携しています。特に、動的な関数呼び出しは、コンパイラが生成する通常の関数呼び出しとは異なる特殊な処理を必要とします。

2. Goの埋め込みフィールド(Embedded Fields)とメソッドの昇格

Goの構造体は、他の構造体を「埋め込む」ことができます。これは、埋め込まれた構造体のフィールドやメソッドが、外側の構造体のフィールドやメソッドであるかのようにアクセスできる機能です。

type Inner struct {
    X int
}

func (i *Inner) M() {
    fmt.Println("Inner.M called")
}

type Outer struct {
    *Inner // Inner構造体をポインタとして埋め込む
    Y string
}

func main() {
    o := &Outer{Inner: &Inner{X: 10}, Y: "hello"}
    o.M() // OuterのインスタンスからInnerのM()メソッドを直接呼び出せる
    fmt.Println(o.X) // OuterのインスタンスからInnerのXフィールドを直接アクセスできる
}

コンパイラは、o.M()のような呼び出しを処理する際に、実際にはo.Inner.M()を呼び出すための「ラッパー関数」を生成します。このラッパー関数は、レシーバの型を*Outerから*Innerに変換し、その後Innerのメソッドにジャンプするという最適化を行うことがあります。

3. Goのガベージコレクション(GC)と正確なGC(Precise GC)

Goは自動メモリ管理(ガベージコレクション)を採用しています。GCは、プログラムがもはや到達できないメモリ領域(オブジェクト)を自動的に解放し、再利用可能にします。GoのGCは「正確なGC(Precise GC)」です。

  • 正確なGC: プログラムの実行中に、メモリ上のどの値がポインタであり、どの値がポインタでないかをGCが正確に識別できるGCのことです。これにより、GCはポインタが指すオブジェクトのみを追跡し、誤って非ポインタデータをポインタとして解釈して不正なメモリアドレスにアクセスする(そしてクラッシュする)ことを防ぎます。
  • スタックのスキャン: GCは、ヒープ上のオブジェクトだけでなく、各ゴルーチンのスタックもスキャンして、スタック上に存在するポインタ(ローカル変数や引数など)を識別し、それらが指すヒープ上のオブジェクトを「生きている」ものとしてマークします。
  • 型情報とPC: スタック上のポインタを正確に識別するためには、その時点でのプログラムカウンタ(PC)に基づいて、スタックフレーム内のどの位置にどのような型の値(ポインタか非ポインタか)が存在するかという型情報が必要です。Goランタイムは、コンパイル時に生成されたPCごとの型情報(スタックマップなど)を利用して、これを実現しています。

4. メモリレイアウトとインターフェースの内部表現

Goのインターフェースは、内部的には2つのワードで構成されます。

  • 型情報(itabまたはtype descriptor): インターフェースが保持している具体的な値の型に関する情報へのポインタ。
  • データポインタ: インターフェースが保持している具体的な値へのポインタ。

GCがインターフェースをスキャンする際、これらのポインタを追跡して、参照先のオブジェクトが生きているかどうかを判断します。もしGCが誤って非ポインタデータをインターフェースとして解釈した場合、存在しない型情報ポインタをデリファレンスしようとして、不正なメモリアクセスやクラッシュを引き起こす可能性があります。

これらの概念が複雑に絡み合い、今回のGCクラッシュ問題を引き起こしていました。

技術的詳細

このコミットが修正する問題は、reflect.Value.Callが内部的に使用するreflect.call関数と、Goの正確なガベージコレクション(GC)の相互作用に起因します。特に、埋め込みフィールドを持つ構造体のメソッド呼び出しが関与する点が重要です。

問題のメカニズム

  1. コンパイラによるレシーバの書き換え: Goのコンパイラは、埋め込みフィールドのメソッド呼び出しを最適化する際に、特殊なラッパー関数を生成します。例えば、type Outer struct { *Inner }という構造体があり、InnerM()メソッドを持つ場合、(*Outer).M()の呼び出しは、論理的には(*Outer).Inner.M()となります。しかし、コンパイラが生成するアセンブリコードでは、(*Outer).M()のレシーバである*Outerポインタを、直接*Innerポインタに書き換えてから、(*Inner).M()の実装にジャンプするという最適化が行われます。これは、レシーバの型が*Outerから*Innerへと、メモリ上の同じ位置で「変化」することを意味します。

  2. reflect.Value.Callの引数フレーム処理: reflect.Value.Callメソッドが呼び出されると、Goランタイムは以下のステップを実行します。

    • 引数フレームの作成: 呼び出される関数/メソッドの引数と戻り値を格納するための一時的なメモリ領域(「引数フレーム」)がヒープ上に確保されます。このフレームはGCの対象となります。
    • 引数のコピー(memmove: reflect.call関数は、この引数フレームの内容をスタック上の適切な位置にmemmove(メモリコピー)します。これにより、通常の関数呼び出しと同様に、引数がスタック経由で関数に渡されます。
    • 関数呼び出しの実行: 実際の関数/メソッドがスタック上で実行されます。この際、前述のコンパイラ最適化により、レシーバポインタが*Outerから*Innerに書き換えられる可能性があります。
    • 戻り値のコピー(memmove: 関数実行後、reflect.callはスタック上の戻り値を、ヒープ上の引数フレームの対応する位置にmemmoveでコピーし戻します。
  3. GCの誤解釈とクラッシュ: 問題は、この「戻り値のコピー」の段階で発生しました。元々、reflect.callは入力(引数)と出力(戻り値)を区別せず、スタックフレーム全体を引数フレームにコピーし戻していました。

    • 型情報の不一致: スタック上では、プログラムカウンタ(PC)に基づいて、レシーバが*Outerから*Innerに変化したことがGCによって正確に認識されます。しかし、ヒープ上の引数フレームは、reflectによって静的に型付けされており、レシーバのスロットは常に*Outer型として定義されています。
    • 誤ったポインタのコピー: ラッパー関数によって*Innerに書き換えられたレシーバポインタが、スタックから引数フレームの*Outerスロットにコピーされます。
    • GCスキャン時の問題: もし、この引数フレームが破棄される前にGCが実行され、このフレームをスキャンした場合、GCは*Inner型のポインタが格納されているにもかかわらず、そのスロットを*Outer型として解釈しようとします。
    • メモリレイアウトの不一致: *Outer*Innerのメモリレイアウトが異なる場合(特にポインタフィールドのオフセットや数が異なる場合)、GCは*Outerの型情報に基づいてメモリをスキャンするため、*Innerの内部にある非ポインタデータを誤ってポインタとして解釈したり、存在しないポインタをデリファレンスしようとしたりします。
    • インターフェースの例: コミットメッセージの例では、Outerio.Readerインターフェースを持ち、Inneruintptr型のフィールドを持つ場合に問題が顕在化しました。GCが*Inneruintptrフィールドを*Outerio.Readerインターフェースの型情報ポインタとして誤って解釈し、不正なアドレスをデリファレンスしようとしてクラッシュを引き起こしました。これは、インターフェースの型情報ポインタがnilでない場合、GCはそれをデリファレンスして具体的な型情報を取得しようとするためです。

修正内容

このコミットの修正は、reflect.callが「結果のみ」を引数フレームにコピーし戻すように変更することで、この問題を解決します。

  • reflect.call関数に、戻り値の開始オフセットを示す新しい引数retoffsetが追加されました。
  • アセンブリコード(runtime/asm_*.s)内のCALLFNマクロが変更され、戻り値をコピーするmemmoveの範囲が、引数フレーム全体ではなく、retoffsetから始まる戻り値の領域のみに限定されるようになりました。
  • これにより、関数呼び出し中にレシーバポインタが書き換えられたとしても、その変更されたレシーバポインタが引数フレームの「入力」部分にコピーし戻されることがなくなります。引数フレームの「入力」部分は、GCがスキャンする際に元の型情報(*Outer)に基づいて正しく解釈されるため、型ミスマッチによるGCの誤解釈が回避されます。

この修正により、reflect.Value.Callを使用した場合でも、埋め込みフィールドのメソッド呼び出しが原因でGCがクラッシュする問題が解消されました。

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

このコミットのコアとなるコード変更は、主にsrc/pkg/reflect/value.goと、各アーキテクチャのアセンブリファイル(src/pkg/runtime/asm_386.s, src/pkg/runtime/asm_amd64.s, src/pkg/runtime/asm_arm.s)に集中しています。また、ガベージコレクタのデバッグ出力に関する変更がsrc/pkg/runtime/mgc0.cにも見られます。

src/pkg/reflect/value.go

  • reflect.call関数のシグネチャが変更されました。 変更前: func call(fn, arg unsafe.Pointer, n uint32) 変更後: func call(fn, arg unsafe.Pointer, n uint32, retoffset uint32) 新しい引数retoffsetは、引数フレーム内で戻り値が始まるオフセットを示します。

  • Value.callメソッド内でreflect.callを呼び出す箇所が変更されました。 変更前: call(fn, args, uint32(frametype.size)) 変更後: call(fn, args, uint32(frametype.size), uint32(retOffset)) retOffsetは、frametypeから計算される戻り値の開始オフセットです。

  • callMethod関数内でreflect.callを呼び出す箇所も同様に変更されました。

  • テスト目的で、runtime.GC()reflect.callの直後に強制的に実行するためのcallGCというグローバル変数が追加されました。これはTestCallMethodJumpテストで使用されます。

src/pkg/runtime/asm_*.s (例: src/pkg/runtime/asm_amd64.s)

各アーキテクチャのアセンブリファイルでは、reflect.callの実装と、CALLFNマクロが変更されています。

  • reflect.callのスタックフレームサイズが、新しい引数retoffsetを考慮して増加しています(例: TEXT reflect·call(SB), NOSPLIT, $0-20 から $0-24)。

  • CALLFNマクロ(これはruntime·call16, runtime·call32などの実体を生成するマクロ)の内部ロジックが変更されました。 変更前は、引数フレーム全体をスタックにコピーし(入力)、その後スタック全体を引数フレームにコピーし戻していました(出力)。 変更後、戻り値をコピーする部分が以下のように変更されました。

    	/* copy return values back */
    	MOVQ	argptr+8(FP), DI        ; DI = 引数フレームの開始アドレス
    	MOVLQZX argsize+16(FP), CX      ; CX = 引数フレームのサイズ
    	MOVLQZX retoffset+20(FP), BX    ; BX = 戻り値のオフセット
    	MOVQ	SP, SI                  ; SI = スタック上のフレームの開始アドレス
    	ADDQ	BX, DI                  ; DIを戻り値の開始アドレスに移動
    	ADDQ	BX, SI                  ; SIをスタック上の戻り値の開始アドレスに移動
    	SUBQ	BX, CX                  ; CXを戻り値のサイズに調整
    	REP;MOVSB;                      ; 戻り値のみをコピー
    

    この変更により、memmoveの対象が引数フレーム全体から、戻り値が格納される領域のみに限定されます。

src/pkg/runtime/mgc0.c

  • scanblock関数内のデバッグ出力(runtime·printf)が多数追加・修正されています。これは、GCがメモリをスキャンする際の詳細な挙動をトレースするために使用されます。例えば、scanblockがどのメモリブロックをスキャンしているか、どのような型のオブジェクトを検出したか、ポインタ、スライス、文字列、インターフェースなどの内部情報が表示されるようになっています。
  • 特に、GC_IFACEGC_EFACE(インターフェース)のスキャン時に、その内部ポインタ(tabdata)の値を出力するデバッグ情報が追加されています。これは、インターフェースの誤解釈がクラッシュの原因であったため、そのデバッグを容易にするための変更と考えられます。

src/pkg/runtime/panic.c

  • rundefer関数とrunfinq関数内でreflect.callを呼び出す箇所が、新しいシグネチャに合わせてframesz(フレームサイズ)をretoffsetとしても渡すように修正されています。これらのケースでは、引数フレーム全体が戻り値として扱われるため、frameszがそのままretoffsetとして機能します。

これらの変更により、reflect.callがスタックから引数フレームにデータをコピーする際に、不要な領域(特にレシーバが書き換えられた可能性のある入力部分)をコピーしなくなり、GCが誤った型情報に基づいてメモリをスキャンするリスクが排除されました。

コアとなるコードの解説

このコミットの核心は、reflect.call関数がスタックから引数フレームへデータをコピーする際の挙動の変更にあります。

reflect.callの変更点とretoffsetの導入

元々、reflect.callは関数呼び出しの入力(引数)をスタックにmemmoveし、呼び出し後にスタック上の出力(戻り値)を引数フレームにmemmoveし戻していました。この際、両方のmemmoveがスタックフレーム全体に対して行われていました。

問題は、埋め込みフィールドのメソッド呼び出しの際に、コンパイラがレシーバポインタを*Outerから*Innerに書き換える最適化を行う点にありました。この書き換えられた*Innerポインタが、スタックから引数フレームの*Outerスロットにコピーし戻されると、GCがその引数フレームをスキャンする際に、*Innerのメモリを*Outerとして誤って解釈し、クラッシュを引き起こす可能性がありました。

この問題を解決するため、reflect.call関数に新しい引数retoffsetが導入されました。 func call(fn, arg unsafe.Pointer, n uint32, retoffset uint32)

  • fn: 呼び出す関数のポインタ。
  • arg: ヒープ上の引数フレームのポインタ。
  • n: 引数フレームの合計サイズ。
  • retoffset: 引数フレーム内で戻り値が始まるオフセット。

このretoffsetを使用することで、アセンブリコード内のCALLFNマクロ(runtime·call16, runtime·call32などの実体を生成)は、戻り値を引数フレームにコピーし戻す際に、retoffsetから始まる領域のみを対象とするようになりました。

具体的には、アセンブリコードの/* copy return values back */セクションで、コピー元(スタック)とコピー先(引数フレーム)のアドレスにretoffsetを加算し、コピーするサイズからretoffsetを減算することで、戻り値の領域のみを正確にコピーするように変更されています。

	/* copy return values back */
	MOVL	argptr+4(FP), DI        ; DI = 引数フレームの開始アドレス
	MOVL	argsize+8(FP), CX       ; CX = 引数フレームのサイズ
	MOVL	retoffset+12(FP), BX    ; BX = 戻り値のオフセット (retoffset)
	MOVL	SP, SI                  ; SI = スタック上のフレームの開始アドレス
	ADDL	BX, DI                  ; DIを戻り値の開始アドレスに移動 (DI = DI + BX)
	ADDL	BX, SI                  ; SIをスタック上の戻り値の開始アドレスに移動 (SI = SI + BX)
	SUBL	BX, CX                  ; CXを戻り値のサイズに調整 (CX = CX - BX)
	REP;MOVSB;                      ; 戻り値のみをコピー

(上記は386アーキテクチャの例ですが、amd64やarmでも同様のロジックが適用されています。)

この変更により、関数呼び出し中にレシーバポインタが書き換えられたとしても、その変更が引数フレームの「入力」部分にコピーし戻されることがなくなります。引数フレームの「入力」部分は、GCがスキャンする際に元の型情報に基づいて正しく解釈されるため、型ミスマッチによるGCの誤解釈が回避され、クラッシュが防止されます。

mgc0.cにおけるデバッグ出力の追加

src/pkg/runtime/mgc0.cでは、ガベージコレクタのscanblock関数に多数のデバッグ出力(runtime·printf)が追加されています。これは直接的なバグ修正ではありませんが、GCがメモリをスキャンする際の挙動を詳細にトレースし、将来的なデバッグやGCの理解を深めるために非常に役立ちます。

特に、GC_PTR, GC_SLICE, GC_APTR, GC_STRING, GC_EFACE, GC_IFACEなどの様々な型のオブジェクトをGCが検出した際に、そのアドレスや内部構造に関する情報が出力されるようになっています。これにより、GCがどのようにメモリ上のポインタを識別し、追跡しているかを詳細に確認できます。

今回のバグがインターフェースの誤解釈によって引き起こされたことを考えると、GC_EFACEGC_IFACEのスキャン時に、インターフェースの内部ポインタ(型情報ポインタとデータポインタ)の値を出力するデバッグ情報が追加されたことは、問題の根本原因を特定し、修正を検証する上で重要であったと考えられます。

これらの変更は、Goのランタイムがreflectのような動的な機能と、正確なGCのような低レベルのメモリ管理機能をいかに慎重に連携させているかを示しています。

関連リンク

参考にした情報源リンク

  • コミットメッセージ: 72c5d5e7567a67335db1c6ffcbe1a8fe90b72422
  • Go言語の公式ドキュメント (reflectパッケージ, GCに関する情報など)
  • Goのソースコード (特にsrc/pkg/reflectsrc/pkg/runtimeディレクトリ)
  • GoのIssueトラッカー (Issue 7725)
  • Goのコードレビューシステム (CL 85180043)
  • Goのガベージコレクションに関する一般的な情報源 (ブログ記事、論文など)