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

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

このコミットは、Goランタイムのスタックマップエントリのエンコーディングを変更し、ガベージコレクション(GC)における「誤った参照保持(false retention)」の問題を回避することを目的としています。特に、ゼロ長の文字列やゼロ容量のスライスが、実際には参照していないメモリ領域をGCが誤って参照していると判断し、そのメモリが解放されない問題を解決します。

コミット

Author: Keith Randall khr@golang.org Date: Tue Mar 25 14:11:34 2014 -0700

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

https://github.com/golang/go/commit/1b45cc45e37cfe67733ebff5eb5cabfef207eef6

元コミット内容

runtime: redo stack map entries to avoid false retention

Change two-bit stack map entries to encode:
0 = dead
1 = scalar
2 = pointer
3 = multiword

If multiword, the two-bit entry for the following word encodes:
0 = string
1 = slice
2 = iface
3 = eface

That way, during stack scanning we can check if a string
is zero length or a slice has zero capacity.  We can avoid
following the contained pointer in those cases.  It is safe
to do so because it can never be dereferenced, and it is
desirable to do so because it may cause false retention
of the following block in memory.

Slice feature turned off until issue 7564 is fixed.

Update #7549

LGTM=rsc
R=golang-codereviews, bradfitz, rsc
CC=golang-codereviews
https://golang.org/cl/76380043

変更の背景

Goのガベージコレクション(GC)は、プログラムが不要になったメモリを自動的に解放する役割を担っています。GCが正しく機能するためには、スタック上に存在するどの値がヒープ上のオブジェクトへのポインタであるかを正確に識別する必要があります。この識別を誤ると、「誤った参照保持(false retention)」という問題が発生します。これは、実際にはプログラムから到達不可能であるにもかかわらず、GCが誤って参照されていると判断し、メモリが解放されない状態を指します。

特に問題となるのは、Goの文字列(string)やスライス(slice)の内部表現です。これらはデータへのポインタと長さ(およびスライスでは容量)を持つ構造体です。例えば、長さがゼロの文字列や容量がゼロのスライスは、内部的にはデータへのポインタを持っていても、そのポインタが指すメモリ領域は実際には使用されていません。しかし、GCがそのポインタを単なるポインタとして認識してしまうと、たとえそのポインタが指すメモリが不要であっても、GCはそれを「生きている」と判断し、解放せずに保持してしまう可能性がありました。

このコミットは、スタックマップのエントリに、より詳細な型情報(特に文字列やスライス、インターフェースの特殊なケース)をエンコードすることで、この誤った参照保持の問題を解決しようとしています。これにより、GCはスタックをスキャンする際に、ゼロ長の文字列やゼロ容量のスライスに遭遇した場合、その内部ポインタを追跡する必要がないことを認識し、関連するメモリブロックの誤った保持を防ぐことができます。

前提知識の解説

Goのガベージコレクション (GC)

GoのGCは、並行マーク&スイープ方式を採用しています。これは、プログラムの実行と並行してGCが動作し、アプリケーションの停止時間(STW: Stop-The-World)を最小限に抑えることを目指しています。GCの基本的な流れは以下の通りです。

  1. マークフェーズ: GCは、プログラムが現在使用しているオブジェクト(「生きている」オブジェクト)を識別します。この識別は、スタックやグローバル変数など、プログラムが直接アクセスできる「GCルート」からポインタをたどることで行われます。
  2. スイープフェーズ: マークされなかったオブジェクト(「死んでいる」オブジェクト)は、不要なメモリとして解放されます。

スタックマップ

GoのGCがスタック上のポインタを正確に識別するために使用されるのが「スタックマップ」です。各関数のスタックフレームには、そのフレーム内のどの位置にポインタが存在するかを示すメタデータ(ビットベクトル)がコンパイラによって生成されます。GCはこのスタックマップを参照して、スタック上のポインタを効率的かつ正確にスキャンします。

ポインタスキャン

GCは、スタックマップの情報に基づいてスタック上のメモリ領域をスキャンし、ポインタを見つけます。見つかったポインタがヒープ上のオブジェクトを指している場合、そのオブジェクトを「生きている」とマークし、さらにそのオブジェクトが持つポインタをたどって、到達可能なすべてのオブジェクトをマークしていきます。

Goのデータ型の内部表現

  • 文字列 (string): Goの文字列は不変なバイトシーケンスです。内部的には、データへのポインタと文字列の長さ(バイト数)の2つのフィールドを持つ構造体として表現されます。
    type StringHeader struct {
        Data uintptr // データへのポインタ
        Len  int     // 長さ(バイト数)
    }
    
  • スライス (slice): Goのスライスは、基になる配列への動的なビューを提供します。内部的には、データへのポインタ、スライスの長さ、そして容量の3つのフィールドを持つ構造体として表現されます。
    type SliceHeader struct {
        Data uintptr // データへのポインタ
        Len  int     // 長さ(要素数)
        Cap  int     // 容量(要素数)
    }
    
  • インターフェース (interface): Goのインターフェースは、型と値のペアとして内部的に表現されます。
    • 空インターフェース (interface{} または any): 型情報へのポインタと、値へのポインタの2つのフィールドを持ちます。
    • 非空インターフェース (メソッドを持つインターフェース): インターフェーステーブル(itab)へのポインタと、値へのポインタの2つのフィールドを持ちます。itabは、具体的な型情報と、その型がインターフェースのメソッドをどのように実装しているかの情報を含みます。

技術的詳細

このコミットの核心は、スタックマップエントリのエンコーディングを拡張し、より詳細な型情報をGCに提供することです。以前は、ポインタか非ポインタかといった大まかな情報しか持っていませんでしたが、新しいエンコーディングでは以下の4つのカテゴリを2ビットで表現します。

  • 0 = dead (デッド): そのメモリ位置には有効な値がなく、GCがスキャンする必要がないことを示します。
  • 1 = scalar (スカラ): そのメモリ位置がポインタではないスカラ値(整数、浮動小数点数など)であることを示します。GCはスキャンする必要がありません。
  • 2 = pointer (ポインタ): そのメモリ位置がヒープ上のオブジェクトを指すポインタであることを示します。GCはこのポインタを追跡して、参照先のオブジェクトをマークします。
  • 3 = multiword (マルチワード): そのメモリ位置が、複数のワード(ポインタサイズ)にまたがる複合型(文字列、スライス、インターフェースなど)の最初のワードであることを示します。

multiwordの場合、その次のワードの2ビットエントリが、さらに詳細な型情報をエンコードします。

  • 0 = string (文字列): multiwordの次のワードが文字列の長さフィールドであることを示します。
  • 1 = slice (スライス): multiwordの次のワードがスライスの長さフィールドであることを示します。
  • 2 = iface (インターフェース): multiwordの次のワードがインターフェースの型情報(itabまたは_type)へのポインタであることを示します。
  • 3 = eface (空インターフェース): multiwordの次のワードが空インターフェースの型情報(_type)へのポインタであることを示します。

この新しいエンコーディングにより、GCはスタックをスキャンする際に、文字列やスライス、インターフェースの構造をより正確に理解できるようになります。

特に重要なのは、文字列とスライスのケースです。

  • ゼロ長の文字列: Dataポインタは存在するものの、Lenがゼロの場合、そのポインタが指すメモリは実際には使用されていません。新しいエンコーディングでは、GCはこれが文字列であることを認識し、Lenフィールドがゼロであれば、Dataポインタを追跡する必要がないと判断できます。これにより、不要なメモリの誤った保持を防ぎます。
  • ゼロ容量のスライス: 同様に、Dataポインタが存在してもCapがゼロの場合、そのポインタが指すメモリは使用されていません。GCはこれがスライスであることを認識し、Capフィールドがゼロであれば、Dataポインタを追跡する必要がないと判断できます。

このコミットでは、gcdeadという新しいデバッグオプションも導入されています。GODEBUG=gcdead=1を設定すると、GCが「dead」と判断したスタック上のスロットを特定のパターン(0x6969696969696969LL0x6868686868686868LL)で上書きするようになります。これにより、誤って「dead」とマークされたメモリが後で参照された場合に、クラッシュなどの問題を引き起こし、デバッグを容易にすることができます。

ただし、コミットメッセージには「Slice feature turned off until issue 7564 is fixed.」とあり、スライスに関する最適化は、当時未解決だったissue 7564が修正されるまで無効化されていたことが示唆されています。issue 7564は公開されているGoリポジトリでは直接見つかりませんでしたが、これは内部的なトラッキング番号である可能性があります。

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

このコミットは、Goコンパイラ(cmd/cc, cmd/gc)とランタイム(pkg/runtime)の複数のファイルにわたる変更を含んでいます。

  • src/cmd/cc/pgen.c: Cコンパイラのコード生成部分で、スタックマップの生成ロジックが変更されています。特に、ポインタ型に対してBitsPointer(2)をセットするようになり、スカラ型に対してもBitsScalar(1)をセットするループが追加されています。
  • src/cmd/gc/plive.c: Goコンパイラのライブネス解析部分で、スタックマップの生成ロジックが変更されています。twobitwalktype1関数内で、文字列、スライス、インターフェースの型に対してBitsMultiWordとそれに続く詳細な型情報(BitsString, BitsSlice, BitsIface, BitsEface)をエンコードするロジックが追加されています。
  • src/pkg/runtime/extern.go: gcdeadデバッグオプションの定義が追加されています。
  • src/pkg/runtime/malloc.h: スタックマップエントリの新しいエンコーディング定数(BitsDead, BitsScalar, BitsPointer, BitsMultiWord, BitsString, BitsSlice, BitsIface, BitsEface)が定義されています。
  • src/pkg/runtime/mfinal_test.go: ゼロ長スライスとゼロ長文字列が次のオブジェクトをメモリにピン留めしないことを確認するための新しいテストケースTestEmptySliceTestEmptyStringが追加されています。TestEmptySliceissue 7564が修正されるまで無効化されています。
  • src/pkg/runtime/mgc0.c: GCのスタックスキャンロジック(scanbitvector関数)が大幅に変更されています。新しいスタックマップエンコーディングを解釈し、BitsMultiWordの場合に続くワードの型情報に基づいて、文字列やスライスの長さ/容量をチェックし、不要なポインタ追跡をスキップするロジックが実装されています。また、gcdeadオプションが有効な場合にデッドなスロットを上書きする処理も追加されています。
  • src/pkg/runtime/runtime.c: gcdeadデバッグオプションをランタイムに登録するエントリが追加されています。
  • src/pkg/runtime/runtime.h: DebugVars構造体にgcdeadフィールドが追加されています。
  • src/pkg/runtime/stack.c: スタックのポインタ調整ロジック(adjustpointers関数)が変更されています。ここでも新しいスタックマップエンコーディングを解釈し、BitsMultiWordの場合に文字列、スライス、インターフェースの調整ロジックが更新されています。gcdeadオプションが有効な場合にデッドなスロットを上書きする処理も追加されています。

コアとなるコードの解説

src/pkg/runtime/malloc.h の変更

enum {
	// Pointer map
	BitsPerPointer = 2,
	BitsDead = 0,
	BitsScalar = 1,
	BitsPointer = 2,
	BitsMultiWord = 3,
	// BitsMultiWord will be set for the first word of a multi-word item.
	// When it is set, one of the following will be set for the second word.
	BitsString = 0,
	BitsSlice = 1,
	BitsIface = 2,
	BitsEface = 3,
};

この変更は、スタックマップエントリの新しいエンコーディングを定義しています。BitsPerPointerが2ビットであることを示し、各2ビットがDeadScalarPointerMultiWordのいずれかを表します。MultiWordの場合、次の2ビットがさらにStringSliceIfaceEfaceのいずれかを表すことで、複合型の詳細な情報をGCに伝えます。

src/pkg/runtime/mgc0.cscanbitvector 関数の変更

// 変更前 (簡略化)
// if(bits != BitsNoPointer && *(void**)scanp != nil)
//     if(bits == BitsPointer)
//         enqueue1(wbufp, (Obj){scanp, PtrSize, 0});
//     else
//         scaninterfacedata(bits, scanp, afterprologue, wbufp);

// 変更後 (抜粋)
switch(bits) {
case BitsDead:
    if(runtime·debug.gcdead)
        *(uintptr*)scanp = (uintptr)0x6969696969696969LL;
    break;
case BitsScalar:
    break;
case BitsPointer:
    p = *(byte**)scanp;
    if(p != nil)
        enqueue1(wbufp, (Obj){scanp, PtrSize, 0});
    break;
case BitsMultiWord:
    p = *(byte**)scanp;
    if(p != nil) {
        word >>= BitsPerPointer; // 次のワードのエンコーディングを取得
        scanp += PtrSize;
        i--;
        // ... (ビットの読み込みロジック) ...
        switch(word & 3) { // 次のワードのエンコーディングをチェック
        case BitsString:
            if(((String*)(scanp - PtrSize))->len != 0) // 文字列の長さがゼロでない場合のみマーク
                markonly(p);
            break;
        case BitsSlice:
            // ... (スライスの容量チェックとマークロジック) ...
            if(((Slice*)(scanp - PtrSize))->cap != 0) // スライスの容量がゼロでない場合のみマーク
                enqueue1(wbufp, (Obj){scanp - PtrSize, PtrSize, 0});
            break;
        case BitsIface:
        case BitsEface:
            scaninterfacedata(word & 3, scanp - PtrSize, afterprologue, wbufp);
            break;
        }
    }
    break;
}

このscanbitvector関数は、GCがスタック上のビットベクトルをスキャンする際の主要なロジックを含んでいます。変更前はBitsNoPointerBitsPointerの2種類しか区別していませんでしたが、変更後はswitch文を使って新しい4種類のエンコーディング(BitsDead, BitsScalar, BitsPointer, BitsMultiWord)を処理します。

特にBitsMultiWordのケースでは、さらに次のワードのエンコーディングを読み取り、それがBitsStringであれば文字列のlenフィールドを、BitsSliceであればスライスのcapフィールドをチェックします。lencapがゼロの場合、たとえポインタが存在しても、そのポインタが指すメモリは実際には使用されていないため、GCはそれを追跡しないようにmarkonlyenqueue1の呼び出しを条件付きにしています。これにより、ゼロ長の文字列やゼロ容量のスライスによる誤った参照保持を防ぎます。

BitsDeadのケースでは、runtime.debug.gcdeadが有効な場合に、そのメモリ位置を特定のパターンで上書きすることで、デバッグを容易にしています。

src/cmd/gc/plive.ctwobitwalktype1 関数の変更

// 変更前 (簡略化)
// case TSTRING:
//     bvset(bv, (*xoffset / widthptr) * BitsPerPointer); // ポインタとしてマーク

// 変更後 (抜粋)
case TSTRING:
    // struct { byte *str; intgo len; }
    // ...
    bvset(bv, (*xoffset / widthptr) * BitsPerPointer + 0); // BitsMultiWord
    bvset(bv, (*xoffset / widthptr) * BitsPerPointer + 1); // BitsString
    *xoffset += t->width;
    break;

// 変更前 (簡略化)
// case TARRAY: // スライスも含む
//     bvset(bv, (*xoffset / widthptr) * BitsPerPointer); // ポインタとしてマーク

// 変更後 (抜粋)
case TARRAY: // スライスの場合
    // struct { byte *array; uintgo len; uintgo cap; }
    // ...
    if(0) { // このブロックはissue 7564が修正されるまで無効
        bvset(bv, (*xoffset / widthptr) * BitsPerPointer + 0); // BitsMultiWord
        bvset(bv, (*xoffset / widthptr) * BitsPerPointer + 1); // BitsSlice
        bvset(bv, (*xoffset / widthptr) * BitsPerPointer + 2); // 3:1 = multiword/slice
    } else {
        // Until bug 7564 is fixed, we consider a slice as
        // a separate pointer and integer.
        bvset(bv, (*xoffset / widthptr) * BitsPerPointer + 1);  // 2 = live ptr (ポインタ)
        bvset(bv, (*xoffset / widthptr) * BitsPerPointer + 2);  // 1 = live scalar (スカラ)
    }
    // mark capacity as live
    bvset(bv, (*xoffset / widthptr) * BitsPerPointer + 4);  // 1 = live scalar (スカラ)
    *xoffset += t->width;
    break;

// インターフェースの変更も同様にBitsMultiWordとBitsIface/BitsEfaceをセット

twobitwalktype1関数は、コンパイラが型をウォークしてスタックマップのビットベクトルを生成する際に使用されます。この変更により、文字列、スライス、インターフェースといった複合型に対して、単なるポインタとしてではなく、BitsMultiWordとその後の詳細な型情報(BitsString, BitsSlice, BitsIface, BitsEface)をエンコードするようになりました。これにより、GCがこれらの型をより正確に識別できるようになります。

スライスの部分にはif(0)で囲まれたブロックがあり、これはissue 7564が修正されるまでスライスの最適化が一時的に無効化されていたことを示しています。代わりに、スライスはポインタとスカラ(長さと容量)の組み合わせとして扱われていました。

関連リンク

参考にした情報源リンク