[インデックス 19497] ファイルの概要
このコミットは、Goコンパイラのcmd/gc
におけるエスケープ解析のバグを修正するものです。具体的には、関数がパラメータのポインタのポインタ(間接参照)を返す場合に、変数が誤ってスタックに割り当てられ、Go 1.3で導入されたスタックコピー機能によってクラッシュを引き起こす可能性があった問題に対処しています。この修正により、このようなケースで変数が正しくヒープに割り当てられるようになります。
コミット
コミットハッシュ: fe3c913443a713097a9a0a427846d5411c4150b0
作者: Russ Cox rsc@golang.org
日付: Tue Jun 3 11:35:59 2014 -0400
コミットメッセージの要約: cmd/gc
: パラメータの間接参照を返す関数のエスケープ解析を修正
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/fe3c913443a713097a9a0a427846d5411c4150b0
元コミット内容
commit fe3c913443a713097a9a0a427846d5411c4150b0
Author: Russ Cox <rsc@golang.org>
Date: Tue Jun 3 11:35:59 2014 -0400
cmd/gc: fix escape analysis of func returning indirect of parameter
I introduced this bug when I changed the escape
analysis to run in phases based on call graph
dependency order, in order to be more precise about
inputs escaping back to outputs (functions returning
their arguments).
Given
func f(z **int) *int { return *z }
we were tagging the function as 'z does not escape
and is not returned', which is all true, but not
enough information.
If used as:
var x int
p := &x
q := &p
leak(f(q))
then the compiler might try to keep x, p, and q all
on the stack, since (according to the recorded
information) nothing interesting ends up being
passed to leak.
In fact since f returns *q = p, &x is passed to leak
and x needs to be heap allocated.
To trigger the bug, you need a chain that the
compiler wants to keep on the stack (like x, p, q
above), and you need a function that returns an
indirect of its argument, and you need to pass the
head of the chain to that function. This doesn't
come up very often: this bug has been present since
June 2012 (between Go 1 and Go 1.1) and we haven't
seen it until now. It helps that most functions that
return indirects are getters that are simple enough
to be inlined, avoiding the bug.
Earlier versions of Go also had the benefit that if
&x really wasn't used beyond x's lifetime, nothing
broke if you put &x in a heap-allocated structure
accidentally. With the new stack copying, though,
heap-allocated structures containing &x are not
updated when the stack is copied and x moves,
leading to crashes in Go 1.3 that were not crashes
in Go 1.2 or Go 1.1.
The fix is in two parts.
First, in the analysis of a function, recognize when
a value obtained via indirect of a parameter ends up
being returned. Mark those parameters as having
content escape back to the return results (but we
don't bother to write down which result).
Second, when using the analysis to analyze, say,
f(q), mark parameters with content escaping as
having any indirections escape to the heap. (We
don't bother trying to match the content to the
return value.)
The fix could be less precise (simpler).
In the first part we might mark all content-escaping
parameters as plain escaping, and then the second
part could be dropped. Or we might assume that when
calling f(q) all the things pointed at by q escape
always (for any f and q).
The fix could also be more precise (more complex).
We might record the specific mapping from parameter
to result along with the number of indirects from the
parameter to the thing being returned as the result,
and then at the call sites we could set up exactly the
right graph for the called function. That would make
notleaks(f(q)) be able to keep x on the stack, because
the reuslt of f(q) isn't passed to anything that leaks it.
The less precise the fix, the more stack allocations
become heap allocations.
This fix is exactly as precise as it needs to be so that
none of the current stack allocations in the standard
library turn into heap allocations.
Fixes #8120.
LGTM=iant
R=golang-codereviews, iant
CC=golang-codereviews, khr, r
https://golang.org/cl/102040046
変更の背景
このコミットは、Goコンパイラのエスケープ解析における長年のバグを修正するものです。このバグは、Go 1とGo 1.1の間の2012年6月に、Russ Cox氏自身がコールグラフの依存関係順にエスケープ解析を段階的に実行するように変更した際に導入されました。この変更は、入力が(関数が引数を返すなどして)出力にエスケープするケースをより正確に扱うことを目的としていました。
問題は、func f(z **int) *int { return *z }
のように、パラメータの多重間接参照を返す関数において発生しました。エスケープ解析は、z
自体はエスケープせず、返されないと判断していましたが、これは不十分な情報でした。
具体的なバグのトリガーは以下のシナリオです。
var x int
p := &x
q := &p
leak(f(q)) // fは *q を返す。つまり &x が leak に渡される
このコードでは、f(q)
が*q
(つまりp
、さらにその指す&x
)を返します。しかし、エスケープ解析が不正確だったため、コンパイラはx
、p
、q
をすべてスタックに保持しようとする可能性がありました。これは、記録された情報によれば、leak
に興味深いものが何も渡されていないと判断されたためです。しかし実際には、f
は*q
(つまりp
)を返すため、&x
がleak
に渡され、x
はヒープに割り当てられる必要がありました。
このバグは、コンパイラがスタックに保持したいチェーン(上記のx
, p
, q
のようなもの)があり、引数の間接参照を返す関数があり、そのチェーンの先頭がその関数に渡される場合に発生します。このようなケースは稀であるため、このバグは2012年6月から存在していたにもかかわらず、Go 1.3がリリースされるまで発見されませんでした。多くの間接参照を返す関数がインライン化されるような単純なゲッターであったことも、バグが顕在化しなかった一因です。
Goの以前のバージョンでは、&x
がx
のライフタイムを超えて使用されなかった場合、誤ってヒープ割り当てされた構造体に&x
を配置しても問題は発生しませんでした。しかし、Go 1.3で導入された新しいスタックコピー機能により、スタックがコピーされてx
が移動しても、&x
を含むヒープ割り当てされた構造体が更新されなくなり、Go 1.2やGo 1.1では発生しなかったクラッシュを引き起こすようになりました。このため、このバグの修正が不可欠となりました。
前提知識の解説
このコミットを理解するためには、Goのエスケープ解析とメモリ管理に関する以下の概念を理解しておく必要があります。
エスケープ解析 (Escape Analysis)
Goのエスケープ解析は、コンパイラが行う最適化の一つで、変数がスタックに割り当てられるべきか、それともヒープに割り当てられるべきかを決定します。
- スタック (Stack): 関数呼び出しごとに確保されるメモリ領域で、関数のローカル変数や引数が格納されます。スタックに割り当てられた変数は、関数が終了すると自動的に解放されます。高速なアクセスが可能ですが、サイズに制限があり、関数のスコープを超えて生存することはできません。
- ヒープ (Heap): プログラム全体で共有されるメモリ領域で、動的にメモリを確保・解放します。ヒープに割り当てられた変数は、ガベージコレクションによって管理され、関数のスコープを超えて生存できます。スタックに比べてアクセスは遅く、ガベージコレクションのオーバーヘッドが発生します。
エスケープ解析の目的は、変数の生存期間を分析し、その変数が関数のスコープ外でも参照される可能性がある(「エスケープする」)かどうかを判断することです。
- エスケープしない場合: 変数はスタックに割り当てられます。
- エスケープする場合: 変数はヒープに割り当てられます。
変数がエスケープする典型的なケースには以下のようなものがあります。
- ポインタが関数から返される場合: 関数内で作成された変数のアドレスが、その関数の戻り値として返される場合。
func createPointer() *int { x := 10 // xはスタックに割り当てられるべきだが、そのアドレスが返されるためヒープにエスケープする return &x }
- ポインタがグローバル変数や、関数の引数として渡された構造体のフィールドに格納される場合:
var globalVar *int func storePointer(p *int) { globalVar = p // pが指す値はグローバルにエスケープする }
- クロージャが外部変数を参照する場合: クロージャが定義されたスコープ外で実行される可能性があるため、参照される外部変数はヒープにエスケープします。
エスケープ解析は、メモリ効率とパフォーマンスを向上させるために非常に重要です。不正確なエスケープ解析は、不要なヒープ割り当て(パフォーマンス低下)や、スタックに割り当てられた変数が関数の終了後に参照され、不正なメモリアクセス(クラッシュ)を引き起こす可能性があります。
Go 1.3のスタックコピー (Stack Copying)
Go 1.3より前のバージョンでは、Goのランタイムは「セグメントスタック」を使用していました。これは、関数呼び出しの深さに応じてスタックを動的に拡張する仕組みです。しかし、セグメントスタックにはいくつかの問題がありました。例えば、スタックの拡張・縮小に伴うオーバーヘッドや、Cgoとの連携の複雑さなどです。
Go 1.3では、これらの問題を解決するために「連続スタック」と「スタックコピー」が導入されました。連続スタックでは、スタックは連続したメモリ領域として確保され、必要に応じてより大きな領域にコピーされます。
このスタックコピーの導入により、エスケープ解析の正確性がより重要になりました。なぜなら、スタックに割り当てられた変数のアドレスがヒープ上の構造体などに格納されている場合、スタックがコピーされて変数のアドレスが変わると、ヒープ上のポインタが古いアドレスを指したままになり、不正なメモリ参照が発生してクラッシュにつながる可能性があるからです。
今回のバグは、まさにこのスタックコピーの導入によって顕在化したものであり、エスケープ解析の不正確さが直接クラッシュを引き起こすようになった典型的な例です。
技術的詳細
このコミットの修正は、エスケープ解析のロジックを改善し、特にパラメータの間接参照が関数の戻り値としてエスケープするケースを正確に検出することに焦点を当てています。修正は大きく2つの部分に分かれます。
1. 関数解析時におけるパラメータ内容のエスケープ検出
関数のエスケープ解析を行う際、パラメータのポインタを介してアクセスされる値(「内容」)が、関数の戻り値として返されるかどうかを認識します。
EscContentEscapes
フラグの導入:src/cmd/gc/go.h
に新しいエスケープフラグEscContentEscapes
が導入されました。これは、パラメータの内容が何らかの戻り値にエスケープすることを示すビットフラグです。escwalk
関数の変更:src/cmd/gc/esc.c
のescwalk
関数は、エスケープ解析の主要なウォーカーです。この関数内で、パラメータ(src->class == PPARAM
)が戻り値(dst->class == PPARAMOUT
)に流れる場合、かつその流れが間接参照(level > 0
)を伴う場合に、src->esc
にEscContentEscapes
フラグが設定されるようになりました。これにより、パラメータ自体ではなく、そのパラメータが指す内容がエスケープするという情報が記録されます。
2. 呼び出しサイトにおける間接参照のエスケープ処理
関数が呼び出される際、その関数のパラメータに EscContentEscapes
フラグが設定されている場合、そのパラメータが指す任意の間接参照がヒープにエスケープするとマークされます。
EscState
構造体へのfuncParam
ノードの追加:src/cmd/gc/esc.c
のEscState
構造体に、特殊なノードfuncParam
が追加されました。このノードは、パラメータの内容がエスケープする際に、そのエスケープ先として使用されます。funcParam
はescloopdepth
が非常に大きな値に設定されており、常にヒープにエスケープするような振る舞いをします。escassignfromtag
関数の変更:escassignfromtag
関数は、エスケープ解析の結果に基づいて割り当てを行う関数です。この関数内で、em & EscContentEscapes
が真の場合(つまり、パラメータの内容がエスケープするとマークされている場合)、escassign(e, &e->funcParam, src)
が呼び出されます。これにより、src
(パラメータ)が指す内容がfuncParam
にエスケープするとマークされ、結果的にヒープに割り当てられるようになります。escassign
関数の変更:escassign
関数は、エスケープ解析のフローを伝播させる関数です。この関数内で、dst == &e->funcParam
の場合に、src
が指すポインタがヒープにエスケープすると判断されるロジックが追加されました。
精度に関する考慮事項
コミットメッセージでは、この修正の精度について議論されています。
-
より低い精度(より単純な修正):
- 最初の部分で、内容がエスケープするすべてのパラメータを単純なエスケープとしてマークし、2番目の部分を省略する。
f(q)
を呼び出す際に、q
が指すすべてのものが常にエスケープすると仮定する。 これらのアプローチは実装が単純になりますが、より多くのスタック割り当てがヒープ割り当てに変わり、パフォーマンスが低下する可能性があります。
-
より高い精度(より複雑な修正):
- パラメータから結果への具体的なマッピングと、パラメータから返されるものへの間接参照の数を記録する。
- 呼び出しサイトで、呼び出された関数に対して正確なグラフを設定する。
これにより、
notleaks(f(q))
のようなケースでx
をスタックに保持できるようになります。しかし、実装は非常に複雑になります。
このコミットで採用された修正は、「現在の標準ライブラリにおけるスタック割り当てがヒープ割り当てに変わらないように、必要なだけの精度」を持つように設計されています。これは、パフォーマンスへの影響を最小限に抑えつつ、バグを効果的に修正するためのバランスの取れたアプローチです。
コアとなるコードの変更箇所
このコミットでは、主にGoコンパイラのバックエンドであるcmd/gc
内のエスケープ解析関連のファイルが変更されています。
-
src/cmd/gc/esc.c
:- エスケープ解析の主要なロジックが記述されているファイルです。
EscState
構造体にfuncParam
という新しいNode
が追加され、パラメータの内容がエスケープする際の特別なターゲットとして機能します。analyze
関数内でfuncParam
ノードの初期化が行われます。escassignfromtag
関数が変更され、EscContentEscapes
フラグが設定されたパラメータの内容をfuncParam
に割り当てるロジックが追加されました。escwalk
関数が変更され、パラメータの間接参照が戻り値に流れる場合にEscContentEscapes
フラグを設定するロジックが追加されました。また、leaks
の判定ロジックも更新され、funcParam
へのエスケープを考慮するようになりました。
-
src/cmd/gc/go.h
:- Goコンパイラで使用される共通の定義や構造体が記述されているヘッダーファイルです。
enum
に新しいエスケープフラグEscContentEscapes
が追加されました。これは、パラメータの内容が戻り値にエスケープすることを示すビットフラグです。EscBits
の値が4
から3
に変更され、EscReturnBits
がEscBits+1
として定義されました。これにより、エスケープフラグのビット割り当てが調整されています。
-
test/escape2.go
:- エスケープ解析のテストケースが記述されているファイルです。
issue 8120
というコメントとともに、このバグを再現し、修正が正しく機能することを確認するための新しいテストケースが追加されました。U
およびV
という構造体と、それらを使用するString()
メソッドやNewV
関数が定義され、パラメータの間接参照がエスケープするシナリオが検証されています。- 既存のテストケースの一部で、期待されるエスケープ解析の結果(エラーメッセージ)が更新されています。例えば、
AlsoNoLeak()
やLeaksABit()
のコメントが// ERROR "b does not escape"
から// ERROR "leaking param b content to result ~r0"
に変更されています。
コアとなるコードの解説
src/cmd/gc/go.h
の変更
enum
EscNone,
EscReturn,
EscNever,
EscBits = 3, // 変更前は 4
EscMask = (1<<EscBits) - 1,
EscContentEscapes = 1<<EscBits, // 新規追加
EscReturnBits = EscBits+1, // 新規追加
};
EscBits
が4
から3
に変更されました。これは、エスケープフラグのビットフィールドのサイズを調整するためです。EscContentEscapes
が新しく追加されました。これは、パラメータの内容が(間接参照を介して)戻り値にエスケープすることを示すフラグです。EscReturnBits
が新しく追加されました。これは、戻り値に関連するエスケープフラグの開始ビット位置を定義します。
src/cmd/gc/esc.c
の変更
EscState
構造体への funcParam
の追加
struct EscState {
// ... 既存のフィールド ...
// If an analyzed function is recorded to return
// pieces obtained via indirection from a parameter,
// and later there is a call f(x) to that function,
// we create a link funcParam <- x to record that fact.
// The funcParam node is handled specially in escflood.
Node funcParam;
// ... 既存のフィールド ...
};
funcParam
は、パラメータの内容がエスケープする際に、そのエスケープ先として使用される特別なノードです。これにより、エスケープ解析のフローグラフ内で、パラメータの内容がヒープにエスケープするという情報を表現できます。
analyze
関数での funcParam
の初期化
analyze(NodeList *all, int recursive) {
// ... 既存の初期化 ...
e->funcParam.op = ONAME;
e->funcParam.orig = &e->funcParam;
e->funcParam.class = PAUTO;
e->funcParam.sym = lookup(".param");
e->funcParam.escloopdepth = 10000000; // 非常に大きな値で、常にヒープにエスケープするように振る舞う
// ... 既存の処理 ...
}
funcParam
ノードが初期化され、そのescloopdepth
が非常に大きな値に設定されます。これにより、このノードに到達する値は、事実上ヒープにエスケープすると見なされます。
escassignfromtag
関数の変更
escassignfromtag(EscState *e, Strlit *note, NodeList *dsts, Node *src) {
// ... 既存の処理 ...
// If content inside parameter (reached via indirection)
// escapes back to results, mark as such.
if(em & EscContentEscapes)
escassign(e, &e->funcParam, src); // ここで funcParam に割り当てられる
// ... 既存の処理 ...
for(em >>= EscReturnBits; em && dsts; em >>= 1, dsts=dsts->next) // EscBits から EscReturnBits に変更
// ... 既存の処理 ...
}
em & EscContentEscapes
の条件が追加されました。これは、エスケープ解析の結果タグem
にEscContentEscapes
フラグが含まれている場合(つまり、パラメータの内容がエスケープするとマークされている場合)に真となります。- この条件が真の場合、
escassign(e, &e->funcParam, src)
が呼び出されます。これは、src
(パラメータ)が指す内容がfuncParam
にエスケープするという情報をエスケープ解析のフローグラフに追加します。 - ループの開始条件が
em >>= EscBits
からem >>= EscReturnBits
に変更されました。これは、エスケープフラグのビット割り当ての変更に対応するためです。
escwalk
関数の変更
escwalk(EscState *e, int level, Node *dst, Node *src) {
// ... 既存の処理 ...
// Input parameter flowing to output parameter?
if(dst->op == ONAME && dst->class == PPARAMOUT && dst->vargen <= 20) {
if(src->op == ONAME && src->class == PPARAM && src->curfn == dst->curfn && src->esc != EscScope && src->esc != EscHeap) {
if(level == 0) { // 直接のパラメータ
// ... 既存の処理 ...
src->esc |= 1<<((dst->vargen-1) + EscReturnBits); // EscBits から EscReturnBits に変更
goto recurse;
} else if(level > 0) { // 間接参照を介したパラメータ
// ... 警告メッセージ ...
if((src->esc&EscMask) != EscReturn)
src->esc = EscReturn;
src->esc |= EscContentEscapes; // ここで EscContentEscapes を設定
goto recurse;
}
}
}
// ... 既存の処理 ...
// The second clause is for values pointed at by an object passed to a call
// that returns something reached via indirect from the object.
// We don't know which result it is or how many indirects, so we treat it as leaking.
leaks = level <= 0 && dst->escloopdepth < src->escloopdepth ||
level < 0 && dst == &e->funcParam && haspointers(src->type); // 新規追加
// ... 既存の処理 ...
}
escwalk
は、エスケープ解析のフローをたどる関数です。level > 0
の条件が追加されました。これは、パラメータが間接参照を介して戻り値に流れるケースを検出します。この場合、src->esc
にEscContentEscapes
フラグが設定されます。leaks
の判定ロジックに新しい条件が追加されました。level < 0 && dst == &e->funcParam && haspointers(src->type)
は、funcParam
にエスケープするポインタを持つ値がヒープにエスケープすると判断するためのものです。level < 0
は、呼び出しサイトでの解析を示す特別なレベルです。
これらの変更により、Goコンパイラは、パラメータの間接参照が関数の戻り値としてエスケープする複雑なシナリオを正確に識別し、関連する変数を適切にヒープに割り当てることで、Go 1.3のスタックコピー機能との互換性を確保し、クラッシュを防ぐことができます。
関連リンク
- Go Issue: Fixes #8120
- Gerrit Change-ID: https://golang.org/cl/102040046