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

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

このコミットは、Goコンパイラのcmd/gcにおけるエスケープ解析のバグ修正に関するものです。具体的には、クロージャがインプレースで呼び出された際にアドレスが不適切にエスケープされる問題を修正しています。

変更されたファイルは以下の通りです。

  • src/cmd/gc/esc.c: Goコンパイラのエスケープ解析ロジックが含まれるC言語のソースファイル。
  • test/escape2.go: エスケープ解析のテストケースを含むGo言語のソースファイル。

コミット

commit 5583060c4cc16951d6a4d43daa73519bbd2ba8ee
Author: Luuk van Dijk <lvd@golang.org>
Date:   Mon Apr 23 15:39:01 2012 -0400

    cmd/gc: fix addresses escaping through closures called in-place.
    
    Fixes #3545.
    
    R=rsc
    CC=golang-dev
    https://golang.org/cl/6061043

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

https://github.com/golang/go/commit/5583060c4cc16951d6a4d43daa73519bbd2ba8ee

元コミット内容

cmd/gc: fix addresses escaping through closures called in-place. (cmd/gc: インプレースで呼び出されるクロージャを介したアドレスのエスケープを修正。)

このコミットは、Goコンパイラのエスケープ解析におけるバグを修正するものです。特に、クロージャがその場で(インプレースで)呼び出される場合に、本来エスケープしないはずのアドレスが誤ってエスケープすると判断される問題に対処しています。

変更の背景

このコミットは、GoのIssue #3545「cmd/gc: escape analysis bug」を修正するために行われました。このバグは、Goコンパイラのエスケープ解析が変数を誤ってエスケープしないと判断し、結果としてクラッシュやメモリ破損を引き起こす可能性がありました。

Goのエスケープ解析は、変数がスタックに割り当てられるべきか、それともヒープに割り当てられるべきかを決定する重要なプロセスです。スタック割り当ては高速ですが、変数の寿命が関数呼び出しの期間に限定されます。一方、ヒープ割り当ては変数の寿命が長く、複数の関数やゴルーチン間で共有できますが、ガベージコレクションのオーバーヘッドが発生します。

この問題は、特にクロージャが関係する場合に発生していました。クロージャは、その定義されたスコープ外の変数を「キャプチャ」することができます。エスケープ解析は、キャプチャされた変数がクロージャの実行後も参照され続けるかどうかを判断し、必要に応じてヒープに割り当てます。しかし、このバグにより、インプレースで呼び出されるクロージャの場合に、この判断が誤っていたと考えられます。

前提知識の解説

Goのエスケープ解析 (Escape Analysis)

Goコンパイラは、プログラムの実行中に変数がどこにメモリ割り当てされるべきかを自動的に決定する「エスケープ解析」という最適化を行います。主な目的は、ガベージコレクションの負担を減らし、プログラムのパフォーマンスを向上させることです。

  • スタック割り当て (Stack Allocation): 関数内で宣言され、その関数の実行が終了すると不要になる変数は、通常スタックに割り当てられます。スタックは高速で、メモリの割り当てと解放が非常に効率的です。
  • ヒープ割り当て (Heap Allocation): 変数が関数のスコープを超えて参照される可能性がある場合(例: ポインタが関数から返される、グローバル変数に代入される、別のゴルーチンから参照されるなど)、その変数はヒープに割り当てられます。ヒープはガベージコレクタによって管理され、スタックよりも割り当てと解放のコストが高くなります。

エスケープ解析の誤りは、本来ヒープに割り当てるべき変数をスタックに割り当ててしまい、その変数が関数の終了後に参照されると、不正なメモリアクセスやクラッシュを引き起こす可能性があります。

クロージャ (Closures)

Goにおけるクロージャは、関数リテラルがその周囲の環境(つまり、その関数が定義されたスコープ内の非ローカル変数)を参照できる機能です。クロージャは、これらの「キャプチャされた」変数を、クロージャが呼び出される場所やタイミングに関わらずアクセスできます。

クロージャがキャプチャする変数は、そのクロージャが関数のスコープを超えて存在する場合(例: 別の関数に返される、ゴルーチンで実行されるなど)には、ヒープにエスケープする必要があります。エスケープ解析は、この判断を正確に行う必要があります。

インプレース呼び出し (In-place calls)

「インプレースで呼び出されるクロージャ」とは、クロージャが定義された直後、または非常に近い場所で、その場で実行されるようなケースを指します。例えば、即時実行関数式 (IIFE) のように使われる場合です。

func main() {
    i := 10
    func() { // このクロージャがインプレースで呼び出される
        fmt.Println(i)
    }() // ここで即座に実行
}

このような場合でも、クロージャがキャプチャした変数がエスケープするかどうかの判断は重要です。

技術的詳細

このコミットの主要な変更は、Goコンパイラのエスケープ解析を担当するsrc/cmd/gc/esc.cファイルに集中しています。

escfunc関数の変更

escfunc関数は、クロージャのエスケープ解析を行う部分です。 変更前は、クロージャ自体がリークするケースを処理するために、ダミーのoaddrノードをクロージャに直接リンクしていました。しかし、コメントの変更とコードの追加により、paramref(パラメータ参照)の扱いがより明確になっています。

変更後のコメントでは、paramref自体はヒープに移動せず、その元の値のみが移動すると説明されています。これは、paramrefが単に内側の関数内の何かへの割り当てであり、元の変数がそのループ深度から外に割り当てられるように見えるわけではない、という点を強調しています。

esc関数の変更

esc関数は、AST(抽象構文木)を走査してエスケープ解析を行う再帰関数です。 変更前は、n->left, n->right, n->ntestなどの子ノードに対して無条件にescを再帰的に呼び出していました。

変更後、n->op == OCLOSUREの場合に特別な処理が追加されました。

  • if(n->op == OCLOSURE) { escfunc(n); } else { ... } これは、ノードがクロージャである場合、通常の再帰的な子ノードの走査を行う前に、またはその代わりに、escfunc(n)を呼び出してクロージャ固有のエスケープ解析を行うことを意味します。これにより、クロージャの内部構造とキャプチャされた変数のエスケープ挙動をより正確に分析できるようになります。

escassign関数の変更

escassign関数は、代入操作のエスケープ解析を行います。

  • print文のフォーマットが変更され、%hJが追加されました。これは、デバッグ出力でノードの型情報も表示するようにするためのものです。
  • case OARRAYLIT, OMAPLIT, OSTRUCTLIT のブロックに、OMAKECHAN, OMAKEMAP, OMAKESLICE, ONEW, OCLOSURE が追加されました。これらの操作は、新しいオブジェクトを生成し、そのオブジェクトがどこに割り当てられるべきかをescflows関数で判断する必要があります。
  • 以前のOMAKECHAN, OMAKEMAP, OMAKESLICE, ONEW, OCLOSUREの個別のcaseブロックが削除されました。これは、上記の変更により、これらのケースがまとめて処理されるようになったためです。特にOCLOSUREの場合、以前はescflows(dst, src)escfunc(src)の両方を呼び出していましたが、新しい構造ではesc関数内でOCLOSUREが特別に処理されるため、escassignからは削除されました。

escwalk関数の変更

escwalk関数は、エスケープフローを追跡するための関数です。

  • print文のフォーマットが変更され、%hJが追加されました。これもデバッグ出力の改善です。
  • case PPARAMREF(パラメータ参照)の処理が追加されました。
    • paramrefは自動的に逆参照され、そのアドレスを取ると元の変数のアドレスが生成されるため、値のフローを追跡するだけでよく、level(エスケープレベル)は変更されないと説明されています。
    • src->closureに対してescwalk(level, dst, src->closure)が呼び出されています。これは、paramrefが参照するクロージャの元の変数に対してもエスケープ解析のウォークを行うことで、クロージャを介したエスケープを正確に検出するための重要な変更です。

test/escape2.goの変更

このファイルには、エスケープ解析の挙動をテストするための多数の新しいテストケースが追加されています。これらのテストケースは、特にクロージャが変数をキャプチャし、それが様々な状況(ループ内、ゴルーチン、deferなど)でどのようにエスケープするかを検証しています。

例えば、foo124からfoo137までの関数が追加されており、それぞれが異なるクロージャの使用パターンと、それらがキャプチャする変数のエスケープ挙動をテストしています。// ERRORコメントは、コンパイラが期待するエスケープ解析の結果(例: "moved to heap", "escapes", "does not escape", "leaking closure reference")を示しています。

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

src/cmd/gc/esc.c

--- a/src/cmd/gc/esc.c
+++ b/src/cmd/gc/esc.c
@@ -131,7 +131,12 @@ escfunc(Node *func)
 	}
 
 	// walk will take the address of cvar->closure later and assign it to cvar.
-	// handle that here by linking a fake oaddr node directly to the closure.
+	// linking a fake oaddr node directly to the closure handles the case
+	// of the closure itself leaking.  Following the flow of the value to th
+	// paramref is done in escflow, because if we did that here, it would look
+	// like the original is assigned out of its loop depth, whereas it's just
+	// assigned to something in an inner function.  A paramref itself is never
+	// moved to the heap, only its original.
 	for(ll=curfn->cvars; ll; ll=ll->next) {
 		if(ll->n->op == OXXX)  // see dcl.c:398
 			continue;
@@ -221,16 +226,19 @@ esc(Node *n)
 	if(n->op == OFOR || n->op == ORANGE)
 		loopdepth++;
 
-	esc(n->left);
-	esc(n->right);
-	esc(n->ntest);
-	esc(n->nincr);
-	esclist(n->ninit);
-	esclist(n->nbody);
-	esclist(n->nelse);
-	esclist(n->list);
-	esclist(n->rlist);
-
+	if(n->op == OCLOSURE) {
+		escfunc(n);
+	} else {
+		esc(n->left);
+		esc(n->right);
+		esc(n->ntest);
+		esc(n->nincr);
+		esclist(n->ninit);
+		esclist(n->nbody);
+		esclist(n->nelse);
+		esclist(n->list);
+		esclist(n->rlist);
+	}
 	if(n->op == OFOR || n->op == ORANGE)
 		loopdepth--;
 
@@ -379,8 +387,8 @@ esc(Node *n)
 		}
 		break;
 	
-	case OADDR:
 	case OCLOSURE:
+	case OADDR:
 	case OMAKECHAN:
 	case OMAKEMAP:
 	case OMAKESLICE:
@@ -407,8 +415,8 @@ escassign(Node *dst, Node *src)
 		return;
 
 	if(debug['m'] > 1)
-		print("%L:[%d] %S escassign: %hN = %hN\n", lineno, loopdepth,
-		      (curfn && curfn->nname) ? curfn->nname->sym : S, dst, src);
+		print("%L:[%d] %S escassign: %hN(%hJ) = %hN(%hJ)\n", lineno, loopdepth,
+		      (curfn && curfn->nname) ? curfn->nname->sym : S, dst, dst, src, src);
 
 	setlineno(dst);
 	
@@ -467,18 +475,6 @@ escassign(Node *dst, Node *src)
 	case OARRAYLIT:
 	case OMAPLIT:
 	case OSTRUCTLIT:
-		// loopdepth was set in the defining statement or function header
+	case OMAKECHAN:
+	case OMAKEMAP:
+	case OMAKESLICE:
+	case ONEW:
+	case OCLOSURE:
 		escflows(dst, src);
 		break;
 
-	case OMAKECHAN:
-	case OMAKEMAP:
-	case OMAKESLICE:
-	case ONEW:
-		escflows(dst, src);
-		break;
-
-	case OCLOSURE:
-		escflows(dst, src);
-		escfunc(src);
-		break;
-
 	case OADD:
 	case OSUB:
 	case OOR:
@@ -543,7 +539,7 @@ escassign(Node *dst, Node *src)
 // This is a bit messier than fortunate, pulled out of escassign's big
 // switch for clarity.	We either have the paramnodes, which may be
 // connected to other things throug flows or we have the parameter type
-// nodes, which may be marked 'n(ofloworescape)'. Navigating the ast is slightly
+// nodes, which may be marked "noescape". Navigating the ast is slightly
 // different for methods vs plain functions and for imported vs
 // this-package
 static void
@@ -711,8 +707,8 @@ escwalk(int level, Node *dst, Node *src)
 	src->walkgen = walkgen;
 
 	if(debug['m']>1)
-		print("escwalk: level:%d depth:%d %.*s %hN scope:%S[%d]\n",
-		      level, pdepth, pdepth, "\t\t\t\t\t\t\t\t\t\t", src,
+		print("escwalk: level:%d depth:%d %.*s %hN(%hJ) scope:%S[%d]\n",
+		      level, pdepth, pdepth, "\t\t\t\t\t\t\t\t\t\t", src, src,
 		      (src->curfn && src->curfn->nname) ? src->curfn->nname->sym : S, src->escloopdepth);
 
 	pdepth++;
@@ -726,6 +722,16 @@ escwalk(int level, Node *dst, Node *src)
 			if(debug['m'])
 				warnl(src->lineno, "leaking param: %hN", src);
 		}
+		// handle the missing flow ref <- orig
+		// a paramref is automagically dereferenced, and taking its
+		// address produces the address of the original, so all we have to do here
+		// is keep track of the value flow, so level is unchanged.
+		// alternatively, we could have substituted PPARAMREFs with their ->closure in esc/escassign/flow,
+		if(src->class == PPARAMREF) {
+			if(leaks && debug['m'])
+				warnl(src->lineno, "leaking closure reference %hN", src);
+			escwalk(level, dst, src->closure);
+		}
 		break;
 
 	case OPTRLIT:

test/escape2.go

多数の新しいテストケースが追加されています。例として一部を抜粋します。

--- a/test/escape2.go
+++ b/test/escape2.go
@@ -1051,7 +1051,7 @@ func foo122() {
 
 	goto L1
 L1:
-	i = new(int) // ERROR "does not escape"
+	i = new(int) // ERROR "new.int. does not escape"
 	_ = i
 }
 
@@ -1060,8 +1060,141 @@ func foo123() {
 	var i *int
 
 L1:
-	i = new(int) // ERROR "escapes"
+	i = new(int) // ERROR "new.int. escapes to heap"
 
 	goto L1
 	_ = i
 }
+
+func foo124(x **int) {	// ERROR "x does not escape"
+	var i int	// ERROR "moved to heap: i"
+	p := &i 	// ERROR "&i escapes"
+	func() {	// ERROR "func literal does not escape"
+		*x = p	// ERROR "leaking closure reference p"
+	}()
+}
+
+func foo125(ch chan *int) {	// ERROR "does not escape"
+	var i int	// ERROR "moved to heap"
+	p := &i 	// ERROR "&i escapes to heap"
+	func() {	// ERROR "func literal does not escape"
+		ch <- p	// ERROR "leaking closure reference p"
+	}()
+}
+
+// ... (以下、foo126からfoo137までの多数のテストケースが追加)

コアとなるコードの解説

このコミットの核心は、Goコンパイラのエスケープ解析がクロージャ、特にインプレースで呼び出されるクロージャを介した変数のエスケープをより正確に追跡できるようにすることです。

  1. esc関数におけるクロージャの特別扱い: 以前のesc関数は、ASTを再帰的に走査する際に、クロージャノード(OCLOSURE)を他の一般的なノードと同じように扱っていました。しかし、クロージャはキャプチャされた変数という特殊な性質を持つため、そのエスケープ解析には特別なロジックが必要です。 変更後、if(n->op == OCLOSURE) { escfunc(n); } else { ... }という条件分岐が追加されました。これにより、OCLOSUREノードに遭遇した場合、まずescfuncを呼び出してクロージャ固有のエスケープ解析ロジックを適用し、その後で通常の子ノード走査を行う(または行わない)ようになりました。これは、クロージャの内部構造と、それが参照する外部変数のエスケープ挙動をより正確に判断するための重要な変更です。

  2. escassign関数におけるオブジェクト生成の統一処理: OMAKECHAN, OMAKEMAP, OMAKESLICE, ONEW, OCLOSUREといった、新しいオブジェクトを生成する操作は、その結果がどこに割り当てられるべきか(スタックかヒープか)をescflows関数で判断する必要があります。以前はこれらのケースが個別に処理されていましたが、変更後はOARRAYLIT, OMAPLIT, OSTRUCTLITなどと共にまとめてescflows(dst, src)を呼び出すように統一されました。これにより、コードの重複が減り、これらのオブジェクト生成に関するエスケープ解析のロジックが一貫性を持つようになりました。特にOCLOSUREの場合、以前はescassign内でescfunc(src)も呼び出していましたが、これはesc関数でのOCLOSUREの特別扱いにより不要になりました。

  3. escwalk関数におけるPPARAMREFの追跡: PPARAMREFは、パラメータ参照を表すノードです。クロージャが外部の変数をキャプチャする際、その変数はクロージャのパラメータとして扱われることがあります。この変更では、PPARAMREFノードに遭遇した場合、その参照が指す元のクロージャ変数(src->closure)に対してもescwalkを呼び出すようになりました。これは、クロージャを介して変数がエスケープするパスを正確に追跡するために不可欠です。例えば、クロージャがキャプチャした変数のアドレスが、さらに別のクロージャや外部に渡されるような場合に、この追跡が重要になります。"leaking closure reference"というエラーメッセージは、この追跡によって検出される問題を示しています。

これらの変更により、Goコンパイラは、クロージャがインプレースで呼び出された場合でも、キャプチャされた変数のエスケープ挙動をより正確に分析できるようになり、誤ったスタック割り当てによる潜在的なバグを防ぐことができます。追加されたtest/escape2.goのテストケースは、これらの修正が様々な複雑なシナリオで正しく機能することを検証しています。

関連リンク

参考にした情報源リンク