[インデックス 18500] ファイルの概要
このコミットは、Goコンパイラ(cmd/gc
)におけるエスケープ解析のバグ修正に関するものです。具体的には、for
ループの初期化ステートメントが、エスケープ解析によって誤ってループ内部のスコープとして扱われていた問題を解決します。これにより、本来エスケープしないはずの変数が誤ってヒープに割り当てられる可能性がありました。
コミット
e0a55a6c9826f3b0548a2d78be82931ad73ac218
Author: Daniel Morsing daniel.morsing@gmail.com
Date: Thu Feb 13 19:04:43 2014 +0000
cmd/gc: for loop init statement misanalyzed by escape analysis
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/e0a55a6c9826f3b0548a2d78be82931ad73ac218
元コミット内容
cmd/gc: for loop init statement misanalyzed by escape analysis
Logically, the init statement is in the enclosing scopes loopdepth, not inside the for loop.
Fixes #7313.
LGTM=rsc
R=golang-codereviews, gobot, rsc
CC=golang-codereviews
https://golang.org/cl/62430043
変更の背景
Go言語のコンパイラには、プログラムのパフォーマンスを最適化するための「エスケープ解析(Escape Analysis)」という重要な機能があります。エスケープ解析は、変数がヒープに割り当てられるべきか、それともスタックに割り当てられるべきかを決定します。スタック割り当てはヒープ割り当てよりも高速であり、ガベージコレクションの負荷を軽減するため、可能な限りスタック割り当てが推奨されます。
このコミットが修正する問題は、Goのfor
ループの初期化ステートメント(例: for p := &l; ...
の p := &l
部分)が、エスケープ解析の過程で誤ってループ本体のスコープ(loopdepth
)に属すると判断されていたことです。論理的には、初期化ステートメントはループが始まる前の、そのループを囲むスコープの一部として実行されます。しかし、コンパイラのエスケープ解析器がこの論理的なスコープと物理的なコード構造を混同し、初期化ステートメントで宣言された変数が、ループ内部でエスケープすると誤って判断される可能性がありました。
具体的には、Issue #7313で報告された問題は、for
ループの初期化部分で宣言されたポインタが、実際にはループの外側のスコープで有効であるにもかかわらず、ループ内部のloopdepth
でエスケープすると誤って判断され、不必要にヒープに割り当てられてしまうというものでした。これは、プログラムの実行効率に悪影響を与える可能性があります。
前提知識の解説
Goコンパイラ (cmd/gc
)
cmd/gc
は、Go言語の公式コンパイラです。Goのソースコードを機械語に変換する役割を担っています。コンパイルの過程で、構文解析、型チェック、最適化(エスケープ解析を含む)、コード生成など、様々なフェーズを実行します。
エスケープ解析 (Escape Analysis)
エスケープ解析は、コンパイラ最適化の一種で、変数のメモリ割り当て場所(スタックかヒープか)を決定します。
- スタック割り当て: 関数内で宣言され、その関数の実行が終了すると不要になる変数は、通常スタックに割り当てられます。スタックは高速で、メモリの解放も自動的に行われます。
- ヒープ割り当て: 変数が関数のスコープを「エスケープ」し、関数の終了後も参照され続ける可能性がある場合(例: ポインタが関数の外に返される、グローバル変数に代入されるなど)、その変数はヒープに割り当てられます。ヒープはより柔軟なメモリ管理を提供しますが、割り当てと解放(ガベージコレクション)のオーバーヘッドがあります。
エスケープ解析の目的は、不必要なヒープ割り当てを減らし、ガベージコレクションの頻度と時間を削減することで、プログラムのパフォーマンスを向上させることです。
コンパイラのAST (Abstract Syntax Tree) と Node
構造体
Goコンパイラは、ソースコードを解析する際に、その構造を抽象構文木(AST)として表現します。ASTの各要素はNode
構造体で表されます。Node
は、変数宣言、関数呼び出し、ループ、条件分岐など、プログラムのあらゆる構文要素を表現します。
for
ループを表すNode
には、以下のようなフィールドがあります。
ninit
: ループの初期化ステートメント(例:for i := 0; ...
のi := 0
)。ntest
: ループの継続条件(例:...; i < 10; ...
のi < 10
)。nincr
: ループの各イテレーション後に実行されるステートメント(例:...; i++
のi++
)。nbody
: ループの本体。op
: ノードの種類を示すオペレーションコード(例:OFOR
はfor
ループ、ORANGE
はfor range
ループ)。
loopdepth
エスケープ解析のコンテキストにおいて、loopdepth
は現在のコードがどれだけ深いループの中にいるかを示すカウンターのようなものです。この値は、変数の寿命とエスケープの可能性を判断する際に使用されます。ループの深さが変わると、変数のスコープや寿命に関するエスケープ解析のルールも変わる可能性があります。
技術的詳細
このコミットの核心は、for
ループの初期化ステートメント(ninit
)が、エスケープ解析のloopdepth
の扱いで誤解されていた点にあります。
Goのfor
ループの構文は以下のようになります。
for init; condition; post {
body
}
ここで、init
ステートメントはループが始まる前に一度だけ実行されます。論理的には、このinit
ステートメントで宣言された変数のスコープは、for
ループ自体を囲むスコープと同じloopdepth
に属します。しかし、エスケープ解析のコードでは、OFOR
やORANGE
ノードを処理する際に、まずloopdepth
をインクリメントしてから、ninit
を含むすべてのサブノードに対してエスケープ解析を実行していました。
これにより、ninit
で宣言された変数が、あたかもループ本体の内部で宣言されたかのように、誤ったloopdepth
で解析されていました。結果として、本来スタックに割り当てられるべき変数が、ループ内部でエスケープすると誤って判断され、ヒープに割り当てられてしまうというバグが発生していました。
この修正は、ninit
ステートメントのエスケープ解析を、loopdepth
をインクリメントする前に行うことで、この論理的な誤りを正しています。つまり、ninit
はループを囲むスコープのloopdepth
で解析され、その後のcondition
、post
、body
はインクリメントされたloopdepth
で解析されるようになります。
コアとなるコードの変更箇所
変更は主にsrc/cmd/gc/esc.c
ファイルと、その動作を検証するためのtest/escape2.go
ファイルにあります。
src/cmd/gc/esc.c
--- a/src/cmd/gc/esc.c
+++ b/src/cmd/gc/esc.c
@@ -423,6 +423,9 @@ esc(EscState *e, Node *n)\
lno = setlineno(n);\
+ // ninit logically runs at a different loopdepth than the rest of the for loop.
+ esclist(e, n->ninit);\
+
if(n->op == OFOR || n->op == ORANGE)\
e->loopdepth++;
@@ -430,7 +433,6 @@ esc(EscState *e, Node *n)\
esc(e, n->right);\
esc(e, n->ntest);\
esc(e, n->nincr);\
- esclist(e, n->ninit);\
esclist(e, n->nbody);\
esclist(e, n->nelse);\
esclist(e, n->list);\
test/escape2.go
--- a/test/escape2.go
+++ b/test/escape2.go
@@ -1357,3 +1357,35 @@ func foo144() {\
//go:noescape
func foo144b(*int)\
+\
+// issue 7313: for loop init should not be treated as "in loop"\
+\
+type List struct {\
+\tNext *List\
+}\
+\
+func foo145(l List) { // ERROR "l does not escape"\
+\tvar p *List\
+\tfor p = &l; p.Next != nil; p = p.Next { // ERROR "&l does not escape"\
+\t}\
+}\
+\
+func foo146(l List) { // ERROR "l does not escape"\
+\tvar p *List\
+\tp = &l // ERROR "&l does not escape"\
+\tfor ; p.Next != nil; p = p.Next {\
+\t}\
+}\
+\
+func foo147(l List) { // ERROR "l does not escape"\
+\tvar p *List\
+\tp = &l // ERROR "&l does not escape"\
+\tfor p.Next != nil {\
+\t\tp = p.Next\
+\t}\
+}\
+\
+func foo148(l List) { // ERROR " l does not escape"\
+\tfor p := &l; p.Next != nil; p = p.Next { // ERROR "&l does not escape"\
+\t}\
+}\
コアとなるコードの解説
src/cmd/gc/esc.c
の変更
esc
関数は、Goコンパイラのエスケープ解析の主要な部分です。この関数はASTのノードを再帰的にトラバースし、各ノード内の変数のエスケープ挙動を分析します。
変更前:
if(n->op == OFOR || n->op == ORANGE)
e->loopdepth++;
esc(e, n->right);
esc(e, n->ntest);
esc(e, n->nincr);
esclist(e, n->ninit); // ここでninitが処理されていた
esclist(e, n->nbody);
esclist(e, n->nelse);
esclist(e, n->list);
変更後:
// ninit logically runs at a different loopdepth than the rest of the for loop.
esclist(e, n->ninit); // ここに移動
if(n->op == OFOR || n->op == ORANGE)
e->loopdepth++;
esc(e, n->right);
esc(e, n->ntest);
esc(e, n->nincr);
- // esclist(e, n->ninit); // 削除
esclist(e, n->nbody);
esclist(e, n->nelse);
esclist(e, n->list);
この変更のポイントは、esclist(e, n->ninit);
の呼び出しが、e->loopdepth++
の行よりも前に移動したことです。
esclist
関数は、ノードのリストに対してエスケープ解析を適用します。- 変更前は、
for
ループ(OFOR
またはORANGE
)のloopdepth
がインクリメントされた後にninit
が解析されていました。これは、ninit
がループ本体と同じloopdepth
にあると誤解釈される原因となっていました。 - 変更後は、
ninit
がloopdepth
をインクリメントする前に解析されるため、ninit
はループを囲むスコープのloopdepth
で正しく評価されます。これにより、for
ループの初期化ステートメントで宣言された変数が、不必要にヒープにエスケープすると誤って判断されることがなくなります。
test/escape2.go
の追加テストケース
test/escape2.go
には、このバグが修正されたことを確認するための新しいテストケースが追加されています。
foo145
からfoo148
までの関数は、様々な形式のfor
ループの初期化ステートメントでポインタを宣言し、それらがエスケープしないことを// ERROR
コメントでアサートしています。
例えば、foo145
は以下のようになっています。
func foo145(l List) { // ERROR "l does not escape"
var p *List
for p = &l; p.Next != nil; p = p.Next { // ERROR "&l does not escape"
}
}
このテストケースでは、&l
(l
のアドレス)がfor
ループの初期化部分でp
に代入されています。この&l
は、関数foo145
のスコープ内で完結しており、関数の外にエスケープすることはありません。したがって、エスケープ解析は&l
がヒープに割り当てられる必要がないと判断し、スタックに割り当てられるべきです。修正前は、この&l
が誤ってエスケープすると判断される可能性がありましたが、修正後は正しく「does not escape」と報告されるようになります。
これらのテストケースは、for
ループの初期化ステートメントにおけるエスケープ解析の挙動が、論理的なスコープに従って正しく行われるようになったことを検証しています。
関連リンク
- Go Issue #7313: cmd/gc: for loop init statement misanalyzed by escape analysis (現在はGitHubに移行済み)
- Go CL 62430043: https://golang.org/cl/62430043
参考にした情報源リンク
- Go言語の公式ドキュメント (エスケープ解析に関する一般的な情報)
- Goコンパイラのソースコード (
src/cmd/gc/esc.c
の周辺コード) - Go言語のASTに関する情報 (Goの
go/ast
パッケージのドキュメントなど) - Go言語の
for
ループの構文に関する情報 - Issue #7313の議論内容 (GitHubのIssueトラッカー)
- Go CL 62430043のレビューコメント (Gerritのレビューシステム)