クロージャーのメモリ割り当てについて(Go言語)
A Tour of GoでGo言語に入門していて、クロージャーのメモリ割り当てについて疑問に思ったので調べた。
クロージャーとは
A Tour of Go での説明をまとめると、
本体の外部から変数を参照する関数値
関数は、参照した変数にアクセスして割り当てることができる
という特徴がある。
サンプルコード
package main import "fmt" func adder() func() int { sum := 0 return func() int { sum++ return sum } } func main() { f := adder() for i := 0; i < 10; i++ { fmt.Println(f()) } }
出力
1 2 3 4 5 6 7 8 9 10
adder 関数はクロージャーを返し、各クロージャーは、sum 変数にバインドされている。
疑問点
サンプルコードではクロージャーが、adder関数で定義されたsum変数を参照、割り当てしてる。しかし、関数呼び出しといえばスタックフレームを用いるイメージしかない私にとっては、sum変数の参照がどこに残っているのか疑問。おそらくヒープ領域に割り当てられてる?
GitHub issue でのやり取り
調べたところ、同じ疑問に答えているissueを見つけた。 質問者は、同じような処理をクロージャーを使用する場合と使用しない場合で試している。そして、クロージャーを使用した場合だとヒープ領域への割り当てが行われると言っている。
実際のコード
package main import ( "fmt" "sync" "testing" ) type Object struct { } var p sync.Pool = sync.Pool{ New: func() interface{} { return &Object{} }, } type Func struct { ctx interface{} } func (this *Func) Run() { p.Put(this.ctx) } func RunWithFunc() Func { ctx := p.Get() return Func{ctx: ctx} } func RunWithClosure() func() { ctx := p.Get() return func() { p.Put(ctx) } } func Test1() { cleanup := RunWithFunc() cleanup.Run() } func Test2() { cleanup := RunWithClosure() cleanup() } func main() { f1 := testing.AllocsPerRun(1000, Test1) f2 := testing.AllocsPerRun(1000, Test2) // 0 fmt.Println(f1) // 1 fmt.Println(f2) }
コードの詳しい内容は、
クロージャーを使わない
RunWithFunc
と使用するRunWithClosure
を実行する。どちらも大雑把に言うと、空の構造体を
sync.Pool
から取り出したり戻したりする。クロージャーを使うとヒープ領域への割り当てが行われることを
testing.AllocsPerRun
が示す。
といった感じ。
回答者は以下のように言っている。
問題は、
RunWithClosure
がクロージャーを返す必要があることです。関数が実行される前にスタック フレームがなくなるため、スタックに割り当てることができません。 可能な場合は、スタックにクロージャーを割り当てます。スタック上にクロージャ(これらの2つのフィールドの匿名構造体)を割り当て、呼び出された関数にそれらへのポインタを渡すことができますし、実際に行っています。ここでの問題は、その構造体が
RunWithClosure
の内部で割り当てられ、RunWithClosure
のフレームは、cleanupを呼び出すまでになくなってしまうことです。そのため、RunWithClosure
のフレームでクロージャを割り当てることはできません。それは、ヒープ上に割り当てられなければなりません。 もし、RunWithClosure
をその呼び出し元にインライン化すれば、そのスタック・フレームが十分に長く生きるので、呼び出し元でクロージャを割り当てることができるようになります。
クロージャーが実行される前に、参照先をもつスタックフレームがなくなってしまう場合、それをヒープ領域に割り当てるらしい。またそれを避けたい場合は、関数になっている部分をインライン化するといいらしい。
まとめ
Go言語に入門していて、クロージャーが参照している変数がどこに残っているか疑問に思ったが、GitHub issueのやり取りから、予想した通り、ヒープ領域への割り当てが行われていることがわかった。