ぴよぴよ日記

調べたことで有益そうなことを残してく

クロージャーのメモリ割り当てについて(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のやり取りから、予想した通り、ヒープ領域への割り当てが行われていることがわかった。