メニュー

2013年10月15日

Go言語のスタックとヒープ

GoCon 2013 Autumn で「Go言語のスタックとヒープ」という発表をしました。

資料はこちら: http://goo.gl/s6at62

スライドだけでは分かりにくい部分もあるので、ブロク記事として以下にも記しておきます。(この記事を読めば、スライドは読まなくてOKなはず)

スタックとヒープについて


実行時に動的にメモリを確保する領域として、スタックとヒープがある。

スタックメモリは関数のコールスタックを格納していて、ローカル変数、引数、戻り値もここに置かれる。

スタックのPushとPopは高速なので、オブジェクトをスタックメモリに確保するコストは小さい。ただし関数を抜けてスタックがPopされると解放されるので、関数の寿命を超えてオブジェクトは生存できない。

一方のヒープメモリは、コールスタックとは関係ないので、関数スコープに縛られずにオブジェクトを確保しておける。ただし空き領域を探したり、GCで不要となったオブジェクトを回収したりするので、処理コストがかかる。

※Go風には値というべきかもしれないが、この記事ではメモリを使う何らかの実体のことはオブジェクトと呼ぶ

Go言語ではコンパイラが、オブジェクトをスタックに確保するかヒープに確保するか決定するので、プログラマが意識する必要は通常はない。

※なおGo言語ではスタックメモリが不足すると、新しいチャンクが確保されて追加されるので、スタックにメモリを積み過ぎて死ぬ(StackOverflow)ようなことは、発生しにくくなっているらしい。よって大きいオブジェクトを積んでもよい(というか、そのへんはコンパイラが上手くやってくれる)。

意識する必要はない、とはいえスタックよりヒープのほうが処理コストが大きいので、場合によってはパフォーマンス上の問題になりうる。

そこで、どのような場合でオブジェクトがスタックもしくはヒープが使われるのか、確認してみる。

簡単に言うと


  • 関数内だけで使われる値は、スタックに置かれる。
  • ある関数内で確保した値が、関数外でも必要になるなら、ヒープに置かれる。

単純に言ってしまうと、結論は上記の通り(例外はあるが、大筋では)。

これだけ読むと当たり前の結論としか言えないが、いくつか面白いパターンがあるので、興味ある人は続きをどうぞ。

確認方法


コンパイラにフラグを渡して詳細を知ることが出来る。
> go build -gcflags -m hello.go
以下のような表示がされる。
./hello.go:10: moved to heap: n
./hello.go:11: &n escapes to heap
./hello.go:17: m dones not escape
上記では変数 n がヒープに置かれ、変数 m がスタックに置かれていることが分かる。

定義部分


この記事で試すスニペットの共通の定義部分は以下のとおり。
type Duck struct{}

func (d *Duck) Sound() {
  fmt.Println("quack")
}

type Sounder interface {
  Sound()
}
Duckというquackと鳴く構造体がある。また、Soundメソッドを持っているSounderというインタフェースがあり、DuckはSounderに準拠しているのでSounderである。

ローカル変数


1.) 値型の(ポインタではない)変数、関数内でだけ使っている
func test1() {
  var d Duck = Duck{}
  d.Sound()
}
「スタック」になる。

※これは多くの言語でもスタックになる想定通りのパターン


2.) ポインタ型の変数、関数内でだけ使っている
func test2() {
  var d *Duck = &Duck{}
  d.Sound()
}
これは以下のように書いても同じ意味になる。
func test2b() {
    var d *Duck = new(Duck)
    d.Sound()
}
「スタック」になる。

※いわゆるnewしてもスタックに置かれるパターン
※コードを 1. に置き換えても意味が同じだし、変数が関数内におさまっているので、スタックに置けるとコンパイラが判断しているのだろう

変数を戻り値で返す


3.) 値型の変数を、値型の戻り値として返す
func test3() Duck {
  var d Duck = Duck{}
return d
}
「スタック」になる。

※値型を戻り値で返しても値コピーされるだけで、変数 d は関数内におさまっている。


4.) ポインタ型の変数を、値型の戻り値として返す
func test4() Duck {
  var d *Duck = &Duck{}
return *d
}
「スタック」になる。

3. とほぼ同じなので。


5.) 値型の変数のアドレスを、ポインタ型の戻り値として返す
func test5() *Duck {
  var d Duck = Duck{}
return &d
}
「ヒープ」になる。

変数 d は戻った後も使われる可能性があるのでスタックに置いておくわけにはいかない。よってヒープ。

※C言語などの他の言語で、上記のようなコードを書いてはダメです。ローカル変数のアドレスを返すなんてとんでもない。
※Go言語では大丈夫です。たまたまではなく、言語仕様として大丈夫です。

ただし、この関数はインライン展開されるので、以下のように関数 test5 を使った場合で
func hoge() {
    d := test5()
    d.Sound()
}
関数 hoge 内に test5 がインライン展開される。そして変数 d は関数 hoge 内におさまっているので、スタックに置けることになり、スタックに置かれる(なので高速)。


6.) ポインタ型の変数のアドレスを、ポインタ型の戻り値として返す
func test6() *Duck {
    var d *Duck = &Duck{}
    return d
}
「ヒープ」になる。

5. とほぼ同じなので。インライン展開についても同様。


5b.) メソッドを呼び出ししてから変数のアドレスを、ポインタ型の戻り値として返す
func test5b() *Duck {
    var d Duck = Duck{}
    d.Sound()
    return &d
}
「ヒープ」になる。

5の亜種だが、メソッド呼び出しが関数内に存在する。この場合、インライン展開はされない模様。

interface


7.) 変数のアドレスをinterfaceに代入、関数内でだけ使っている
func test7() {
    var d Sounder = &Duck{}
    d.Sound()
}
「ヒープ」になる。

2. の場合とほぼ同じ処理で、変数 d がDuckポインタだったものがSounder interfaceに変わったもの。2. はスタックだが、こちらはヒープになっている。

※interface変数に入れるとヒープになる模様


8.) 変数のアドレスをinterfaceに代入、戻り値として返す
func test8() Sounder {
    var d Sounder = &Duck{}
    return d
}
「ヒープ」になる。

これは 5. や 6. と同じようなものなので、やはりヒープ。インライン展開もされる。ただし、インライン展開された呼び出し元でもヒープに置かれる。これは 7. の挙動と同じで、interfaceなのでヒープになっているものと思われる。

slice, map, array


基本的にはstructと同じ。

ポインタ型のスライス []*Duck に入れるとヒープに置かれるなどもあるので、いずれ調べてみたい。


メソッド


メソッドのレシーバーがポインタ型 *Duck で、呼び出し時の変数が値型 Duck の場合などにもいろいろありそうなので、いずれ調べてみたい。

まとめ


  • 関数内だけで使われる値は、スタック
  • 関数外でも値が必要になるなら、ヒープ
  • newしてもスタックの場合がある
    • 関数内だけで使われる値ならば
  • ローカル変数のアドレスを返しても良い
    • ヒープに置かれるようコンパイラが扱う
  • インライン展開によって上手く最適化してくれる
  • interfaceに代入するとヒープ

Goコンパイラ賢い!

0 件のコメント:

コメントを投稿