テックタッチアドベントカレンダー9日目を担当する roki です。最近マネージャとして動き始めました。なかなかに難しい仕事ですが学びのある日々を過ごしています。
今回は、一転技術的な話で Go の GC(Garbage Collector)におけるパラメータ GOGC と GOMEMLIMIT(メモリ制限) について学んだことをまとめます。参考にした文書は Go 公式の GC に対するガイドで、Go 1.19 に対するものです。
GCにまつわるコストを理解するために必要な概念の整理と、GOGC, GOMEMLIMIT がどのような役割を果たすのかを見ていきます。GOGC はメモリの使用量と CPU コストのトレードオフを制御し、GOMEMLIMIT はメモリ使用量に制限を与えます。特に、GOMEMLIMIT は GOGC で調整しにくかったメモリ制限を実現しますが、メモリ使用量の下限を強制的に変えるものでなく設定を誤ると恒常的な GC によるスラッシングが発生し、プログラムの実行に支障が出てしまうため注意が必要です。
Go の GC はトレースガベージコレクション
Go プログラムにおいて割り当てたメモリを明示的に開放しなくとも GC によって適宜再利用できる状態になることは周知の事実として、この Go の GC はトレース型ガベージコレクションと呼ばれています。これは、「ルートからオブジェクトグラフをスキャニングして、その際にマーク処理を行うことでライブオブジェクトを特定しスイープ処理を行うことで不要となったオブジェクトを開放して再び利用できるようにする」処理を行います。
ここで出てくる用語の解説をまとめると、以下のようになります:
用語 | 説明 |
---|---|
オブジェクト | 1つまたは複数のGo値を含む、動的に割り当てられたメモリの一部 |
ポインタ | オブジェクト内の任意の値を参照するメモリ アドレス。文字列、スライス、チャンネル、マップ、およびインターフェイスの値を含む |
ライブ | 使用中(ライブオブジェクト:使用中のオブジェクト) |
オブジェクトグラフ | オブジェクトと他のオブジェクトへのポインタをたどること作られるグラフ |
ルート | プログラムによって確実に使用されているオブジェクトを識別するポインタ。ローカル変数だったりグローバル変数だったりする |
スキャニング | オブジェクトグラフをたどる操作 |
マーク | ライブメモリに印をつける処理 |
スイープ | マークされていないメモリを割り当て可能な状態にする処理 |
以上を踏まえてトレーシングガベージコレクションを説明すると、
プログラムによって確実に使用されているオブジェクトを識別するポインタからオブジェクトと他のオブジェクトへのポインタをたどることによって作られるグラフをたどることにより、使用中のメモリを特定する操作をする。未使用なメモリを特定するために、使用中のメモリに印をつける処理と印のついていないメモリを割り当て可能な状態にする処理
となります。
GC サイクルとコスト
GC の1サイクルは、大まかに分けてマークフェーズとスイープフェーズからなります。メモリがすべてトレースされるまでメモリの解放ができない(メモリをすべてたどるまで、あるオブジェクトをライブにしているポインタがスキャンされているかどうかわからない)ので、このスイープ操作とマーク操作は同時には行われません。
そして、GC にかかるコストは「GC が発生する時間を遅らせる(GC が発生する間隔を広げる)と CPU コストは下がる。逆に、 GC が発生する時間を早める(GC が発生する間隔を狭める)と CPU コストが上がる」という性質を持ちます。この性質を直感的にとらえるために、3つの単純な公理からなるGCコストのモデル(シンプルながらも支配的なコストを正確に分類できるモデル!)を考えます:
- GCに関与するリソースはCPUとメモリのみ
- GCのメモリコストは、ライブヒープメモリ、マークフェーズの前に割り当てられた新しいヒープメモリ、他に比べ小さなメタデータに対する空間で構成される
- GCのCPUコストは、サイクルごとの固定コストと、ライブヒープのサイズに比例してスケールする限界コスト(marginal cost)*1としてモデル化される *2
ライブヒープメモリ、新しいヒープメモリの意味は以下の通りです:
用語 | 説明 |
---|---|
ライブヒープメモリ | 前回のGCサイクルによってライブであると判断されたメモリ |
新しいヒープメモリ | 現在のサイクルで割り当てられたメモリで、GCサイクル終了までライブかどうかわからないもの |
スイープ処理はヒープ全体のサイズに比例した操作になるが現在の実装ではマーク処理・スキャニング処理に比べ非常に高速なのでここではスイープ処理のコストは無視できます。また、「メタデータに対する空間」については記述がなく、比較的に小さいということで今後の議論にも出てこず、ここでは気にしないでおきます。
ここで、コストの大きさについて検討するために定常状態というものを考えます。定常状態とは以下のような状態を指します:
- アプリケーションが新しいメモリを確保する速度(1秒あたりのバイト数)は一定
- アプリケーションのオブジェクトグラフは毎回ほぼ同じに見える(オブジェクトは同じような大きさで、ポインタの数はほぼ一定、グラフの最大深度もほぼ一定。限界コストが一定になっている状態)
定常状態は一定の作業負荷のもとでのアプリケーションに見られる代表的な挙動で、実際のメモリの状態はこの定常状態のつなぎ合わせとその間の過渡期に組み合わせのようになります。なので、以下定常状態を考察していきます。
定常状態において、GC が発生する間隔を広げると、新しいメモリを確保する速度は一定なので GC が発生する間隔を広げる前に比べて新しいメモリヒープサイズが大きくなります。しかし、GC の結果新たに発生するライブヒープサイズは変わりません(GC 発生時のヒープ全体のサイズは大きくなりますが、定常状態なのでそのヒープ全体の中のライブメモリは常に一定です)。3つ目の公理から各サイクルはライブヒープに比例したコストがかかるため、GC が発生する間隔を広げても同じ CPU コストを発生させます。
時間間隔を広げてもコストは同じなので、同じ時間間隔内で比較すると GC が発生する間隔を広げると広げる前に比べて GC コストは下がることになります。よって、GC の発生間隔(頻度)を制御することで CPU コストとメモリ使用量のトレードオフを行うことができます。
GOGC
GOGC はまさに GC の CPU とメモリのトレードオフを決めるパラメータで、ターゲットヒープサイズ(次のサイクルでの総ヒープサイズの目標値)を決めるために使用されます。ターゲットヒープサイズとの関係は、
ターゲットヒープメモリ = ライブヒープ + (ライブヒープ + GC ルート) * GOGC / 100
で定義されます。GC はサイクルの結果残ったライブヒープに対して次のターゲットヒープメモリサイズを決めて、さらにヒープサイズの合計がターゲットヒープサイズを超える前に GC サイクルを終えメモリを開放しようとします。この式に従えば、あるライブヒープサイズに対して GOGC を大きくすればターゲットヒープサイズ(=次サイクルでのおよそのヒープサイズ全体)は大きくなり GC の頻度が下がるので、GCモデルの話からCPUコストは下がります。逆に GOGC 小さくすればヒープサイズは小さくなり GC の頻度は上がり CPU コストは上がります。
※GC ルートを考慮に入れるのは 1.18 以降のみです。goroutineのスタックはとても小さくライブヒープが支配的であったものの、1.18以前は goroutine が何十万もある場合は不適切な判断を下していたとのことです。また、ターゲットヒープサイズは単なるターゲットであり、GCサイクルがそのターゲットで正しく終了しないことはありえます(ex. 巨大なメモリを割り当てたとき)。
GOGC は、環境変数 GOGC か、もしくは runtime/debug の SetGCPercentage() で設定できて、off (SetGCPercentage(-1)) によって GC を無効化することもできます。
GCの様子を可視化する例が https://tip.golang.org/doc/gc-guide#GOGC 記述されていて、GOGCを変化させたときのヒープサイズの動きをシミュレーションできるのでイメージが湧きやすいです。
GOMEMLIMIT(メモリ制限)
CPUとメモリのバランスを取る GOGC でしたが、これはメモリが有限であること考慮していません。つまりライブヒープサイズがスパイクすると定義からターゲットヒープサイズもスパイクしてしまいます。
なので、GOGC だけで使用メモリの上限を決める場合は、ピークライブサイズを考慮して GOGC を決める必要があります。ただ、例えばアプリケーションコンテナのようにメモリの上限が決まっているときのライブヒープのピーク予測は簡単ではなく、メモリスパイクが発生するとターゲットヒープサイズがコンテナのメモリ上限値を超える可能性があります。
これを解決するために Go 1.19 ではランタイムが利用するメモリの上限を決めるための GOMEMLIMIT 環境変数 (runtime/debugのSetMemoryLimit())がサポートされました。runtime.MemStats のフィールドで表現すると Sys - HeapReleased で定義される値、すなわち 「OSから取得したメモリの総バイト数」-「OSに戻された物理メモリのバイト数」が指定した制限値に沿うように GC の頻度を調整したり、より積極的にメモリを OS に戻したりして、ランタイムはこのメモリ制限を守ろうとします。
GOGC とメモリ制限値は実行時の状態を理解して設定する必要があります。このメモリ制限が GOGC で決まるピークメモリより小さい場合、ピークメモリを制限値内に収めるために GC の実行頻度が高くなります。すなわち、CPUコストが高くなってしまいます。GOGC とメモリ制限を適切な値に設定すれば、ピーク時のメモリ使用量は CPU コストを払うことでメモリ制限の値に抑えられ、他の実行は部分については GOGC によって設定されたターゲットヒープサイズに従うように設定できます。
また、GOGC が off に設定されている場合でもメモリ制限は尊重されるため、「GOGC=off・メモリ制限有り」という設定は、メモリ制限を維持するために必要な最小限の GC を行うための設定となります。
上記の通りメモリ制限はうまく使えばとても有効ですが、GOGC の設定によらずとも Go プログラムがメモリ制限を超えてヒープを使おうとすると恒常的に GC 状態(スラッシング)になりアプリケーションの実行に支障が出でてしまいます。なので、メモリ制限はあくまで soft リミットとなっていて、GC はある時間内でおよそ50%のCPUまでしか使えないようになっています。この CPU 時間制限によってメモリ使用量を制限値内に抑えるための GC の作業が遅れる一方で、Go プログラムはメモリ制限を越えても新しいヒープメモリを割り当て続けることができるようになっています。
このメモリ制限を適切に扱う例が何点か解説されているので、ここで紹介します:
- Go プログラムの実行環境が完全に自分のコントロール下にあり、Go プログラムが何らかのリソース(コンテナのメモリ制限のような、何らかのメモリ予約)を利用できる唯一のプログラムである場合、メモリ制限を利用する(例:利用可能なメモリ量が決まっているコンテナへのWebサービスのデプロイメント。ランタイムが認識していないメモリ利用を考慮して更に5〜10%の余裕をもたせると良いです)
- 状況の変化に応じて、リアルタイムにメモリ制限を調整する(例:C言語ライブラリが一時的に多くのメモリを使用する必要がある場合)。
- Go プログラムが限られたメモリの一部を他のプログラムと共有する可能性があり、それらのプログラムは普段 Go プログラムから分離されている場合は、メモリ制限を使いつつ GOGC を off にはしないようにします。望ましくない一時的な動作を抑制するにはメモリ制限が役立つので、メモリ制限を維持し、GOGC を小さめの値に設定します。
- 特に、プログラムのメモリ使用量が入力に比例する場合や、入力を制御できない実行環境にデプロイする場合は、メモリ制限を使用しないでください。
- プログラムがすでに環境のメモリ制限に近づいているときに、メモリ不足の状態を回避するためにメモリ制限を設定するのはやめましょう。メモリ不足時のメモリ制限は、メモリの不足を速度低下のリスクに置き換えるだけなので、環境のメモリ制限を増やすか GOGC を小さくしたほうが効果的です。
まとめ
Go の GC を制御するパラメータは2つあって、
- GOGC はメモリ使用量とCPUコストのトレードオフを制御します
- GOMEMLIMIT はメモリに対しての soft リミットとなる。だた、GOGC によるターゲットヒープサイズ以下を設定したりメモリ制限を超えてヒープメモリを使おうとしてしまうと GC によるスラッシングが発生してしまうので、実際のメモリ利用状況を踏まえて設定する必要があります
その他、 A Guide to the Go Garbage Collector にはレイテンシーの話(難しい)、メモリのフットプリントを理解するためにはVSSよりRSSを見たほうが良い話、そしてGCをチューニングする必要があるか判断するためのポイントやヒープアロケーションを避けるためにできることについても解説があります。Go のメモリに関するチューニングを行う際には必須の読み物だと思います。気になる方はぜひご一読してみてください。
*1: https://ja.wikipedia.org/wiki/限界費用、今回はライブヒープを増やしたときにかかるCPUコストのことか
*2:自分の理解:ライブヒープとならないメモリはマーク時にたどらないため、GCのコストはライブヒープのサイズに比例してスケールするとしても問題なく、スイープ処理についても後述の通り無視して問題ない