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

[インデックス 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: ノードの種類を示すオペレーションコード(例: OFORforループ、ORANGEfor rangeループ)。

loopdepth

エスケープ解析のコンテキストにおいて、loopdepthは現在のコードがどれだけ深いループの中にいるかを示すカウンターのようなものです。この値は、変数の寿命とエスケープの可能性を判断する際に使用されます。ループの深さが変わると、変数のスコープや寿命に関するエスケープ解析のルールも変わる可能性があります。

技術的詳細

このコミットの核心は、forループの初期化ステートメント(ninit)が、エスケープ解析のloopdepthの扱いで誤解されていた点にあります。

Goのforループの構文は以下のようになります。

for init; condition; post {
    body
}

ここで、initステートメントはループが始まる前に一度だけ実行されます。論理的には、このinitステートメントで宣言された変数のスコープは、forループ自体を囲むスコープと同じloopdepthに属します。しかし、エスケープ解析のコードでは、OFORORANGEノードを処理する際に、まずloopdepthをインクリメントしてから、ninitを含むすべてのサブノードに対してエスケープ解析を実行していました。

これにより、ninitで宣言された変数が、あたかもループ本体の内部で宣言されたかのように、誤ったloopdepthで解析されていました。結果として、本来スタックに割り当てられるべき変数が、ループ内部でエスケープすると誤って判断され、ヒープに割り当てられてしまうというバグが発生していました。

この修正は、ninitステートメントのエスケープ解析を、loopdepthをインクリメントする前に行うことで、この論理的な誤りを正しています。つまり、ninitはループを囲むスコープのloopdepthで解析され、その後のconditionpostbodyはインクリメントされた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にあると誤解釈される原因となっていました。
  • 変更後は、ninitloopdepthをインクリメントする前に解析されるため、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"
	}
}

このテストケースでは、&llのアドレス)がforループの初期化部分でpに代入されています。この&lは、関数foo145のスコープ内で完結しており、関数の外にエスケープすることはありません。したがって、エスケープ解析は&lがヒープに割り当てられる必要がないと判断し、スタックに割り当てられるべきです。修正前は、この&lが誤ってエスケープすると判断される可能性がありましたが、修正後は正しく「does not escape」と報告されるようになります。

これらのテストケースは、forループの初期化ステートメントにおけるエスケープ解析の挙動が、論理的なスコープに従って正しく行われるようになったことを検証しています。

関連リンク

参考にした情報源リンク

  • Go言語の公式ドキュメント (エスケープ解析に関する一般的な情報)
  • Goコンパイラのソースコード (src/cmd/gc/esc.cの周辺コード)
  • Go言語のASTに関する情報 (Goのgo/astパッケージのドキュメントなど)
  • Go言語のforループの構文に関する情報
  • Issue #7313の議論内容 (GitHubのIssueトラッカー)
  • Go CL 62430043のレビューコメント (Gerritのレビューシステム)