はじめに
Goの学習を進めていく中で、普段あまりメモリ管理に気を使わずに学習を進めていました。
ただ、Goのコーディングのしやすさの要素の一つとして、「メモリ管理」が関わっているのではないかと思い、今回記事にまとめることにしました。
メモリ管理が全てであるということ
現代のプログラミングにおいてCPUがボトルネックになることはほとんどありません。
「メモリI/O、ネットワークI/O、ディスクI/O」などのI/Oによってプロップされます。
Goのコンパイラーとランタイムは賢いメモリ管理をしているので、むやみにメモリ管理を改善しようとすると失敗してしまいます。
では、メモリ管理を理解するためには、まずOSの仕組みを理解する必要があります。Linux上でプログラムがどのように動作しているのかを確認していきましょう。
OSの仕組み
こちらは軽く触れようと思います。(こちらで深掘っています)
まず、Linux上でプログラムがどのように動いているのか確認していきましょう。
上図からもわかるように、プログラムが直接物理的な記憶媒体にアクセスすることはできません。プロセスはOSを経由することでのみ記憶媒体にアクセスすることが可能です。
したがって、プロセスはOSから提供される仮想メモリという仮想的な空間を使用します。
上図に細かく記載してありますが、最も伝えたいことは以下の点です。
プロセスからは仮想メモリしか見えないということ
仮想メモリ
先ほども触れましたが、プロセスからは仮想メモリしかさわれません。
ここで意識することは青色で示されている「ヒープ」と「スタック」です。
Goのランタイムとプロセスの「ヒープ」および「スタック」の関係性を知っていく必要があります。
OSとの対応
プロセスの中の「ヒープ」というのが、Goランタイムのグローバルなオブジェクトである「mheap」に相当します。
また、「スタック」に関してですが、goroutineのスタックは2KB
でとても小さいです。
OSのデフォルトサイズ(8MB
)と比較してもかなり小さいことがわかるかと思います。
そうなると無駄が生じてしまうので、Goのランタイムは一つ一つのgoroutineにアサインされた細かいスタックをいい具合にOSスレッドのスタックに格納しています。
大体プログラムを書いていると「ヒープ」が問題であることが多いです。
GC(ガベージコレクション1)に関してもヒープを処理するものです。
そのため、ヒープを理解していくことがとても重要になってきます。
Goの内部のメモリ構造
まず、Goランタイム全体で共有されている「mheap」があります。
そして、このmheapの中に「arena」という単位でメモリを確保しています。
さらに、arenaの中に「mcentral」というバケットがあります。
それでは、各要素を順に見ていきましょう。
mheap(ページヒープ)
Goが動的データ(コンパイル時にサイズが計算できないデータ)を保持する場所です。
最大のメモリブロックであり、GCが行われます。
mspan
- mspanはmheap内でメモリのページを管理する最も基本的な構造です
- これは「開始ページのアドレス」、「スパンサイズクラス」、「スパン内のページ数」を保持する双方向連結リストです
- 各スパンは、ポインタ(
scan
クラス)を持つオブジェクト、もう一つはポインタを持たない(noscan
クラス)オブジェクトの2種類が存在します- これはGCの際に、残っているオブジェクトを探すためにnoscanスパンを通過する必要がないため役に立ちます
メモリのアローケートは高コストな操作であるため、Goのランタイムは効率的なメモリ管理を行っています。具体的には、事前にメモリを確保しておき、その後必要に応じて使い回すという戦略を取っています。
mspanの中には、小さなspanというオブジェクトがたくさんあり、それらがクラスごとに分かれてmcentralに保存されます。
mcentral
mcentralは、同じサイズのクラスのスパンをまとめてグループ化します。
各mcentralは2つのmspanListを含みます
empty
- 空きオブジェクトを持たないスパンや、mcacheにキャッシュされているスパンの双方向連結リスト
- ここでスパンが解放されると、
non empty
リストに移動します
- ここでスパンが解放されると、
non empty
- 空きオブジェクトを持つスパンの双方向連結リスト
-
mcentralから新しいスパンが要求されると、
non empty
リストからemtpy
リストに移動します
mcentralに空きスパンが存在しない場合、mheapから新しいページを要求します。
arena
mheapの中では「arena」という単位でメモリを確保しています。
ヒープメモリは、割り当てられた仮想メモリの範囲内で必要に応じて増減します。
より多くのメモリが必要になると、mheapはarenaと呼ばれる64MB(64ビットアーキテクチャの場合)のチャンク2として仮想メモリから引き出します。
ここではページがスパンにマッピングされます。
mcache
mcacheは、P(論理プロセッサ)に提供されるメモリのキャッシュであり、小さなオブジェクト(サイズが32KB以下)を格納します。
これはスレッドスタックに似ていますが、ヒープの一部として動的データのために使用されます。
mcacheには、全てのクラスサイズに対応したscanタイプとnoscanタイプのmspanが含まれています。
Pは同時に一つのgoroutineしか保持できないため、goroutineはロックされることなくmcacheからメモリを確保することができ、非常に効率的です。mcacheは必要に応じてmcentralから新しいスパンを要求します。
stack
スタックメモリ領域でgoroutine毎に1つのスタックが存在します。この領域には関数フレーム、静的構造体、プリミティブ値、そして動的な構造体を保持するためのポインタを含む静的データが保持されています。
これはPに割り当てられるmcache
とは異なります。
では、どのようにGoではオブジェクトを割り当てているのか見ていきましょう。
スタックとヒープ
Golangは内部で自動的にメモリの割り当てを行います。
ローカル変数として宣言された変数が、他の関数に渡されたり返り値として使用される場合は、その変数の寿命が長くなるため、ヒープに割り当てられることがあります。
基本的には、高速性を優先してスタックに割り当てられますが、外部の関数や返り値として渡される場合は、ヒープに移されます。
以下のテーブルのようにまとめられます。
ヒープ | スタック | |
---|---|---|
スコープ | レキシカルスコープ3外 | レキシカルスコープ3内 |
用途 | グローバル変数・巨大なデータ | ローカル変数・関数の引数と戻り値 |
ポインターの有無 | ポインター使用 | ポインターでない |
ライフサイクル | 予測不能 | LIFO(Last In, First Out) |
スタックのライフサイクルは、データをすぐに入れてすぐに取り出すことができます。また、レキシカルスコープ内で完結しているため、ガベージコレクション(GC)が不要です。そのため、コンパイル時間でメモリの処理がわかっているため、速くなります。
一方で、ヒープはいつまでオブジェクトが参照されるのかがわからないので、GCなどを使用して消してあげる必要があります。
以上の点より、スタックでいいならスタックを使いましょう。
例えば以下のコードはの変数s
はヒープとスタックのどちらにあるでしょう。
package main
import (
"fmt"
)
type Rectangle struct {
Width, Height int
}
func main() {
for i := 0; i < 100; i++ {
s := NewRectangle(i, 2*i)
fmt.Println(s.Area())
}
}
func NewRectangle(w, h int) Rectangle {
return Rectangle{
Width: w,
Height: h,
}
}
func (r Rectangle) Area() int {
return r.Width * r.Height
}
Goでは、-gcflags -m
を渡すことで、メモリがスタックとヒープどちらに確保されているかを知ることができます。
では、以下のコマンドを叩いてみましょう。
$ go build -gcflags -m main.go
// 出力結果
./main.go:18:6: can inline NewRectangle
./main.go:25:6: can inline Rectangle.Area
./main.go:13:20: inlining call to NewRectangle
./main.go:14:21: inlining call to Rectangle.Area
./main.go:14:14: inlining call to fmt.Println
<autogenerated>:1: inlining call to Rectangle.Area
./main.go:14:14: ... argument does not escape
./main.go:14:21: ~r0 escapes to heap
出力を見てみると様々な関数がインライン化されています。
NewRectangle
関数は値を返しています。そのためdoes not escape
と出ます。
また、最後の行を見てみると./main.go:14:21: ~r0 escapes to heap
でヒープに置かれていることが確認できます。
では、NewRectangle
関数をポインタを返すように変えてみましょう。
func main() {
for i := 0; i < 100; i++ {
s := NewRectangle(i, 2*i)
fmt.Println(s.Area())
}
}
// ポインタを返すようにする
func NewRectangle(w, h int) *Rectangle {
return &Rectangle{
Width: w,
Height: h,
}
}
func (r Rectangle) Area() int {
return r.Width * r.Height
}
さっきのテーブルに記載していた通り、レキシカルスコープ外に行っているようなものであれば、ヒープに割り当てられるのではないかと感じるかもしれませんが、出力結果をみてみると、、、、
./main.go:18:6: can inline NewRectangle
./main.go:25:6: can inline Rectangle.Area
./main.go:13:20: inlining call to NewRectangle
./main.go:14:21: inlining call to Rectangle.Area
./main.go:14:14: inlining call to fmt.Println
// インライン展開されたものはスタックにあります
<autogenerated>:1: inlining call to Rectangle.Area
./main.go:13:20: &Rectangle{...} does not escape
./main.go:14:14: ... argument does not escape
./main.go:14:21: ~r0 escapes to heap
// 戻り値はヒープに渡されます
./main.go:19:9: &Rectangle{...} escapes to heap
./main.go:13:20: &Rectangle{...} does not escape
をみるとインライン展開4されているのでスタックに渡されます。
does not escape
- 変数
&Rectangle{...}
は関数のスコープ外にエスケープしないことを示しています - つまり、スタックに割り当てられていると言える
escapes to heap
- 変数
&Rectangle{...}
や~r0
はヒープにエスケープすることを示しています - これは、これらの変数が関数のスコープ外で必要とされるため、ヒープメモリに割り当てられます
Goのコンパイラは、効率的なメモリ管理を実現するためにさまざまな最適化を行なっていることが確認できました。
Goのコンパイラは賢いですね〜。
プリミティブ型のサイズ
データ型のサイズは以下の通りです。
データ型 | サイズ |
---|---|
bool |
1バイト |
int |
8バイト(64ビット) |
int8 |
1バイト |
int16 |
2バイト |
int32 |
4バイト |
int64 |
8バイト |
uint |
8バイト(64ビット) |
uint8 (byte ) |
1バイト |
uint16 |
2バイト |
uint32 |
4バイト |
uint64 |
8バイト |
uintptr |
8バイト |
float32 |
4バイト |
float64 |
8バイト |
complex64 |
8バイト(float32 が2つ) |
complex128 |
16バイト(float64 が2つ) |
string |
ポインタ+長さに応じて変動 |
A Tour of Go
ここで気をつけるべき点は、string
型、slice
型、map
型についてです。
これらは、裏で配列のようなものを持っており、そこに対するポインターを持つ構造体というのがメインな部分となっています。
このため、string
やmap
, slice
のポインタを渡す場合の意味をよく理解する必要があります。
つまり、ポインターはアドレスの値を持った数値でしかないので、ポインターのポインターをとったところで、二度手間になっているだけなんですね。
さらに、ポインターにするとヒープに割り当てられる可能性が高くなるので処理が遅くなることが考えられます。
必要以上にポインターを渡さない方が良い!
では、以下の構造体はどのくらいのメモリが確保されるでしょうか?
type Example struct {
A uint32 // 4byte
B uint16 // 2byte
}
The Go Playground:実行してみましょう。
上のテーブルより、「6byteだ!」と思うかもしれませんが実は、8byteになります。なぜなら、「メモリアラインメント」があるからです。
メモリアラインメント
メモリアラインメント
- メモリを効率的に使用するために、CPUやGoランタイムが決まったサイズのメモリを取得する仕組みです
- データが特定の境界に揃えられることで、CPUのアクセス速度が向上し、全体的なパフォーマンスが向上します
余った部分はパディングと言って、「0」で埋めてくれます。
このメモリアライメントの特徴として、フィールドの並びによってサイズが変わるということです。
type Example struct {
C bool
B uint32
A uint16
}
The Go Playground:実行してみましょう
12byte
と出力されますね
type Example struct {
A uint32
B uint16
C bool
}
The Go Playground:実行してみましょう
8byte
と出力されました。
どうなっているのか画像と一緒に見てみましょう。
チューニングする際は、こういったところで多少のチューニングができそうですね。
ガベージコレクション(GC)
Goはガベージコレクションによってヒープメモリを管理しています。
つまり、参照されていないオブジェクトが使用していたメモリを解放して、新しいオブジェクトを作成するためのスペースを確保します。
- 頻度が低いとOSからのメモリ確保の頻度が上がる
- 頻度が高いとプログラムが止められずぎて遅くなる
そのためGCには以下のような仕組みがあります。
-
Mark Setup(Stop the world)
- GCが始まると、コレクタは書き込みバリアをオンにし、次の並行フェーズでも整合性が保たれるようにします
- このステップでは、実行中の全てのgoroutineが一時的に停止され、その後続行されます。ごくわずかな一時停止が必要です
-
Marking(Concurrent)
- 書き込みバリアがオンになると、実際のマーキング処理がアプリケーションと並列して開始され、使用可能なCPUの25%が使用されます。
- マーキングが完了するまで、対応したPは占有されています
- これは専用のgoroutineを使って行われます。ここでGCは稼働中(アクティブなgoroutineのスタックから参照されている)ヒープ上の値をマークします
- 収集に時間がかかる場合は、アプリケーションのアクティブなgoroutineを使用してマーキングプロセスを支援します(=
マーク支援
)
-
Mark Termination(Stop the world)
- マーキングが完了すると、すべてのアクティブなgoroutineは一時停止し、書き込みバリアがオフになり、クリーンアップ作業が開始する。また、GCはここで次のGCのゴールを計算します
- これが完了すると、確保されたPは解放されアプリケーションに戻されます
-
Sweeping(Concurrent)
- 収集が完了し、割り当てが試みられると、スウィーピングプロセスは、未活動とマークされたヒープからメモリを取り戻し始めます
- メモリの吐き出し量は、割り当てられている量と同期しています
多くの言語ではマークアンドスイープアルゴリズムが使われているようで、Golangでも採用されています。
さいごに
今回は、Goにおけるメモリ管理についてまとめてみました。具体的には、スタックとヒープの違いや、ガベージコレクションの仕組みとその影響について学べました。
内部構造を知ることで、Goの様々な工夫に触れることができましたね。
今後も、Goの深い理解と実践的な応用を追求していこうと思います!!
参考