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

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

このコミットは、Goコンパイラ(cmd/gc)におけるガベージコレクション(GC)の安全性に関する重要な修正です。具体的には、ランタイム関数(chanrecvmapiterinitなど)に初期化されていない一時変数へのポインタが渡される際に発生する潜在的な問題を解決します。これにより、GCがこれらの変数をスキャンする際に安全性が確保され、プログラムの安定性が向上します。

コミット

commit e150ca9c9aba9b8d8e61d0953ea4b90deef620bc
Author: Russ Cox <rsc@golang.org>
Date:   Fri Mar 28 11:30:02 2014 -0400

    cmd/gc: never pass ptr to uninit temp to runtime

    chanrecv now expects a pointer to the data to be filled in.
    mapiterinit expects a pointer to the hash iterator to be filled in.
    In both cases, the temporary being pointed at changes from
    dead to alive during the call. In order to make sure it is
    preserved if a garbage collection happens after that transition
    but before the call returns, the temp must be marked as live
    during the entire call.

    But if it is live during the entire call, it needs to be safe for
    the garbage collector to scan at the beginning of the call,
    before the new data has been filled in. Therefore, it must be
    zeroed by the caller, before the call. Do that.

    My previous attempt waited to mark it live until after the
    call returned, but that's unsafe (see first paragraph);
    undo that change in plive.c.

    This makes powser2 pass again reliably.

    I looked at every call to temp in the compiler.
    The vast majority are followed immediately by an
    initialization of temp, so those are fine.
    The only ones that needed changing were the ones
    where the next operation is to pass the address of
    the temp to a function call, and there aren't too many.

    Maps are exempted from this because mapaccess
    returns a pointer to the data and lets the caller make
    the copy.

    Fixes many builds.

    TBR=khr
    CC=golang-codereviews
    https://golang.org/cl/80700046

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

https://github.com/golang/go/commit/e150ca9c9aba9b8d8e61d0953ea4b90deef620bc

元コミット内容

cmd/gc: never pass ptr to uninit temp to runtime

このコミットは、Goコンパイラ(cmd/gc)において、初期化されていない一時変数へのポインタをランタイムに渡さないようにする変更です。

chanrecv(チャネル受信)とmapiterinit(マップイテレータ初期化)は、データが書き込まれるポインタを期待します。これらの呼び出しでは、ポインタが指す一時変数は、呼び出し中に「死んだ」状態から「生きた」状態に変化します。この変化後、かつ呼び出しが戻る前にガベージコレクションが発生した場合に、その一時変数が正しく保持されるようにするためには、呼び出し全体を通してその一時変数を「生きた」とマークする必要があります。

しかし、もし呼び出し全体を通して「生きた」とマークされる場合、新しいデータが書き込まれる前の、呼び出しの開始時にガベージコレクタがその変数をスキャンしても安全である必要があります。したがって、呼び出し元が呼び出し前にその変数をゼロ初期化する必要があります。このコミットはその処理を行います。

以前の試みでは、変数を「生きた」とマークするのを呼び出しが戻った後まで待っていましたが、これは安全ではないため(上記参照)、plive.cでのその変更は元に戻されました。

この修正により、powser2テストが確実にパスするようになりました。

コンパイラ内でのtemp(一時変数)へのすべての呼び出しを調査しました。その大部分は、tempの直後に初期化が続くため問題ありません。変更が必要だったのは、次の操作がtempのアドレスを関数呼び出しに渡す場合のみであり、そのようなケースは多くありません。

マップは、mapaccessがデータへのポインタを返し、呼び出し元がコピーを行うため、このルールの例外となります。

多くのビルドの問題を修正します。

変更の背景

この変更の背景には、Goのガベージコレクタ(GC)とコンパイラが生成する一時変数(テンポラリ)のライフサイクル管理における微妙な問題がありました。

GoのGCは、プログラムが使用しているメモリを追跡し、不要になったメモリを解放する役割を担っています。GCは、ポインタをたどって到達可能なオブジェクトを「生きている」(live)と判断し、それらを保持します。

問題は、chanrecv(チャネルからの値の受信)やmapiterinit(マップイテレータの初期化)のような特定のランタイム関数が、結果を格納するための一時変数へのポインタを受け取る際に発生しました。これらの関数が呼び出されると、その一時変数は、呼び出しの途中で「死んだ」(GCの対象外)状態から「生きた」(GCの対象)状態へと変化します。

もし、この「死んだ」から「生きた」への状態遷移が起こった直後、かつランタイム関数がその一時変数にデータを書き込む前にガベージコレクションが実行された場合、GCは初期化されていない、つまり不定な値を持つメモリ領域をスキャンしようとする可能性がありました。これは、GCが不正なポインタを読み取ってクラッシュしたり、誤ったメモリを解放したりする原因となり、プログラムの不安定性やクラッシュ(特にpowser2テストのような特定のシナリオで顕著)を引き起こしていました。

このコミットは、このような状況下でのGCの安全性を確保し、Goプログラムの堅牢性を高めることを目的としています。

前提知識の解説

このコミットを理解するためには、以下のGoの内部動作に関する知識が役立ちます。

  • Goのガベージコレクション (GC): Goは、自動メモリ管理のためにトレース型ガベージコレクタを使用しています。これは、プログラムがアクセス可能な(「生きている」)オブジェクトを特定し、それ以外のオブジェクトを「死んでいる」と見なしてメモリを解放します。GCは、プログラムの実行中に非同期的に動作することがあり、特定のタイミングでヒープ上のポインタをスキャンしてオブジェクトの到達可能性を判断します。
  • 一時変数 (Temporary Variables): Goコンパイラ(cmd/gc)は、式の評価や関数呼び出しの引数準備などのために、一時的な変数を生成します。これらの一時変数は通常、スタック上に割り当てられ、そのスコープが非常に短い場合があります。
  • ポインタ (Pointers): Goにおけるポインタは、メモリ上の特定のアドレスを指します。GCは、ポインタをたどることで、どのメモリが使用中であるかを判断します。
  • chanrecvmapiterinit: これらはGoランタイムの一部である関数です。
    • chanrecv: チャネルから値を受信する操作を処理します。受信した値は、呼び出し元が提供するメモリ位置(ポインタで指定)に書き込まれます。
    • mapiterinit: for...rangeループでマップをイテレートする際に使用されるマップイテレータを初期化します。イテレータの状態は、呼び出し元が提供するメモリ位置(ポインタで指定)に書き込まれます。
  • cmd/gc: Goコンパイラの主要部分であり、Goのソースコードを中間表現に変換し、最終的に機械語を生成します。このプロセスには、メモリ割り当て、変数管理、GCとの連携に関するコードの生成が含まれます。
  • ライブネス解析 (Liveness Analysis): コンパイラ最適化の一種で、プログラムの特定のポイントでどの変数が「生きている」(将来的に使用される可能性がある)かを判断します。GCは、このライブネス情報に基づいて、どのメモリ領域をスキャンする必要があるかを決定します。
  • ゼロ初期化 (Zero Initialization): Goでは、変数が宣言されると、その型のゼロ値で自動的に初期化されます(例: 整数は0、ブール値はfalse、ポインタはnil)。しかし、コンパイラが生成する特定の一時変数は、最適化のために明示的なゼロ初期化が省略される場合があります。

技術的詳細

このコミットの核心は、Goのコンパイラが生成する一時変数とガベージコレクタの相互作用における潜在的な競合状態を解消することにあります。

  1. 問題の特定:

    • chanrecvmapiterinitのようなランタイム関数は、結果を格納するために、呼び出し元から渡された一時変数へのポインタを使用します。
    • これらの関数が呼び出される際、ポインタが指す一時変数は、その時点ではまだデータが書き込まれていない(初期化されていない)状態です。しかし、ランタイム関数がその変数にデータを書き込むと、その変数は「有効なデータを持つ」状態、つまりGCがスキャンすべき「生きている」状態に変化します。
    • もし、この状態変化の直後、かつランタイム関数がデータを書き込む前にGCが実行された場合、GCは初期化されていない(不定な値を持つ)メモリ領域をスキャンしようとします。この不定な値がたまたま有効なポインタのように見えてしまうと、GCは不正なメモリ領域をたどろうとし、クラッシュやメモリ破損を引き起こす可能性がありました。
  2. 以前の試みとその問題点:

    • コミットメッセージによると、以前の試みでは、一時変数を「生きている」とマークするタイミングを、ランタイム関数が戻った後まで遅らせていました。
    • しかし、これは安全ではありませんでした。なぜなら、ランタイム関数が呼び出されている間にもGCは発生する可能性があり、その間に一時変数が「死んだ」とマークされていると、GCによって誤って解放されたり、スキャンされなかったりするリスクがあったからです。コミットメッセージの「see first paragraph」が示すように、呼び出し中に一時変数が「生きた」状態に変化する以上、呼び出し全体を通して「生きた」とマークされるべきでした。
  3. 今回の解決策:

    • 一時変数のゼロ初期化の強制: 問題の根本原因は、GCがスキャンする可能性のある一時変数が初期化されていないことでした。そこで、このコミットでは、chanrecvmapiterinitのようなランタイム関数にポインタを渡す前に、コンパイラがその一時変数を明示的にゼロ値で初期化するように変更しました。
    • これにより、GCが関数呼び出しの開始時(データがまだ書き込まれていない時点)に一時変数をスキャンしても、その内容はゼロ値(ポインタの場合はnil)であるため、安全に処理できます。GCはnilポインタをたどることはなく、不正なメモリアクセスを防ぎます。
    • ライブネスの管理: 一時変数は、ランタイム関数への呼び出し全体を通して「生きている」とマークされます。これにより、GCがいつ実行されても、その変数が適切に扱われることが保証されます。ゼロ初期化と組み合わせることで、このアプローチは安全かつ堅牢になります。
    • plive.cの変更の巻き戻し: 以前の安全でない試み(ライブネスのマークを遅らせる変更)は、src/cmd/gc/plive.cで元に戻されました。これは、ライブネス解析のロジックを修正し、一時変数がより早く「生きている」と認識されるようにするためです。
    • 影響範囲の限定: コンパイラが生成する一時変数の大部分は、生成直後に初期化されるため、この問題の影響を受けません。この修正は、一時変数のアドレスがランタイム関数に渡され、その関数内でデータが書き込まれる特定のケースに焦点を当てています。
    • マップの例外: mapaccess(マップからの値の読み取り)は、データへのポインタを返し、呼び出し元がそのデータをコピーするため、この問題の影響を受けません。これは、mapaccessがデータを「書き込む」のではなく「読み取る」操作であり、一時変数の初期化状態が問題にならないためです。

この修正により、powser2という特定のテストケースが安定してパスするようになりました。これは、この問題が特定の複雑なコードパターン(おそらくチャネルやマップの操作が絡むもの)で顕在化していたことを示唆しています。

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

このコミットは、Goコンパイラの以下のファイルに影響を与えています。

  • src/cmd/gc/plive.c: ライブネス解析に関連する変更。以前の安全でない変更を元に戻しています。
    • progeffects関数内で、from->node->addrtakenの場合のbvset(avarinit, pos);の条件を緩和し、常にセットするように変更。
  • src/cmd/gc/range.c: for...rangeループのコンパイルに関連する変更。
    • walkrange関数内で、マップイテレータ(hiter)やチャネル受信(TCHAN)のための一時変数をゼロ初期化するコードを追加。
      • init = list(init, nod(OAS, hit, N));
      • if(haspointers(t->type)) init = list(init, nod(OAS, hv1, N));
  • src/cmd/gc/select.c: selectステートメントのコンパイルに関連する変更。
    • walkselect関数内で、チャネル受信のための一時変数をゼロ初期化するコードを追加。
      • if(n->colas && haspointers(ch->type->type)) { ... }
      • if(haspointers(ch->type->type)) { ... }
  • src/cmd/gc/walk.c: 一般的な式のウォーク(変換)に関連する変更。
    • walkexpr関数内で、chanrecv2chanrecv1(チャネル受信)のための一時変数をゼロ初期化するコードを追加。
      • if(haspointers(var->type)) { ... }

これらの変更の主なパターンは、haspointers(type)というヘルパー関数を使って、一時変数がポインタを含む型である場合に、nod(OAS, var, N)var = nilに相当する代入ノード)を生成し、初期化リストに追加している点です。

コアとなるコードの解説

変更の核心は、Goコンパイラがランタイム関数に渡す一時変数が、ポインタを含む型である場合に、その変数を明示的にゼロ値(ポインタの場合はnil)で初期化するコードを挿入することです。

例えば、src/cmd/gc/range.cwalkrange関数では、マップイテレータのためのhitという一時変数が生成された後、以下の行が追加されています。

init = list(init, nod(OAS, hit, N));

これは、コンパイル時にhit = nil(またはその型のゼロ値)という代入操作を生成し、それを初期化リスト(init)に追加することを意味します。これにより、mapiterinitランタイム関数にhitのアドレスが渡される前に、hitが確実にゼロ初期化されます。

同様に、チャネル受信に関連するコード(src/cmd/gc/range.c, src/cmd/gc/select.c, src/cmd/gc/walk.c)では、受信した値を格納するための一時変数(例: hv1, tmp, var)に対して、haspointers関数でポインタを含む型であるかを確認し、もしそうであれば同様のゼロ初期化のコードが挿入されます。

if(haspointers(ch->type->type)) {
    // clear tmp for garbage collector, because the recv
    // must execute with tmp appearing to be live.
    r = nod(OAS, tmp, N);
    typecheck(&r, Etop);
    sel->ninit = concat(sel->ninit, list1(r));
}

このコードブロックは、tmpという一時変数がポインタを含む型である場合、tmp = nilという代入を生成し、それをselectステートメントの初期化リスト(sel->ninit)に追加します。コメントにあるように、これはガベージコレクタのためにtmpをクリアするものであり、受信操作が実行される際にtmpが「生きている」と見なされる必要があるためです。

src/cmd/gc/plive.cの変更は、ライブネス解析のロジックを微調整し、一時変数がより適切に「生きている」と判断されるようにするためのものです。これにより、GCがこれらの変数をスキャンするタイミングと、それらがゼロ初期化されていることの整合性が保たれます。

これらの変更により、ランタイム関数に渡される一時変数が、GCがスキャンする可能性のある期間中、常に安全な(ゼロ初期化された)状態であることが保証され、不定なメモリ内容によるGCのクラッシュや誤動作が防止されます。

関連リンク

  • Go Change-Id: Ie150ca9c9aba9b8d8e61d0953ea4b90deef620bc (Gerrit Code Reviewの内部ID)
  • Go CL 80700046: https://golang.org/cl/80700046 (このコミットに対応するGoのコードレビューページ)

参考にした情報源リンク

  • Goの公式ドキュメント (Goのガベージコレクション、コンパイラ、ランタイムに関する一般的な情報)
  • Goのソースコード (特にsrc/cmd/gcディレクトリ内のファイル)
  • GoのIssueトラッカー (関連するバグ報告や議論がある場合)
  • Goのメーリングリスト (golang-dev, golang-nutsなどでの議論)

(注:具体的な参照リンクは、コミットメッセージから直接得られる情報と、一般的なGoの内部に関する知識に基づいています。特定の外部記事や詳細な設計ドキュメントへの直接的なリンクはコミットメッセージには含まれていません。)