😸

Goにおけるメモリ管理の可視化

2021/10/29に公開

はじめに

この記事は@deepu105に許可を頂きVisualizing memory management in Golangという記事の翻訳したものになります。

Goのメモリ管理を図やスライドを活用して非常に分かりやすく説明されていたため、学習として翻訳しました。

以降が実際の記事の翻訳になります。


これは"メモリ管理"シリーズになります。

  1. 🚀 Demystifying memory management in modern programming languages
  2. 🚀 Visualizing memory management in JVM(Java, Kotlin, Scala, Groovy, Clojure)
  3. 🚀 Visualizing memory management in V8 Engine (JavaScript, NodeJS, Deno, WebAssembly)
  4. 🚀 Visualizing memory management in Golang
  5. 🚀 Visualizing memory management in Rust
  6. Avoiding Memory Leaks in NodeJS: Best Practices for Performance

このシリーズでは、メモリ管理の背後にある概念を紐解き、モダンなプログラミング言語におけるメモリ管理について深堀りすることを目的としています。私はこのシリーズを通して、先ほど述べた言語のメモリ管理がどのように行われているかを皆さんに少しでも理解して頂けると嬉しいです。

この章では、Goのメモリ管理について見ていきたいと思います。Goは静的型付け言語であり、C/C++やRustのようなコンパイル言語です。したがって、GoはVMを必要とせず、Goアプリケーションのバイナリは、ガベージコレクション、スケジューリングや並行性のような言語機能を処理する小さなランタイムが組み込まれています。

もしあなたがこのシリーズの最初の記事をまだ読んでいない場合は、まず初めにそちらの記事を読むことで、この章で私が説明したスタックとヒープメモリの違いを理解することに役立つと思います。

この記事はGo1.13の公式デフォルトの公式実装に基づいており、コンセプトの詳細はGoの将来のバージョンで変更される可能性があります

Goの内部メモリ構造

はじめに、Goの内部メモリ構造がどのようなものか見ていきましょう。

Goランタイムは、goroutine(G)を論理プロセッサ(P)にスケジューリングして実行します。各(P)にはマシン(M)があります。この記事では、P、M、Gを使用します。Goのスケジューラに慣れてない方は、まずGo scheduler: Ms, Ps & Gs を読んでみてください。

Goプログラムの各プロセスは、オペレーティング・システム(OS)によっていくつかの仮想メモリが割り当てられており、これがプロセスがアクセスできる総メモリとなります。仮想メモリ内で使用されている実際のメモリはResident Setと呼ばれています。このスペースは以下の様に内部メモリ構造によって管理されています。

これはGoによって利用される内部オブジェクトに基づいた簡易図であり、実際には、Goはこの素晴らしい記事で述べられている通りメモリをページに分割してグループ化しています。

これは前回までのJVMとV8の章で見たメモリ構造とは非常に異なります。見ていただいた通り、ここには世代間のメモリがありません。主な理由はTCMalloc、つまりGo独自のメモリアロケータがモデルとなっているからです。

それでは、それぞれの構成要素を見ていきましょう。

ページヒープ(mheap)

Goが動的データ(コンパイル時にサイズが計算できないデータ)を保持する場所です。これは最大のメモリブロックであり、ガベージコレクション(GC)が行われます。

このresident setは8KB毎のページに分割され、一つのグローバルmheapオブジェクトとして管理されます。

巨大オブジェクト(サイズが32kbより大きいオブジェクト)はmheapから直接割り当てられます。このような巨大なリクエストは、セントラルロックを犠牲にして行われるため、ある時点で1つのPのリクエストにしか対応できません。

mheapは以下のように異なる構造に分類されたページを管理します。

  • mspan: mspanはmheap内でメモリのページを管理する最も基本的な構造です。これは開始ページのアドレス、スパンサイズクラス、スパン内のページ数を保持する双方向連結リストです。TCMallocと同様にGoも以下の図のように、メモリページを8byteから32kilobytesまでの67種類のクラスのブロックに分割しています。

    各スパンは、ポインタ(scanクラス)を持つオブジェクト、もう一つはポインタを持たない(noscanクラス)オブジェクトの2回存在します。これはGCの際に、残っているオブジェクトを探すためにnoscanスパンを通過する必要がないため役に立ちます。

  • mcentral: mcentralは、同じサイズのクラスのスパンをまとめてグループ化します。各mcentralは2つのmspanListを含みます:

    • empty: 空きオブジェクトを持たないスパンや、mcacheにキャッシュされているスパンの双方向連結リスト。ここでスパンが解放されると、nonemptyリストに移動します。
    • non-empty: 空きオブジェクトを持つスパンの双方向連結リスト。mcentralから新たしいスパンが要求されると、nonemptyリストからemtpyリストに移動します。

mcentralに空きスパンが存在しない時、mheapから新しいページを要求します。

  • arena: ヒープメモリは割り当てられた仮想メモリの範囲内で必要にに応じて増減します。より多くのメモリが必要になると、mheapはarenaと呼ばれる64MB(64ビットアーキテクチャの場合)のチャンクとして仮想メモリから引き出します。ここではページがスパンにマッピングされる。
  • mcache: これは非常に興味深い構成要素です。mcacheはP(論理プロセッサ)に提供されるメモリのキャッシュで、小さなオブジェクト(オブジェクトサイズ32Kb以下)を格納するためのものです。これはスレッドスタックに似ていますが、ヒープの一部であり動的データのために使用されます。mcacheには全てのクラスサイズに対応したscanタイプとnoscanタイプのmspanが含まれています。Pは同時に一つのGしか保持できないため、ゴルーチンはロックされることなくmcacheからメモリを確保することができます。したがって非常に効率的です。mcacheは必要に応じてmcentralから新しいスパンを要求します。

スタック

スタックメモリ領域でゴルーチン(G)毎に1つのスタックが存在します。この領域には関数フレーム、静的構造体、プリミティブ値、そして動的な構造体を保持するためのポインタを含む静的データが保持されています。これはPに割り当てられるmcacheとは異なります。

Goのメモリ使用方法について(スタックvsヒープ)

さてメモリがどのように構成されているか理解したところで、プログラムが実行される際にGoがどのようにスタックとヒープを使用するか見てみましょう。

以下のGoプログラムを使用しますが、スタックとヒープメモリの使用方法に主眼を置いているため、不必要な仲介変数などの問題を考慮していないため、最適化されていないコードであるという点だけご了承ください。

package main

import "fmt"

type Employee struct {
  name   string
  salary int
  sales  int
  bonus  int
}

const BONUS_PERCENTAGE = 10

func getBonusPercentage(salary int) int {
  percentage := (salary * BONUS_PERCENTAGE) / 100
  return percentage
}

func findEmployeeBonus(salary, noOfSales int) int {
  bonusPercentage := getBonusPercentage(salary)
  bonus := bonusPercentage * noOfSales
  return bonus
}

func main() {
  var john = Employee{"John", 5000, 5, 0}
  john.bonus = findEmployeeBonus(john.salary, john.sales)
  fmt.Println(john.bonus)
}

他のガベージコレクションを持つ言語と比較した際のGoの大きな違いは、プログラムスタック上に直接多くのオブジェクトが割り当てられることです。Goコンパイラは、エスケープ解析と呼ばれるプロセスを用いて、コンパイル時にライフタイムが分かっているオブジェクトを見つけ、ガベージコレクションされたヒープメモリではなく、スタック上に割り当てています。コンパイルの際にGoはエスケープ解析を行い、何がスタック(静的データ)に入り、何がヒープ(動的データ)に入る必要があるかを判断します。 go build-gcflags '-m' フラグと共に実行することでコンパイル時にその詳細を見ることができます。先のコードでは、以下の様な結果が出力されます。

❯ go build -gcflags '-m' gc.go
# command-line-arguments
temp/gc.go:14:6: can inline getBonusPercentage
temp/gc.go:19:6: can inline findEmployeeBonus
temp/gc.go:20:39: inlining call to getBonusPercentage
temp/gc.go:27:32: inlining call to findEmployeeBonus
temp/gc.go:27:32: inlining call to getBonusPercentage
temp/gc.go:28:13: inlining call to fmt.Println
temp/gc.go:28:18: john.bonus escapes to heap
temp/gc.go:28:13: io.Writer(os.Stdout) escapes to heap
temp/gc.go:28:13: main []interface {} literal does not escape
<autogenerated>:1: os.(*File).close .this does not escape

これを可視化してみましょう。スライドをクリックし、矢印キーで前後に移動しながら、上記のプログラムがどのように実行されるのか、スタックとヒープメモリがどのように使用されるかを見てみましょう:

注:スライドの端が切れているように見える場合は、スライドのタイトルをクリックするか、ここをクリックするとSpeakerDeckで直接開くことができます。

ご覧の通り:

  • メイン関数はスタック上の"メインフレーム"に保持される
  • 引数や返り値を含む全ての静的変数はスタック上の関数フレームブロックに保持される
  • 型に限らず全ての静的変数は直接スタックに保持される。これはグローバルスコープにも同様に適応される
  • 全ての動的型はヒープ上に作成され、スタックポインタを使用しスタックから参照される。32KB未満のオブジェクトは、Pのmcacheに配置される。これはグローバルスコープにも同様に適応される
  • 静的データを持つ構造体は、動的な値が追加されるまでスタック上に保持され、その時点で構造体はヒープに移動する
  • 実行中の関数から呼び出される関数は、スタックの先頭に格納される
  • 関数がフレームを返す時にスタックから取り出される
  • メインプロセスが完了すると、ヒープ上にあるオブジェクトはスタックから参照されるポインタがなくなり、参照されないオブジェクトになる

ご覧の通りスタックは自動的に管理され、Go自体ではなくオペレーティングシステムによって管理されています。したがってスタックについてはあまり気にする必要はありません。一方、ヒープはOSによって自動的に管理されていません。ヒープは最大のメモリ空間であり動的なデータを保持しているため、指数関数的に成長し、時間の経過と共にプログラムがメモリ不足に陥る可能性があります。また、時間の経過とともに断片化され、アプリケーションの動作が遅くなります。そこで登場するのがガベージコレクションです。

Goのメモリ管理

Goのメモリ管理はメモリが必要になった際に自動的に割り当てられ、不要になった際にはガベージコレクションが行われます。これは標準ライブラリによって行われます。C/C++とは異なり、開発者はそれに対処する必要がなく、Goが行うメモリ管理は最適化されており効率的です。

メモリアロケーション

ガベージコレクションを採用している多くのプログラミング言語では、収集を効率化するために世代毎のメモリ構造を採用し、断片化を減らすために圧縮をしています。先ほど見た通り、Goはこの点において異なるアプローチを取っており、メモリを全く異なる形で構造化しています。Goは小さなオブジェクトの割り当てを高速にするためにスレッドローカルキャッシュを採用しており、GCを高速化するためにscan/noscanスパンを維持します。この構造とプロセスにより、断片化を大幅に回避し、GC時の圧縮を不要にしてます。この割り当てがどのように行われるか見てみましょう。

Goはオブジェクトのサイズにより割り当てプロセスを決定し、3つのカテゴリに分けます。

Tiny(size < 16B): 16B以下のオブジェクトはmcacheの小さなアロケータを使って割り当てられます。これは効率的で、1つの16Bブロックに複数の小さな割り当てが行われます。

Small(size 16B ~ 32KB): 16Bから32KBの大きさのオブジェクトは、Gが動作しているPのmcacheに対応する大きさのクラス(mspan)に割り当てられます。

TinyおよびSmallの両方のアロケーションでは、mspanのリストが空の場合、アロケータはmheapからmspanに使用するページを取得します。mheapが空もしくは実行するための十分なページが存在しない場合は、OSから新規にページグループ(最低1MB)を割り当てます。

Large(size > 32KB): 32KBより大きいオブジェクトは、mheapの対応するサイズクラスに直接割り当てられます。mpeapが空もしくは実行に十分な大きさのページが存在しない場合、OSから新しいページグループ(最低1MB)を割り当てます。

注: こちらのスライドから上記のGIF画像を見つけるとができます

ガベージコレクション

ここまででGoがどのようにメモリを割り当てるかについて知ることができました。さてアプリケーションのパフォーマンスにおいて非常に重要なヒープメモリを自動的に収集する方法を見てみましょう。プログラムがヒープ上に割り当て可能なメモリ以上確保しようとすると、メモリ不足エラーが発生します。同様に適切に管理されていないヒープもメモリリークを起こします。

Goはガベージコレクションによってヒープメモリを管理しています。簡単に言うと、参照されていないオブジェクトが使用していたメモリを解放して、新しいオブジェクトを作成するためのスペースを確保します。

バージョン1.12では、Golangは非世代型の同時3色マークとスイープコレクタを使用しています。回収プロセスは大まかに見ると以下のようになってますが、バージョンによって変わるので詳細は割愛します。しかし、興味のある方はこの素晴らしいブログシリーズをお勧めします。

このプロセスは、ヒープの割り当てが一定の割合(GC率)で行われた時に開始され、コレクタは異なるフェーズの作業を行います:

  • Mark Setup(Stop the world): GCが始まると、コレクタは書き込みバリアをオンにし、次の並行フェーズでも整合性が保たれるようにします。このステップでは、実行中の全てのゴルーチンがこれを可能にするために一時的に停止され、その後続行されるため、ごくわずかな一時停止が必要になります。
  • Marking(Concurrent): 書き込みバリアがオンになると、実際のマーキング処理がアプリケーションと並列して開始され、使用可能なCPUの25%が使用されます。マーキングが完了するまで、対応したPは占有されています。これは専用のゴルーチンを使って行われます。ここでGCは稼働中(アクティブなゴルーチンのスタックから参照されている)ヒープ上の値をマークします。収集に時間がかかる場合は、アプリケーションのアクティブなゴルーチンを使用してマーキングプロセスを支援します。これをマーク支援と呼びます。
  • Mark Termination(Stop the world): マーキングが完了すると、すべてのアクティブなゴルーチンは一時停止し、書き込みバリアがオフになり、クリーンアップ作業が開始する。また、GCはここで次のGCのゴールを計算します。これが完了すると、確保されたPは解放されアプリケーションに戻されます。
  • Sweeping(Concurrent): 収集が完了し、割り当てが試みられると、スウィーピングプロセスは、未活動とマークされたヒープからメモリを取り戻し始めます。メモリの吐き出し量は、割り当てられている量と同期しています。

これらの活動を1つのゴルーチンで見てみましょう。分かりやすくするために、オブジェクトの数は少なくしてます。スライドをクリックし、矢印キーで前後に移動するとその過程を見ることができます。

注:スライドの端が切れているように見える場合は、スライドのタイトルをクリックするか、ここをクリックするとSpeakerDeckで直接開くことができます。

  1. ここでは1つのゴルーチンを対象としているが、実際のプロセスでは全てのアクティブなゴルーチンに対してこの処理が行われます。最初に書き込みバリアがオンになります。
  2. マーキング処理では、GCルートを選択して黒く着色し、そこから深さ優先のツリー状にポインタを走査して、遭遇した各オブジェクトをグレーにマーキングします。
  3. noscanスパン内のオブジェクトに到達するか、オブジェクトのポイントがなくなると、ルートの処理を終了し、次のGCルートオブジェクトを選びます。
  4. 全てのGCルートがスキャンされると、グレーのオブジェクトを選び、同様の方法でそのポインタの走査を続けます。
  5. 書き込みバリアがオンである時に、オブジェクトへのポインタの変更があれば、GCが再スキャンをするようにオブジェクトをグレーにします。
  6. グレーのオブジェクトがなくなると、マーキング処理が完了し、書き込みバリアがオフになります。
  7. スウィーピングは割り当て開始時に実行されます。

これはいくつかのstop-the-world処理ですが、一般的に非常に高速であるため殆どの場合は無視できます。オブジェクトの色付けはスパン上でgcmarkBits属性で行われます。

まとめ

この記事によりGoのメモリ構造とメモリ管理の全体像を理解することができるでしょう。これは全てを網羅したものではなく、もっと高度なコンセプトがあり、実装の詳細はバージョンごとに変わり続けています。しかしながら多くのGoを利用している開発者にとっては、この程度の情報で十分であり、これらを念頭に置いて、パフォーマンスの高いアプリケーションにし、より良いコードを書くのに役立つことを願っています。また、これらを念頭に置くことで、他の方法で遭遇するかもしれない次のメモリリーク問題を回避することができるでしょう。

楽しく学んで頂けたでしょうか。次のシリーズ記事にもご期待ください。

参考文献

Discussion