[インデックス 18540] ファイルの概要
このコミットは、Goコンパイラ (cmd/gc
) の range.c
ファイルにおける変更です。range.c
は、Go言語の for ... range
ループのコンパイル時の変換ロジックを扱っています。具体的には、配列やスライスに対する range
ループにおいて、イテレーション中に使用されるポインタが配列の範囲外を指すことによって発生しうるガベージコレクション(GC)の問題を回避するための修正が加えられています。
コミット
commit 8b6ef69e239ac9abbb187915dbd345c0406435ec
Author: Russ Cox <rsc@golang.org>
Date: Sat Feb 15 20:00:57 2014 -0500
cmd/gc: avoid pointer beyond array in range loop
This problem was discovered by reading the code.
I have not seen it in practice, nor do I have any ideas
on how to trigger it reliably in a test. But it's still worth
fixing.
TBR=ken2
CC=golang-codereviews
https://golang.org/cl/64370046
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/8b6ef69e239ac9abbb187915dbd345c0406435ec
元コミット内容
cmd/gc: avoid pointer beyond array in range loop
この問題はコードを読んでいて発見されたものです。 実際にこの問題が発生しているのを見たことはなく、 テストで確実に再現させる方法も思いつきません。しかし、 それでも修正する価値はあります。
TBR=ken2 CC=golang-codereviews https://golang.org/cl/64370046
変更の背景
このコミットの背景には、Go言語の for ... range
ループがコンパイル時にどのように内部的に変換されるかという実装の詳細に起因する潜在的な問題がありました。具体的には、配列やスライスをイテレートする際に、ループ内で使用されるポインタが、最終イテレーションの後に配列の境界を越えてしまう可能性がありました。
コミットメッセージにもあるように、この問題はコードレビュー中に発見されたもので、実際にこの挙動が原因でバグが発生したという報告はなかったようです。しかし、ポインタが配列の終端を超えてしまうという状況は、ガベージコレクタ(GC)にとって予期せぬ参照となり、本来解放されるべきメモリが解放されずに保持され続ける(メモリリークのような状態)可能性を秘めていました。
Goのガベージコレクタは、到達可能性(reachability)に基づいてメモリを管理します。つまり、プログラムから到達可能なオブジェクトは解放されず、到達不可能になったオブジェクトは解放されます。もし、ループ内で使用されるポインタが、ループの終了後も一時的に配列の範囲外のメモリを指し示し続けると、そのポインタが指すメモリ領域がGCによって「到達可能」と誤認識され、本来回収されるべきメモリが回収されないという事態が発生する恐れがありました。
このコミットは、このような潜在的なGCの非効率性や誤動作を防ぐために、たとえ再現ケースがなくても修正する価値があるという判断のもとで行われました。これは、Goランタイムの堅牢性と信頼性を高めるための予防的な措置と言えます。
前提知識の解説
このコミットの変更内容を理解するためには、以下のGo言語およびコンパイラに関する前提知識が必要です。
-
Goの
for ... range
ループ: Go言語のfor ... range
ループは、配列、スライス、文字列、マップ、チャネルなどのコレクションをイテレートするための構文です。配列やスライスの場合、for index, value := range collection
の形式で、インデックスと要素のコピーを順に取得できます。 コンパイラは、この高レベルなrange
構文を、より低レベルなfor
ループ(インデックス変数とポインタ変数を使ったループ)に変換(desugar)します。 -
Goのガベージコレクション (GC): Goは自動メモリ管理(ガベージコレクション)を採用しています。プログラムが動的に確保したメモリのうち、もはやプログラムから到達不可能になったメモリ領域を自動的に解放します。GCは、メモリリークを防ぎ、開発者が手動でメモリを管理する負担を軽減します。GCは、プログラム内のポインタを追跡し、どのメモリがまだ使用されているかを判断します。もし、不要になったポインタがまだ有効なメモリを指しているとGCが判断した場合、そのメモリは解放されません。
-
Goコンパイラ (
cmd/gc
):cmd/gc
はGo言語の公式コンパイラです。Goのソースコードを機械語に変換する役割を担います。コンパイルの過程で、ソースコードは抽象構文木(AST: Abstract Syntax Tree)にパースされ、様々な最適化や変換が施されます。range
ループの変換もこの段階で行われます。 -
AST (Abstract Syntax Tree) と
Node
構造体: コンパイラはソースコードを直接操作するのではなく、まずその構造をASTとして表現します。ASTは、プログラムの構造を木構造で表現したものです。cmd/gc
では、このASTの各要素がNode
構造体で表現されます。Node *n
: ASTのノードを指すポインタ。OADD
: 加算演算子を表すASTノードの種類(Opcode)。OAS
: 代入演算子を表すASTノードの種類。OIND
: 間接参照(ポインタのデリファレンス)を表すASTノードの種類。list()
: ASTノードのリストを作成するヘルパー関数。nod()
: 新しいASTノードを作成するヘルパー関数。n->nincr
:for
ループのインクリメント部分を表すASTノード。
-
ポインタとメモリ参照: Goでは、ポインタはメモリ上の特定のアドレスを指します。ガベージコレクタは、これらのポインタを辿ることで、どのメモリがまだ参照されているかを判断します。ポインタが有効なメモリ領域を指している限り、そのメモリはGCの対象外となります。
技術的詳細
このコミットの技術的な核心は、Goコンパイラが for ... range
ループを配列やスライスに対してどのように変換するか、そしてその変換プロセスにおけるポインタの管理方法にあります。
Goの for ... range
ループは、コンパイル時に以下のような低レベルな for
ループに「脱糖(desugar)」されます(簡略化された擬似コード):
// 元のGoコード
for i, v := range arr {
// ループ本体
}
// コンパイラによる変換(簡略化)
length := len(arr)
p := &arr[0] // 配列の最初の要素へのポインタ
for i := 0; i < length; i++ {
v := *p // ポインタが指す値をデリファレンスしてvに代入
// ループ本体
p++ // ポインタを次の要素へ進める
}
問題は、この p++
の部分がループ本体の実行「前」に行われていたことにありました。最終イテレーションにおいて、ループ本体が実行される前に p++
が行われると、p
は配列の最後の要素の次、つまり配列の境界を越えたメモリ領域を指すことになります。
コミットメッセージのコメントにあるように、
// Advance pointer as part of increment.
// We used to advance the pointer before executing the loop body,
// but doing so would make the pointer point past the end of the
// array during the final iteration, possibly causing another unrelated
// piece of memory not to be garbage collected until the loop finished.
// Advancing during the increment ensures that the pointer p only points
// pass the end of the array during the final "p++; i++; if(i >= len(x)) break;",
// after which p is dead, so it cannot confuse the collector.
以前の実装では、ポインタ p
のインクリメントがループ本体の実行前に行われていました。これにより、最終イテレーションでは、ループ本体が実行される前に p
が配列の終端を超えてしまう可能性がありました。この「配列の終端を超えたポインタ」が一時的に存在することで、ガベージコレクタがそのポインタを有効な参照と誤解し、本来回収されるべきメモリ(例えば、そのポインタが偶然指してしまった別のオブジェクトのメモリ)が回収されないという、潜在的なGCの問題を引き起こす可能性がありました。
この修正は、ポインタ p
のインクリメントを、ループの「インクリメント部分」(n->nincr
)に移動させることで、この問題を解決しています。これにより、p
が配列の終端を超えて指すのは、p++; i++; if(i >= len(x)) break;
のようなループのインクリメントと条件チェックのフェーズ中のみとなり、その直後に p
は「デッド」(もはや使用されない)となるため、ガベージコレクタを混乱させることはなくなります。つまり、ポインタが配列外を指す期間を最小限にし、その期間中にGCが走っても問題ないようにしています。
コアとなるコードの変更箇所
変更は src/cmd/gc/range.c
ファイルの walkrange
関数内で行われています。
--- a/src/cmd/gc/range.c
+++ b/src/cmd/gc/range.c
@@ -173,13 +173,23 @@ walkrange(Node *n)\n \t\t\ta->list = list(list1(v1), v2);\n \t\t\ta->rlist = list(list1(hv1), nod(OIND, hp, N));\n \t\t\tbody = list1(a);\n-\n+\t\t\t\n+\t\t\t// Advance pointer as part of increment.\n+\t\t\t// We used to advance the pointer before executing the loop body,\n+\t\t\t// but doing so would make the pointer point past the end of the\n+\t\t\t// array during the final iteration, possibly causing another unrelated\n+\t\t\t// piece of memory not to be garbage collected until the loop finished.\n+\t\t\t// Advancing during the increment ensures that the pointer p only points\n+\t\t\t// pass the end of the array during the final "p++; i++; if(i >= len(x)) break;\",\n+\t\t\t// after which p is dead, so it cannot confuse the collector.\n \t\t\ttmp = nod(OADD, hp, nodintconst(t->type->width));\n \t\t\ttmp->type = hp->type;\n \t\t\ttmp->typecheck = 1;\n \t\t\ttmp->right->type = types[tptr];\n \t\t\ttmp->right->typecheck = 1;\n-\t\t\tbody = list(body, nod(OAS, hp, tmp));\n+\t\t\ta = nod(OAS, hp, tmp);\n+\t\t\ttypecheck(&a, Etop);\n+\t\t\tn->nincr->ninit = list1(a);\n \t\t}\n \t\tbreak;\n \n```
具体的には、以下の行が変更されています。
* 削除された行:
```c
body = list(body, nod(OAS, hp, tmp));
```
これは、ポインタ `hp` をインクリメントする代入操作 (`nod(OAS, hp, tmp)`) をループの `body` (本体) の一部として追加していました。
* 追加された行:
```c
a = nod(OAS, hp, tmp);
typecheck(&a, Etop);
n->nincr->ninit = list1(a);
```
ここで、同じポインタインクリメントの代入操作 (`nod(OAS, hp, tmp)`) が行われますが、その結果生成されたASTノード `a` は、ループのインクリメント部分 (`n->nincr->ninit`) に追加されています。`typecheck(&a, Etop)` は、新しく作成されたASTノードの型チェックを行うためのものです。
## コアとなるコードの解説
この変更の目的は、`for ... range` ループがコンパイル時に変換される際の、内部的なポインタ `hp` のインクリメントのタイミングを調整することです。
* **変更前**:
`body = list(body, nod(OAS, hp, tmp));`
この行は、ポインタ `hp` をインクリメントする操作(`hp = hp + width` に相当)を、ループの「本体」(`body`)の最後に組み込んでいました。つまり、各イテレーションでループ本体の処理が完了した後、次のイテレーションの準備としてポインタがインクリメントされていました。しかし、最終イテレーションでは、ループ本体の処理が終わり、ポインタがインクリメントされた後、ループが終了します。このとき、`hp` は配列の終端を超えたアドレスを指したままになる可能性がありました。もしこの時点でガベージコレクタが動作した場合、`hp` が指す(本来無関係な)メモリ領域が「到達可能」と誤認識され、回収されないという問題が発生しうるのです。
* **変更後**:
```c
a = nod(OAS, hp, tmp);
typecheck(&a, Etop);
n->nincr->ninit = list1(a);
```
この変更により、ポインタ `hp` のインクリメント操作は、ループの「インクリメント部分」(`n->nincr->ninit`)に移動されました。`n->nincr` は、`for` ループの `for i := 0; i < length; i++` の `i++` の部分に相当するASTノードです。
これにより、ポインタ `hp` がインクリメントされるのは、各イテレーションの終わりに、次のイテレーションの条件チェックの直前になります。最終イテレーションでは、ポインタがインクリメントされ、その直後にループの終了条件が評価され、ループが終了します。この時点で `hp` は配列の終端を超えているかもしれませんが、その直後に `hp` はもはや使用されなくなる(デッドになる)ため、ガベージコレクタが `hp` を追跡して誤ったメモリを保持し続けるリスクがなくなります。
要するに、この修正は、ポインタが配列の境界外を指す期間を極めて短くし、その期間中にガベージコレクタが誤動作する可能性を排除することで、Goランタイムのメモリ管理の堅牢性を向上させています。これは、コードの正確性を追求し、潜在的なバグの芽を摘むための、予防的かつ重要な改善と言えます。
## 関連リンク
* Go言語の `for` ステートメントの仕様: [https://go.dev/ref/spec#For_statements](https://go.dev/ref/spec#For_statements)
* Go言語のガベージコレクションに関する公式ブログ記事 (古いものも含むが概念は参考になる): [https://go.dev/blog/go15gc](https://go.dev/blog/go15gc)
* Goコンパイラのソースコード (特に `src/cmd/compile/internal/gc` ディレクトリ): [https://github.com/golang/go/tree/master/src/cmd/compile/internal/gc](https://github.com/golang/go/tree/master/src/cmd/compile/internal/gc)
## 参考にした情報源リンク
* GitHub: golang/go commit 8b6ef69e239ac9abbb187915dbd345c0406435ec: [https://github.com/golang/go/commit/8b6ef69e239ac9abbb187915dbd345c0406435ec](https://github.com/golang/go/commit/8b6ef69e239ac9abbb187915dbd345c0406435ec)
* Go Code Review 64370046: cmd/gc: avoid pointer beyond array in range loop: [https://golang.org/cl/64370046](https://golang.org/cl/64370046)
* Go言語の公式ドキュメントおよび仕様書
* Go言語のコンパイラに関する一般的な知識