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

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

このコミットは、Goコンパイラのガベージコレクション(gc)におけるエスケープ解析のバグ修正に関するものです。特に、ループ内で変数がキャプチャされる(クロージャなどで参照される)際に発生するエスケープ解析の誤りを修正し、関連するテストケースを追加しています。

コミット

commit ba97d52b85a26e41dc1751bfbb5d268717d45f94
Author: Rémy Oudompheng <oudomphe@phare.normalesup.org>
Date:   Fri Aug 31 22:23:37 2012 +0200

    cmd/gc: fix escape analysis bug with variable capture in loops.
    
    Fixes #3975.
    
    R=rsc, lvd
    CC=golang-dev, remy
    https://golang.org/cl/6475061

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

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

元コミット内容

cmd/gc: fix escape analysis bug with variable capture in loops.

Fixes #3975.

R=rsc, lvd
CC=golang-dev, remy
https://golang.org/cl/6475061

変更の背景

このコミットは、Go言語のコンパイラ(cmd/gc)におけるエスケープ解析のバグを修正するために行われました。具体的には、ループ内で定義された変数がクロージャ(無名関数)によってキャプチャされる際に、エスケープ解析がその変数を誤ってスタック上に割り当ててしまう問題がありました。

Go言語では、変数がそのスコープを越えて参照される可能性がある場合(例えば、関数からポインタが返される場合や、クロージャによって外部変数が参照される場合)、その変数はヒープに割り当てられる必要があります。これを「エスケープ」と呼びます。エスケープ解析は、コンパイル時にどの変数をスタックに割り当てるべきか、どの変数をヒープに割り当てるべきかを決定するプロセスです。スタック割り当ては高速ですが、変数の寿命が関数の実行期間に限定されます。ヒープ割り当てはガベージコレクションの対象となり、変数の寿命が長くなりますが、オーバーヘッドが大きくなります。

問題のバグは、ループ内で定義された変数をクロージャがキャプチャするシナリオで発生しました。Goのクロージャは、外部スコープの変数を参照する際に、その変数の「アドレス」をキャプチャします。ループ内で変数を定義し、その変数をループの各イテレーションで生成されるクロージャがキャプチャする場合、通常は各クロージャがループの異なるイテレーションで生成された変数の「コピー」ではなく、同じ変数の「アドレス」をキャプチャしてしまいます。このため、ループが終了した後にクロージャを実行すると、最後にループ変数が持っていた値が参照されてしまうという、Go言語のよく知られた落とし穴の一つです。

このバグは、エスケープ解析がループの深さを適切に考慮していなかったために発生しました。結果として、本来ヒープに割り当てられるべき変数が誤ってスタックに割り当てられ、プログラムの動作が不正になる可能性がありました。Fixes #3975 という記述から、このバグがGoのIssueトラッカーで報告されていたことがわかります。

前提知識の解説

1. Go言語のエスケープ解析 (Escape Analysis)

Goコンパイラは、プログラムの実行効率を向上させるために「エスケープ解析」と呼ばれる最適化を行います。これは、変数がメモリのどこに割り当てられるべきかを決定するプロセスです。

  • スタック割り当て (Stack Allocation): 関数内で宣言され、その関数の実行が終了すると不要になる変数は、通常スタックに割り当てられます。スタックは高速で、メモリの解放も自動的に行われるため、オーバーヘッドが小さいです。
  • ヒープ割り当て (Heap Allocation): 変数がその宣言されたスコープ(関数)の寿命を超えて存在する必要がある場合、またはそのアドレスが外部に「エスケープ」する場合、ヒープに割り当てられます。ヒープに割り当てられたメモリはガベージコレクタによって管理されます。ヒープ割り当てはスタック割り当てよりも遅く、ガベージコレクションのオーバーヘッドも発生します。

エスケープ解析の目的は、可能な限り多くの変数をスタックに割り当てることで、ガベージコレクションの負荷を減らし、プログラムのパフォーマンスを向上させることです。

変数がエスケープする一般的なケース:

  • 関数の戻り値としてポインタが返される場合。
  • クロージャが外部変数を参照する場合(変数のアドレスがクロージャの寿命に依存するため)。
  • チャネルを通じてポインタが送信される場合。

2. Go言語におけるループ変数とクロージャのキャプチャ

Go言語において、for ループ内でクロージャを定義し、ループ変数をそのクロージャ内で参照する際には注意が必要です。Goのクロージャは、外部変数を「参照」によってキャプチャします。これは、クロージャが変数の「値」をコピーするのではなく、変数の「アドレス」を記憶することを意味します。

package main

import "fmt"

func main() {
    var funcs []func()
    for i := 0; i < 3; i++ {
        funcs = append(funcs, func() {
            fmt.Println(i) // ここでiがキャプチャされる
        })
    }

    for _, f := range funcs {
        f() // 実行されるとすべて「2」が出力される
    }
}

上記の例では、i はループの各イテレーションで再利用される単一の変数です。各クロージャは同じ i のアドレスをキャプチャするため、ループが終了した時点での i の最終的な値(この場合は 2)がすべてのクロージャで参照されてしまいます。

この問題を回避するには、ループ内で新しい変数を導入し、ループ変数の値をその新しい変数にコピーしてからクロージャでキャプチャする必要があります。

package main

import "fmt"

func main() {
    var funcs []func()
    for i := 0; i < 3; i++ {
        j := i // ループ変数の値を新しい変数jにコピー
        funcs = append(funcs, func() {
            fmt.Println(j) // jがキャプチャされる
        })
    }

    for _, f := range funcs {
        f() // 0, 1, 2 がそれぞれ出力される
    }
}

このコミットで修正されたバグは、この「ループ変数とクロージャのキャプチャ」のメカニズムとエスケープ解析が相互作用する際に発生したもので、本来ヒープに割り当てられるべき j のような変数が誤ってスタックに割り当てられてしまう可能性があったということです。

3. src/cmd/gc/esc.c

src/cmd/gc/esc.c は、Goコンパイラのバックエンドの一部であり、エスケープ解析のロジックが実装されているC言語のソースファイルです。このファイルは、Goのソースコードを解析し、どの変数がエスケープするかを判断する役割を担っています。

技術的詳細

このバグは、Goコンパイラのエスケープ解析が、ループ内でキャプチャされる変数の「ループ深さ」を適切に追跡していなかったことに起因します。

Goコンパイラのエスケープ解析は、変数の寿命と参照関係を分析し、変数がスタックに割り当てられるべきか、ヒープに割り当てられるべきかを決定します。ループ内でクロージャが外部変数をキャプチャする場合、その変数はクロージャの寿命に依存するため、通常はヒープにエスケープする必要があります。

問題は、src/cmd/gc/esc.c 内の esc 関数(エスケープ解析の主要な関数)が、変数がクロージャによってキャプチャされる際に、その変数がどのループの深さで定義されたかを正確に記録していなかった点にありました。

具体的には、Node 構造体(GoのASTノードを表す)には、変数が定義されたループの深さを示す escloopdepth フィールドがあります。エスケープ解析のコンテキスト (EscState) には、現在の解析中のコードがどのループの深さにあるかを示す loopdepth フィールドがあります。

バグ修正前は、クロージャが外部変数をキャプチャする際に、そのキャプチャされた変数の escloopdepth が適切に設定されていませんでした。これにより、エスケープ解析が、その変数がループの外部で定義されたかのように誤解し、結果としてスタックに割り当ててしまう可能性がありました。

このコミットでは、src/cmd/gc/esc.cesc 関数内で、クロージャが外部変数をキャプチャする処理において、キャプチャされる変数の escloopdepth を現在のエスケープ解析のコンテキストの loopdepth に設定する行が追加されました。

a->escloopdepth = e->loopdepth;

この一行の追加により、エスケープ解析は、ループ内でキャプチャされた変数が、その変数が定義されたループの深さと同じ深さを持つことを正しく認識できるようになりました。これにより、変数がクロージャの寿命に依存してヒープにエスケープする必要がある場合に、正しくヒープに割り当てられるようになります。

新しいテストケース test/escape.gofor_escapes3 関数や test/escape2.gofoo74b 関数は、このバグが顕在化する典型的なシナリオを再現しています。これらのテストは、ループ内で定義された変数をクロージャがキャプチャし、そのクロージャがループの外で実行される場合に、変数が正しくヒープにエスケープされることを検証しています。

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

src/cmd/gc/esc.c

--- a/src/cmd/gc/esc.c
+++ b/src/cmd/gc/esc.c
@@ -543,6 +543,7 @@ esc(EscState *e, Node *n)\n 			continue;\n 		a = nod(OADDR, ll->n->closure, N);\n 		a->lineno = ll->n->lineno;\n+		a->escloopdepth = e->loopdepth;\n 		typecheck(&a, Erv);\n 		escassign(e, n, a);\n 	}\n```

この変更は、`esc` 関数内で、クロージャが外部変数をキャプチャする際に、そのキャプチャされた変数の `escloopdepth` フィールドに、現在のエスケープ解析の状態 (`e`) が持つ `loopdepth` を設定しています。

### `test/escape.go`

新しいテスト関数 `for_escapes3` が追加されました。

```go
+func for_escapes3(x int, y int) (*int, *int) {
+	var f [2]func() *int
+	n := 0
+	for i := x; n < 2; i = y {
+		p := new(int)
+		*p = i
+		f[n] = func() *int { return p }
+		n++
+	}
+	return f[0](), f[1]()
+}

このテストは、ループ内でポインタ p を作成し、そのポインタをクロージャ f[n] がキャプチャするシナリオをシミュレートしています。この p が正しくヒープにエスケープされることを検証します。

また、main 関数に for_escapes3 の呼び出しと検証が追加されました。

@@ -199,6 +211,9 @@ func main() {
 	p, q = for_escapes2(103, 104)
 	chkalias(p, q, 103, "for_escapes2")
 
+	p, q = for_escapes3(105, 106)
+	chk(p, q, 105, "for_escapes3")
+
 	_, p = out_escapes(15)
 	_, q = out_escapes(16)
 	chk(p, q, 15, "out_escapes")

test/escape2.go

新しいテスト関数 foo74b が追加されました。

--- a/test/escape2.go
+++ b/test/escape2.go
@@ -540,6 +540,19 @@ func foo74() {
 	}
 }
 
+// issue 3975
+func foo74b() {
+	var array [3]func()
+	s := []int{3, 2, 1} // ERROR "\[\]int literal does not escape"
+	for i, v := range s {
+		vv := v // ERROR "moved to heap: vv"
+		// actually just escapes its scope
+		array[i] = func() { // ERROR "func literal escapes to heap"
+			println(vv) // ERROR "&vv escapes to heap"
+		}
+	}
+}
+
 func myprint(y *int, x ...interface{}) *int { // ERROR "x does not escape" "leaking param: y"
 	return y
 }

このテストは、for range ループ内で vv という変数を定義し、その変数をクロージャがキャプチャするシナリオです。コメントに ERROR と書かれている箇所は、エスケープ解析が正しく動作した場合に期待されるエラーメッセージ(変数がヒープにエスケープすることを示すメッセージ)を示しています。これは、このバグが修正されたことで、これらの変数が正しくヒープに割り当てられるようになったことを確認するためのものです。

コアとなるコードの解説

このコミットの核心は、src/cmd/gc/esc.c ファイル内の esc 関数における以下の変更です。

a->escloopdepth = e->loopdepth;
  • a: これは、クロージャによってキャプチャされる変数(またはそのアドレス)を表す抽象構文木(AST)ノードです。Goコンパイラ内部では、プログラムの構造がASTとして表現され、各ノードは変数、関数呼び出し、演算などを表します。
  • escloopdepth: Node 構造体のフィールドで、その変数が定義されたループの深さを示します。この値は、エスケープ解析が変数の寿命を判断する上で重要な情報となります。例えば、escloopdepth0 であれば、その変数はループの外で定義されたことを意味します。
  • e: これは、現在のエスケープ解析の状態を保持する EscState 構造体へのポインタです。
  • loopdepth: EscState 構造体のフィールドで、現在エスケープ解析が処理しているコードが、ネストされたループのどの深さにいるかを示します。例えば、一番外側のループであれば 1、その中のループであれば 2 といった具合です。

この一行の追加により、クロージャが外部変数をキャプチャする際に、そのキャプチャされた変数の escloopdepth が、変数が実際に定義されたループの深さ (e->loopdepth) と一致するように設定されます。

この修正がなぜ重要なのか?

バグ修正前は、クロージャがループ内で定義された変数をキャプチャしても、その変数の escloopdepth が適切に更新されず、デフォルト値(おそらく 0)のままになっていました。これにより、エスケープ解析は、その変数がループの外部で定義されたかのように誤解し、その寿命がクロージャの寿命よりも短いと判断してしまう可能性がありました。結果として、本来ヒープに割り当てられるべき変数が誤ってスタックに割り当てられ、クロージャが実行された際に不正なメモリにアクセスしたり、予期せぬ値が参照されたりする可能性がありました。

この修正によって、エスケープ解析は、ループ内でキャプチャされた変数が、その変数が定義されたループの深さと同じ深さを持つことを正しく認識できるようになります。これにより、変数がクロージャの寿命に依存してヒープにエスケープする必要がある場合に、正しくヒープに割り当てられるようになり、プログラムの正確性と安定性が向上します。

追加されたテストケース (for_escapes3foo74b) は、この修正が正しく機能することを確認するためのものです。これらのテストは、ループ内で変数を定義し、それをクロージャでキャプチャする典型的なシナリオを再現し、変数が正しくヒープにエスケープされることを検証しています。

関連リンク

参考にした情報源リンク