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

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

このコミットは、Goコンパイラのcmd/gcパッケージ内のエスケープ解析ロジックに関する修正です。具体的には、src/cmd/gc/esc.cファイルが変更され、エスケープ解析の挙動が改善されています。また、この修正の動作を検証するための新しいテストケースがtest/escape2.goに追加されています。

src/cmd/gc/esc.cは、Goコンパイラのエスケープ解析を担当するC言語のソースファイルです。エスケープ解析は、Goプログラムの変数がスタックに割り当てられるべきか、それともヒープに割り当てられるべきかを決定する重要な最適化プロセスです。

test/escape2.goは、Go言語のテストファイルであり、エスケープ解析に関する様々なシナリオを検証するために使用されます。このコミットでは、特に「関数結果のアドレスが関数結果自身にエスケープする」という特定のケースをテストする新しい関数が追加されています。

コミット

cmd/gc: fix &result escaping into result

There is a hierarchy of location defined by loop depth:

        -1 = the heap
        0 = function results
        1 = local variables (and parameters)
        2 = local variable declared inside a loop
        3 = local variable declared inside a loop inside a loop
        etc

In general if an address from loopdepth n is assigned to
something in loop depth m < n, that indicates an extended
lifetime of some form that requires a heap allocation.

Function results can be local variables too, though, and so
they don't actually fit into the hierarchy very well.
Treat the address of a function result as level 1 so that
if it is written back into a result, the address is treated
as escaping.

Fixes #8185.

LGTM=iant
R=iant
CC=golang-codereviews
https://golang.org/cl/108870044

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

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

元コミット内容

commit f20e4d5ecb87cae4846be07a68cb0e9132e6a8c6
Author: Russ Cox <rsc@golang.org>
Date:   Wed Jun 11 14:21:06 2014 -0400

    cmd/gc: fix &result escaping into result
    
    There is a hierarchy of location defined by loop depth:
    
            -1 = the heap
            0 = function results
            1 = local variables (and parameters)
            2 = local variable declared inside a loop
            3 = local variable declared inside a loop inside a loop
            etc
    
    In general if an address from loopdepth n is assigned to
    something in loop depth m < n, that indicates an extended
    lifetime of some form that requires a heap allocation.
    
    Function results can be local variables too, though, and so
    they don't actually fit into the hierarchy very well.
    Treat the address of a function result as level 1 so that
    if it is written back into a result, the address is treated
    as escaping.
    
    Fixes #8185.
    
    LGTM=iant
    R=iant
    CC=golang-codereviews
    https://golang.org/cl/108870044

変更の背景

このコミットは、Goコンパイラのエスケープ解析における特定のバグ、具体的にはIssue #8185を修正するために行われました。このバグは、関数からの戻り値(結果変数)のアドレスが、その戻り値自身に代入されるようなケースで、エスケープ解析が正しく機能しないというものでした。

Goのエスケープ解析は、変数の寿命を分析し、その変数をスタックに割り当てるかヒープに割り当てるかを決定します。スタック割り当ては高速でガベージコレクションのオーバーヘッドがないため、可能な限りスタックに割り当てることが望ましいとされています。しかし、変数の寿命が現在のスコープ(関数呼び出しなど)を超えてしまう場合、その変数はヒープに「エスケープ」する必要があります。

問題となっていたのは、関数結果変数が、コンパイラ内部で定義されている「ループ深度」の階層にうまく適合しない点でした。関数結果は、ある意味でローカル変数と似ていますが、その寿命の扱いは特殊です。この不整合により、y = &x のように、関数結果xのアドレス&xが別の関数結果yに代入される、あるいはx = &xのように、関数結果xのアドレス&xx自身に代入される場合に、コンパイラがxがヒープにエスケープする必要があることを正しく認識できませんでした。

この修正は、このようなケースで関数結果のアドレスが正しくエスケープすると判断されるように、エスケープ解析のロジックを調整することを目的としています。

前提知識の解説

Goのメモリ管理(スタックとヒープ)

Goプログラムでは、変数は主に2つの場所に割り当てられます。

  1. スタック (Stack): 関数呼び出しごとに確保されるメモリ領域です。関数内で宣言されたローカル変数や関数の引数などがスタックに割り当てられます。スタックはLIFO(後入れ先出し)の構造を持ち、関数の呼び出しと終了に伴って自動的にメモリが確保・解放されるため、非常に高速です。スタックに割り当てられた変数は、その変数を宣言した関数が終了すると自動的に解放されます。
  2. ヒープ (Heap): プログラム全体で共有されるメモリ領域です。スタックに収まらない、あるいは関数の寿命を超えて存在する必要があるデータ(例えば、関数からポインタとして返されるデータや、グローバル変数、チャネル、マップ、スライスなど)がヒープに割り当てられます。ヒープに割り当てられたメモリは、Goのガベージコレクタによって管理され、不要になった時点で自動的に解放されます。ヒープ割り当てはスタック割り当てよりも遅く、ガベージコレクションのオーバーヘッドも発生します。

エスケープ解析 (Escape Analysis)

エスケープ解析は、Goコンパイラが行う重要な最適化の一つです。その目的は、変数がスタックに割り当てられるべきか、ヒープに割り当てられるべきかをコンパイル時に決定することです。

  • スタック割り当ての条件: 変数がその宣言された関数のスコープ内で完結し、関数の終了とともに不要になる場合、コンパイラはその変数をスタックに割り当てることができます。
  • ヒープ割り当て(エスケープ)の条件: 変数の寿命がその宣言された関数のスコープを超えてしまう場合、またはそのアドレスが外部に公開される場合、その変数はヒープに「エスケープ」する必要があります。例えば、関数の戻り値としてポインタを返す場合や、グローバル変数に代入される場合などです。

エスケープ解析が正しく機能することで、不必要なヒープ割り当てが減り、ガベージコレクションの頻度が低下し、結果としてプログラムのパフォーマンスが向上します。

ループ深度 (Loop Depth) の概念

Goコンパイラのエスケープ解析では、変数の「寿命」を相対的に表現するために「ループ深度」という概念が用いられます。これは、変数が宣言されたスコープの「深さ」を示すもので、変数の寿命がどれだけ長いかを判断するヒントになります。コミットメッセージで示されている階層は以下の通りです。

  • -1 = ヒープ (the heap): 最も寿命が長く、ガベージコレクタによって管理される領域。
  • 0 = 関数結果 (function results): 関数の戻り値として使用される変数。関数の呼び出し元に値を返すため、関数の実行中だけでなく、呼び出し元が値を受け取るまで寿命が続く。
  • 1 = ローカル変数 (local variables) およびパラメータ (parameters): 関数内で宣言された通常のローカル変数や、関数に渡される引数。これらは通常、関数が終了すると寿命が終わる。
  • 2 = ループ内で宣言されたローカル変数 (local variable declared inside a loop): 一重のループ内で宣言された変数。
  • 3 = ループ内のループ内で宣言されたローカル変数 (local variable declared inside a loop inside a loop): 二重のループ内で宣言された変数。
  • など: ループのネストが深くなるにつれて、この数値は増加します。

この階層において、一般的に「ループ深度nの変数のアドレスが、ループ深度m < nの変数に代入される」場合、それは変数の寿命が延長されることを意味し、ヒープ割り当てが必要となる可能性が高いと判断されます。例えば、ループ内で宣言されたローカル変数(深度2以上)のアドレスが、関数結果(深度0)や通常のローカル変数(深度1)に代入される場合などです。

&演算子とエスケープ解析

Go言語の&演算子は、変数のメモリアドレスを取得するために使用されます。このアドレスが取得され、そのアドレスが変数の宣言されたスコープを超えて使用される可能性がある場合、エスケープ解析はその変数をヒープに割り当てる必要があります。

Goコンパイラ内部のASTノードの種類

Goコンパイラは、ソースコードを抽象構文木(AST)に変換して処理します。このASTのノードには、様々な種類があります。

  • ONAME: 変数名を表すノード。
  • PAUTO: 自動変数(ローカル変数)を表すクラス。
  • PPARAM: 関数の入力パラメータを表すクラス。
  • PPARAMOUT: 関数の出力パラメータ(戻り値)を表すクラス。

これらのノードの種類とクラスは、エスケープ解析が変数の性質を識別し、適切な処理を行うために利用されます。

技術的詳細

このコミットの核心は、Goコンパイラのエスケープ解析が、関数結果変数のアドレスがその関数結果自身、または別の関数結果に代入されるケースを正しく処理できるようにすることです。

コミットメッセージが指摘するように、関数結果は「ローカル変数でもある」という側面を持ちながらも、その寿命の扱いは通常のローカル変数とは異なります。通常のローカル変数は関数が終了すると寿命が終わりますが、関数結果は呼び出し元に値を返すため、呼び出し元がその値を受け取るまで寿命が続きます。この特殊性により、エスケープ解析の「ループ深度」の階層にうまく当てはまらないという問題がありました。

以前の実装では、PPARAMOUT(出力パラメータ、つまり関数結果)のescloopdepth(エスケープ解析におけるループ深度)が適切に設定されていませんでした。特に、&xのように関数結果xのアドレスを取得する際に、そのアドレスのescloopdepthが正しく計算されないことが問題でした。

この修正では、src/cmd/gc/esc.c内のエスケープ解析ロジックにおいて、PPARAMOUTクラスの変数のアドレスを取得する際(&xのような操作)に、そのアドレスのescloopdepth常に1として扱うように変更されました。

なぜ1なのか?

  • コミットメッセージの階層定義では、0が「関数結果」、1が「ローカル変数(およびパラメータ)」です。
  • 関数結果は、その性質上、ローカル変数と似た寿命を持ちます。しかし、そのアドレスが外部に渡される場合、通常のローカル変数と同様に、その寿命が関数のスコープを超えてしまう可能性があります。
  • PPARAMOUTのアドレスをescloopdepth = 1として扱うことで、もしそのアドレスが「ループ深度0」(関数結果自身)に代入される場合、n=1からm=0への代入となり、m < nの条件が満たされます。これにより、エスケープ解析は「寿命が延長される」と判断し、正しくヒープへのエスケープを検出できるようになります。

具体的には、func f() (x int, y *int)のような関数でy = &xと記述された場合、xは関数結果(PPARAMOUT)であり、そのアドレス&xが取得されます。この&xescloopdepth1と設定されます。そして、この&xが別の関数結果yescloopdepth = 0)に代入されるため、1から0への代入となり、xがヒープにエスケープする必要があると正しく判断されるようになります。

同様に、func g() (x interface{})のような関数でx = &xと記述された場合、xは関数結果(PPARAMOUT)であり、そのアドレス&xが取得されます。この&xescloopdepth1と設定されます。そして、この&xx自身(escloopdepth = 0)に代入されるため、1から0への代入となり、xがヒープにエスケープする必要があると正しく判断されるようになります。

この修正により、Goコンパイラは、関数結果のアドレスが不適切にスタックに割り当てられることを防ぎ、より堅牢なエスケープ解析を実現します。

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

src/cmd/gc/esc.c

--- a/src/cmd/gc/esc.c
+++ b/src/cmd/gc/esc.c
@@ -673,12 +673,21 @@ esc(EscState *e, Node *n, Node *up)
 		// for &x, use loop depth of x if known.
 		// it should always be known, but if not, be conservative
 		// and keep the current loop depth.
-		if(n->left->op == ONAME && (n->left->escloopdepth != 0 || n->left->class == PPARAMOUT)) {
+		if(n->left->op == ONAME) {
 			switch(n->left->class) {
 			case PAUTO:
+				if(n->left->escloopdepth != 0)
+					n->escloopdepth = n->left->escloopdepth;
+				break;
 			case PPARAM:
 			case PPARAMOUT:
-				n->escloopdepth = n->left->escloopdepth;
+				// PPARAM is loop depth 1 always.
+				// PPARAMOUT is loop depth 0 for writes
+				// but considered loop depth 1 for address-of,
+				// so that writing the address of one result
+				// to another (or the same) result makes the
+				// first result move to the heap.
+				n->escloopdepth = 1;
 				break;
 			}
 		}

test/escape2.go

--- a/test/escape2.go
+++ b/test/escape2.go
@@ -1478,3 +1478,15 @@ func foo153(v interface{}) *int { // ERROR "leaking param: v"
 	}
 	panic(0)
 }
+
+// issue 8185 - &result escaping into result
+
+func f() (x int, y *int) { // ERROR "moved to heap: x"
+	y = &x // ERROR "&x escapes to heap"
+	return
+}
+
+func g() (x interface{}) { // ERROR "moved to heap: x"
+	x = &x // ERROR "&x escapes to heap"
+	return
+}

コアとなるコードの解説

src/cmd/gc/esc.cの変更点

変更は、esc関数内の&xのようなアドレス取得演算子を処理する部分にあります。

変更前: if(n->left->op == ONAME && (n->left->escloopdepth != 0 || n->left->class == PPARAMOUT)) この条件文は、ONAMEノード(変数名)であり、かつその変数のescloopdepth0ではないか、またはPPARAMOUTクラスである場合に、内部のswitch文に入ることを示していました。 PPARAMOUTの場合、n->escloopdepth = n->left->escloopdepth;として、左辺(x)のescloopdepthをそのまま右辺(&x)のescloopdepthにコピーしていました。これが問題でした。関数結果のescloopdepth0と定義されているため、&xescloopdepth0になってしまい、y = &xのような代入でxがヒープにエスケープする必要があることを正しく検出できませんでした。

変更後: if(n->left->op == ONAME) 条件文が簡略化され、ONAMEノードであれば常にswitch文に入るようになりました。

switch(n->left->class)ブロック内で、PPARAMPPARAMOUTのケースが統合され、以下のコメントとコードが追加されました。

			case PPARAM:
			case PPARAMOUT:
				// PPARAM is loop depth 1 always.
				// PPARAMOUT is loop depth 0 for writes
				// but considered loop depth 1 for address-of,
				// so that writing the address of one result
				// to another (or the same) result makes the
				// first result move to the heap.
				n->escloopdepth = 1;
				break;

この変更がこのコミットの核心です。

  • PPARAM(入力パラメータ)は常にループ深度1として扱われます。これは以前からそうであった可能性が高いです。
  • PPARAMOUT(出力パラメータ、関数結果)については、**書き込み時にはループ深度0**ですが、アドレス取得時(&演算子使用時)にはループ深度1として扱われるようになりました。

この「アドレス取得時にループ深度1として扱う」という変更が重要です。これにより、&xxPPARAMOUT)のescloopdepth1になります。もしこの&xが、escloopdepth0である別の関数結果(またはx自身)に代入されると、エスケープ解析のルール「ループ深度nのアドレスがループ深度m < nに代入されるとヒープ割り当てが必要」が適用され、xがヒープにエスケープする必要があると正しく判断されるようになります。

test/escape2.goの追加テストケース

このコミットでは、修正が正しく機能することを確認するために、以下の2つの新しいテスト関数がtest/escape2.goに追加されました。

  1. func f() (x int, y *int):

    func f() (x int, y *int) { // ERROR "moved to heap: x"
    	y = &x // ERROR "&x escapes to heap"
    	return
    }
    

    このテストケースでは、関数fint型の戻り値x*int型の戻り値yを宣言しています。y = &xという行で、xのアドレスがyに代入されています。

    • // ERROR "moved to heap: x": これは、コンパイラがxがヒープに移動する必要があると正しく判断したことを示す期待されるエラーメッセージです。
    • // ERROR "&x escapes to heap": これは、&xがヒープにエスケープしたことを示す期待されるエラーメッセージです。 このテストは、関数結果のアドレスが別の関数結果に代入される場合に、エスケープ解析が正しく機能することを確認します。
  2. func g() (x interface{}):

    func g() (x interface{}) { // ERROR "moved to heap: x"
    	x = &x // ERROR "&x escapes to heap"
    	return
    }
    

    このテストケースでは、関数ginterface{}型の戻り値xを宣言しています。x = &xという行で、x自身のアドレスがxに代入されています。

    • // ERROR "moved to heap: x": xがヒープに移動する必要があると正しく判断されたことを示します。
    • // ERROR "&x escapes to heap": &xがヒープにエスケープしたことを示します。 このテストは、関数結果のアドレスがその関数結果自身に代入されるという、より特殊なケースでエスケープ解析が正しく機能することを確認します。

これらのテストケースは、src/cmd/gc/esc.cの修正が意図した通りに動作し、以前は検出されなかったエスケープが正しく報告されるようになったことを検証します。

関連リンク

参考にした情報源リンク