Goのガベージコレクターガイド
はじめに
このガイドは、Goのガベージコレクターに関する洞察を提供することで、Goの上級ユーザーがアプリケーションのコストをより良く理解するのに役立つことを目的としています。また、Goユーザーがこれらの洞察を利用してアプリケーションのリソース利用を改善する方法についてのガイダンスも提供します。ガベージコレクションに関する事前知識は想定していませんが、Goプログラミング言語に精通していることは前提としています。
Go言語はGoの値のストレージを管理する責任を負います。ほとんどの場合、Go開発者はこれらの値がどこに、なぜ、もしあるとしても、格納されるかについて気にする必要はありません。しかし実際には、これらの値はしばしばコンピュータの物理メモリに格納される必要があり、物理メモリは有限なリソースです。有限であるため、Goプログラムの実行中にメモリ不足を避けるためには、メモリを慎重に管理し、再利用する必要があります。必要なときにメモリを割り当て、再利用するのがGo実装の仕事です。
メモリを自動的に再利用するもう一つの用語はガベージコレクションです。大まかに言えば、ガベージコレクター(略してGC)は、メモリのどの部分がもはや不要であるかを特定することで、アプリケーションに代わってメモリを再利用するシステムです。Goの標準ツールチェーンは、すべてのアプリケーションに同梱されるランタイムライブラリを提供しており、このランタイムライブラリにはガベージコレクターが含まれています。
このガイドで説明されているガベージコレクターの存在は、Go仕様によって保証されているわけではなく、Go値の基礎となるストレージが言語自体によって管理されることのみが保証されていることに注意してください。この省略は意図的であり、根本的に異なるメモリ管理技術の使用を可能にします。
したがって、このガイドはGoプログラミング言語の特定の実装に関するものであり、他の実装には適用されない場合があります。具体的には、この以下のガイドは標準ツールチェーン(gc Goコンパイラとツール)に適用されます。GccgoとGollvmはどちらも非常によく似たGC実装を使用しているため、多くの同じ概念が適用されますが、詳細は異なる場合があります。
さらに、これはライブドキュメントであり、Goの最新リリースを最もよく反映するように時間の経過とともに変更されます。このドキュメントは現在、Go 1.19時点のガベージコレクターについて説明しています。
Goの値がどこに存在するのか
GCに深く潜り込む前に、まずGCによって管理される必要のないメモリについて議論しましょう。
例えば、ローカル変数に格納される非ポインタGo値は、Go GCによってまったく管理されない可能性が高く、Goは代わりに、それが作成された字句スコープに紐付けられたメモリが割り当てられるように手配します。一般的に、これはGCに依存するよりも効率的です。なぜなら、Goコンパイラはそのメモリがいつ解放されるかを事前に判断し、クリーンアップするマシン命令を出力できるからです。通常、このようにGo値にメモリを割り当てることを「スタック割り当て」と呼びます。なぜなら、その空間はゴルーチンスタックに格納されるからです。
Goコンパイラがその寿命を判断できないため、このようにメモリを割り当てることができないGo値は、ヒープにエスケープすると言われます。「ヒープ」は、Go値がどこかに配置される必要がある場合の、メモリ割り当ての包括的なものと考えることができます。ヒープ上でメモリを割り当てる行為は、通常「動的メモリ割り当て」と呼ばれます。なぜなら、コンパイラとランタイムの両方が、このメモリがどのように使用され、いつクリーンアップできるかについてほとんど仮定できないからです。そこでGCが登場します。GCは、動的なメモリ割り当てを具体的に識別してクリーンアップするシステムです。
Goの値がヒープにエスケープする必要がある理由はたくさんあります。一つの理由は、そのサイズが動的に決定される可能性があることです。例えば、初期サイズが定数ではなく変数によって決定されるスライスのバッキング配列を考えてみてください。ヒープへのエスケープも推移的である必要があることに注意してください。Go値への参照が、すでにエスケープすると決定されている別のGo値に書き込まれた場合、その値もエスケープする必要があります。
Goの値がエスケープするかどうかは、それが使用されるコンテキストとGoコンパイラのエスケープ解析アルゴリズムの関数です。値がいつエスケープするかを正確に列挙しようとすることは不安定で困難でしょう。アルゴリズム自体はかなり洗練されており、Goのリリース間で変化します。どの値がエスケープし、どの値がエスケープしないかを識別する方法の詳細については、ヒープ割り当ての排除のセクションを参照してください。
トレーシングガベージコレクション
ガベージコレクションは、メモリを自動的に再利用する様々な方法を指すことがあります。例えば、参照カウントなどです。この文書の文脈では、ガベージコレクションはトレーシングガベージコレクションを指します。これは、ポインタを推移的に辿ることで、使用中の、いわゆるライブオブジェクトを特定します。
これらの用語をより厳密に定義しましょう。
-
オブジェクト—オブジェクトは、1つ以上のGo値を含む動的に割り当てられたメモリの断片です。
-
ポインタ—オブジェクト内の任意の値を参照するメモリアドレス。これには、
*T形式のGo値が自然に含まれますが、組み込みのGo値の一部も含まれます。文字列、スライス、チャネル、マップ、およびインターフェース値はすべて、GCがトレースしなければならないメモリアドレスを含んでいます。
オブジェクトと他のオブジェクトへのポインタは、まとめてオブジェクトグラフを形成します。ライブメモリを特定するために、GCはプログラムのルート、つまりプログラムによって確実に使用されているオブジェクトを識別するポインタからオブジェクトグラフを辿ります。ルートの2つの例は、ローカル変数とグローバル変数です。オブジェクトグラフを辿るプロセスはスキャンと呼ばれます。Goのドキュメントで目にする可能性のあるもう1つのフレーズは、オブジェクトが到達可能であるかどうかであり、これは単に、スキャンプロセスによってオブジェクトが発見できることを意味します。また、1つの例外を除いて、メモリが到達不能になると、それは到達不能のままになることにも注意してください。
この基本的なアルゴリズムは、すべてのトレーシングGCに共通しています。トレーシングGCが異なるのは、メモリがライブであると発見した後に何をするかです。GoのGCはマーク&スイープ技術を使用します。これは、進行状況を追跡するために、GCが遭遇した値をライブとしてマークすることも意味します。トレースが完了すると、GCはヒープ内のすべてのメモリを走査し、マークされていないすべてのメモリを割り当てに使用可能にします。このプロセスはスイープと呼ばれます。
あなたが familiarかもしれない代替技術の1つは、実際にオブジェクトをメモリの新しい部分に移動し、後でアプリケーションのすべてのポインタを更新するために使用される転送ポインタを残すことです。このようにオブジェクトを移動するGCを移動型GCと呼びます。Goは非移動型GCです。
GCサイクル
Go GCはマーク&スイープGCであるため、大まかにマークフェーズとスイープフェーズの2つのフェーズで動作します。この声明はトートロジー的(同義語反復)に見えるかもしれませんが、重要な洞察を含んでいます。すべてのメモリがトレースされるまでメモリを解放して割り当て可能にすることはできません。なぜなら、オブジェクトを生きている状態に保つ未スキャンなポインタが存在する可能性があるからです。結果として、スイープの行為はマークの行為から完全に分離されなければなりません。さらに、GC関連の作業がない場合、GCはまったくアクティブでないこともあります。GCは、GCサイクルとして知られるスイープ、オフ、マーキングの3つのフェーズを連続的に繰り返します。このドキュメントの目的のために、GCサイクルはスイープから始まり、オフになり、次にマーキングに移ると考えてください。
次のいくつかのセクションでは、GCのコストに対する直感を構築し、ユーザーが自身の利益のためにGCパラメータを調整するのを支援することに焦点を当てます。
コストの理解
GCは、より複雑なシステムの上に構築された本質的に複雑なソフトウェアです。GCを理解し、その動作を調整しようとすると、詳細に陥りがちです。このセクションは、Go GCのコストとそのチューニングパラメータについて推論するためのフレームワークを提供することを目的としています。
まず、3つの単純な公理に基づくGCコストのこのモデルを考えてみましょう。
-
GCは物理メモリとCPU時間の2つのリソースのみを対象とする。
-
GCのメモリコストは、ライブヒープメモリ、マークフェーズ前に割り当てられた新しいヒープメモリ、および前のコストに比例するとしても比較的小さいメタデータのためのスペースから構成されます。
サイクルNのGCメモリコスト = サイクルN-1からのライブヒープ + 新しいヒープ
ライブヒープメモリとは、前回のGCサイクルによってライブと判断されたメモリであり、新しいヒープメモリとは、現在のサイクルで割り当てられたメモリであり、サイクルの終わりまでにライブであるかどうかわからないものです。特定の時点でのライブメモリの量は、プログラムの特性であり、GCが直接制御できるものではありません。
-
GCのCPUコストは、サイクルあたりの固定コストと、ライブヒープのサイズに比例して変動する限界コストとしてモデル化されます。
サイクルNのGC CPU時間 = サイクルあたりの固定CPU時間コスト + バイトあたりの平均CPU時間コスト * サイクルNで見つかったライブヒープメモリ
サイクルあたりの固定CPU時間コストには、次のGCサイクル用のデータ構造の初期化など、各サイクルで定数回発生することが含まれます。このコストは通常小さく、完全性のために含まれています。
GCのCPUコストのほとんどは、マーキングとスキャンであり、これは限界コストによって捕捉されます。マーキングとスキャンの平均コストは、GCの実装だけでなく、プログラムの動作にも依存します。例えば、ポインタが多いほどGCの作業量が増えます。なぜなら、少なくともGCはプログラム内のすべてのポインタを訪れる必要があるからです。リンクリストやツリーのような構造は、GCが並行して走査するのがより困難であり、バイトあたりの平均コストを増加させます。
このモデルは、スイープコストを無視しています。スイープコストは、デッドなメモリ(割り当てに使用可能にする必要がある)を含む、総ヒープメモリに比例します。Goの現在のGC実装では、スイープはマーキングとスキャンよりもはるかに高速であるため、そのコストは比較して無視できます。
このモデルはシンプルですが効果的です。GCの主要なコストを正確に分類しています。また、ガベージコレクターの総CPUコストは、特定の時間枠におけるGCサイクルの総数に依存することも教えてくれます。最後に、このモデルにはGCの根本的な時間/空間のトレードオフが組み込まれています。
その理由を見るために、制約はありますが有用なシナリオである定常状態を探ってみましょう。GCの観点から見たアプリケーションの定常状態は、以下の特性によって定義されます。
-
アプリケーションが新しいメモリを割り当てる速度(バイト/秒)は一定です。
これは、GCの観点から、アプリケーションのワークロードが時間の経過とともにほぼ同じように見えることを意味します。たとえば、ウェブサービスの場合、これは一定のリクエストレートで、平均して同じ種類のリクエストが行われ、各リクエストの平均寿命がほぼ一定であることを意味します。
-
GCの限界コストは一定です。
これは、オブジェクトのサイズ分布、ポインターの数、データ構造の平均深度など、オブジェクトグラフの統計がサイクルごとに同じままであることを意味します。
例を考えてみましょう。あるアプリケーションが10 MiB/秒を割り当て、GCが100 MiB/cpu-秒の速度でスキャンでき(これは架空の値です)、固定GCコストがゼロであると仮定します。定常状態ではライブヒープのサイズに関する仮定はありませんが、簡潔にするために、このアプリケーションのライブヒープは常に10 MiBであるとしましょう。(注:一定のライブヒープは、新しく割り当てられたすべてのメモリがデッドであることを意味するものではありません。GCが実行された後、古いヒープメモリと新しいヒープメモリのある組み合わせがライブであることを意味します。)各GCサイクルが正確に1 cpu-秒ごとに発生する場合、定常状態の例のアプリケーションは、各GCサイクルで合計20 MiBのヒープサイズを持つことになります。そして、各GCサイクルで、GCはその作業を行うために0.1 cpu-秒を必要とし、その結果10%のオーバーヘッドが発生します。
次に、各GCサイクルがより少なく、2 cpu-秒ごとに1回発生するとしましょう。すると、例のアプリケーションは定常状態では、各GCサイクルで合計30 MiBのヒープサイズを持つことになります。しかし、各GCサイクルで、GCは作業を行うために依然としてわずか0.1 cpu-秒しか必要としません。したがって、これはGCオーバーヘッドが10%から5%に低下したことを意味し、その代償としてメモリ使用量が50%増加します。
このオーバーヘッドの変化は、先に述べた根本的な時間/空間のトレードオフです。そして、GC頻度がこのトレードオフの中心にあります。GCをより頻繁に実行すれば、使用するメモリは少なくなり、その逆もまた然りです。しかし、GCは実際にどのくらいの頻度で実行されるのでしょうか?Goでは、GCを開始するタイミングを決定することが、ユーザーが制御できる主要なパラメータです。
GOGC
大まかに言えば、GOGCはGCのCPUとメモリのトレードオフを決定します。
これは、各GCサイクル後の目標ヒープサイズ、つまり次のサイクルにおける合計ヒープサイズの目標値を決定することで機能します。GCの目標は、合計ヒープサイズが目標ヒープサイズを超える前に収集サイクルを完了することです。合計ヒープサイズは、前のサイクル終了時のライブヒープサイズと、前のサイクル以降にアプリケーションによって割り当てられた新しいヒープメモリとして定義されます。一方、目標ヒープメモリは次のように定義されます。
目標ヒープメモリ = ライブヒープ + (ライブヒープ + GCルート) * GOGC / 100
例として、ライブヒープサイズが8 MiB、ゴルーチンのスタックが1 MiB、グローバル変数内のポインタが1 MiBのGoプログラムを考えてみましょう。この場合、GOGCの値が100であれば、次のGCが実行されるまでに割り当てられる新しいメモリの量は10 MiB、つまり10 MiBの作業の100%となり、合計ヒープフットプリントは18 MiBになります。GOGCの値が50であれば、50%、つまり5 MiBになります。GOGCの値が200であれば、200%、つまり20 MiBになります。
注: GOGCにはGo 1.18以降ルートセットが含まれています。以前はライブヒープのみをカウントしていました。多くの場合、ゴルーチンスタックのメモリ量は非常に少なく、ライブヒープサイズが他のすべてのGC作業のソースを支配しますが、数十万のゴルーチンを持つプログラムの場合、GCは不適切な判断を下していました。
ヒープターゲットはGCの頻度を制御します。ターゲットが大きいほど、GCは次のマークフェーズを開始するまで長く待つことができ、その逆も同様です。正確な計算式は見積もりを行うのに役立ちますが、GOGCをその基本的な目的、つまりGCのCPUとメモリのトレードオフのポイントを選択するパラメータとして考えるのが最も良いでしょう。重要な点は、GOGCを2倍にするとヒープメモリのオーバーヘッドも2倍になり、GCのCPUコストは約半分になるということです。(その理由の完全な説明については、付録を参照してください。)
注: ヒープターゲットサイズはあくまで目標であり、GCサイクルがそのターゲットで正確に終了しない理由はいくつかあります。例えば、十分大きなヒープ割り当てが単純にターゲットを超える可能性があります。しかし、他の理由は、このガイドがこれまで使用してきたGCモデルを超えるGC実装にも現れます。詳細については、レイテンシセクションを参照してください。完全な詳細は追加リソースで見つけることができます。
GOGCは、GOGC環境変数(すべてのGoプログラムが認識する)またはruntime/debugパッケージのSetGCPercent APIを介して設定できます。
GOGC=offを設定するか、SetGCPercent(-1)を呼び出すことで、GOGCはGCを完全にオフにするためにも使用できます(メモリ制限が適用されない限り)。概念的には、この設定はGOGCを無限の値に設定することと同じです。GCがトリガーされる前の新しいメモリの量が無制限になるためです。
これまで説明してきたすべてをよりよく理解するために、先に議論したGCコストモデルに基づいて構築された以下のインタラクティブな可視化を試してみてください。この可視化は、GC以外の作業が完了するまでに10秒のCPU時間を要するプログラムの実行を描写しています。最初の1秒で、定常状態に入る前に初期化ステップ(ライブヒープの増加)を実行します。アプリケーションは合計で200 MiBを割り当て、一度に20 MiBがライブです。関連するGC作業はライブヒープからのみ発生し、アプリケーションは追加のメモリをまったく使用しない(非現実的ですが)と仮定しています。
スライダーを使用してGOGCの値を調整し、合計実行時間とGCオーバーヘッドの観点からアプリケーションがどのように応答するかを確認してください。各GCサイクルは、新しいヒープがゼロになったときに終了します。新しいヒープがゼロになるのにかかる時間は、サイクルNのマークフェーズとサイクルN+1のスイープフェーズの合計時間です。この可視化(およびこのガイドのすべての可視化)は、GCが実行されている間アプリケーションが一時停止すると仮定しているため、GCのCPUコストは、新しいヒープメモリがゼロになるのにかかる時間によって完全に表されます。これは可視化を単純化するためだけです。同じ直感は依然として適用されます。X軸は常にプログラムの全CPU時間期間を表示するようにシフトします。GCによって使用される追加のCPU時間が全体的な期間を増加させることに注意してください。
GCは常にCPUとピークメモリのオーバーヘッドを発生させることに注意してください。GOGCを増やすと、CPUオーバーヘッドは減少しますが、ピークメモリはライブヒープサイズに比例して増加します。GOGCを減らすと、ピークメモリ要件は、追加のCPUオーバーヘッドを犠牲にして減少します。
注: グラフはプログラムの完了までの経過時間ではなく、CPU時間を表示します。プログラムが1つのCPUで実行され、そのリソースを完全に利用する場合、これらは同等です。現実世界のプログラムは、マルチコアシステムで実行され、常にCPUを100%利用するわけではありません。これらの場合、GCの経過時間への影響は小さくなります。
注:Go GCの最小合計ヒープサイズは4 MiBであるため、GOGCで設定されたターゲットがそれ以下になる場合、切り上げられます。この可視化はこの詳細を反映しています。
これはもう少し動的で現実的な別の例です。ここでも、アプリケーションはGCなしで完了するまでに10 CPU秒かかりますが、定常状態の割り当てレートは途中で劇的に増加し、ライブヒープサイズは最初のフェーズで少し変動します。この例は、ライブヒープサイズが実際に変化している場合の定常状態がどのように見えるか、そして割り当てレートが高いほどGCサイクルが頻繁になる様子を示しています。
メモリ制限
Go 1.19までは、GOGCがGCの動作を変更できる唯一のパラメータでした。トレードオフを設定する方法としては優れていますが、利用可能なメモリが有限であることを考慮していません。ライブヒープサイズが一時的に急増した場合に何が起こるかを考えてみてください。GCはそのライブヒープサイズに比例して合計ヒープサイズを選択するため、通常のケースでGOGC値が高い方がより良いトレードオフを提供したとしても、GOGCはピークライブヒープサイズに合わせて設定されなければなりません。
以下の可視化は、この一時的なヒープスパイクの状況を示しています。
もし、例のワークロードが60 MiBを少し超えるメモリが利用可能なコンテナで実行されている場合、GOGCは100を超えて増やすことはできません。これは、残りのGCサイクルでその追加メモリを利用するのに十分なメモリがあったとしてもです。さらに、一部のアプリケーションでは、このような一時的なピークはまれで予測が困難な場合があり、偶発的で避けられない、そして潜在的に高コストなメモリ不足の状況につながる可能性があります。
それが、1.19リリースでGoがランタイムメモリ制限の設定をサポートするようになった理由です。メモリ制限は、すべてのGoプログラムが認識するGOMEMLIMIT環境変数、またはruntime/debugパッケージで利用可能なSetMemoryLimit関数を介して設定できます。
このメモリ制限は、Goランタイムが使用できるメモリの総量の最大値を設定します。含まれる特定のメモリセットは、runtime.MemStatsの式として定義されます。
Sys - HeapReleased
または、runtime/metricsパッケージの観点からは同等に、
/memory/classes/total:bytes - /memory/classes/heap/released:bytes
Go GCは、使用するヒープメモリ量を明示的に制御できるため、このメモリ制限とGoランタイムが使用する他のメモリ量に基づいて、合計ヒープサイズを設定します。
以下の可視化は、GOGCセクションと同じ単一フェーズの定常状態ワークロードを示していますが、今回はGoランタイムからの追加の10 MiBのオーバーヘッドと、調整可能なメモリ制限が含まれています。GOGCとメモリ制限の両方を変更してみて、何が起こるか見てみましょう。
メモリ制限がGOGCによって決定されるピークメモリ(GOGCが100の場合42 MiB)を下回ると、GCはピークメモリを制限内に維持するためにより頻繁に実行されることに注意してください。
一時的なヒープスパイクの前の例に戻りましょう。メモリ制限を設定し、GOGCを上げることで、両方の良い点を享受できます。メモリ制限の違反がなく、リソースの節約も向上します。以下のインタラクティブな可視化を試してみてください。
GOGCとメモリ制限のいくつかの値では、ピークメモリ使用量がメモリ制限値で止まるが、プログラムの残りの実行はGOGCによって設定された合計ヒープサイズルールに従うことに注意してください。
この観察は、もう1つの興味深い詳細につながります。GOGCがオフに設定されていても、メモリ制限は依然として尊重されます!実際、この特定の構成は、あるメモリ制限を維持するために必要な最小のGC頻度を設定するため、リソース効率の最大化を表します。この場合、プログラムのすべての実行でヒープサイズがメモリ制限に達するように増加します。
さて、メモリ制限は確かに強力なツールですが、メモリ制限の使用にはコストが伴いますし、GOGCの有用性を無効にするものでは決してありません。
ライブヒープが十分に大きくなり、合計メモリ使用量がメモリ制限に近づくとどうなるかを考えてみましょう。上記の定常状態の視覚化で、GOGCをオフにしてから、メモリ制限をゆっくりとさらに下げていくとどうなるか見てください。GCが不可能なメモリ制限を維持するために常に実行されるため、アプリケーションにかかる合計時間が際限なく増加し始めることに気付くでしょう。
このように、絶え間ないGCサイクルによりプログラムが合理的な進行をしない状況は、スラッシングと呼ばれます。これはプログラムを事実上停止させるため、特に危険です。さらに悪いことに、GOGCで避けようとしていたのとまったく同じ状況で発生する可能性があります。十分に大きな一時的なヒープスパイクは、プログラムを無期限に停止させる可能性があります!一時的なヒープスパイクの視覚化でメモリ制限を減らして(約30 MiB以下に)見てください。最も悪い動作がヒープスパイクから始まることに注目してください。
多くの場合、無期限の停止はメモリ不足状態よりも悪く、メモリ不足ははるかに迅速な障害につながる傾向があります。
このため、メモリ制限はソフトとして定義されています。Goランタイムは、いかなる状況下でもこのメモリ制限を維持することを保証するものではありません。合理的な努力をするだけです。このメモリ制限の緩和は、スラッシング動作を避けるために不可欠です。なぜなら、GCに解決策を与えるからです。GCに費やす時間を減らすために、メモリ使用量が制限を超えても構わないということです。
これが内部でどのように機能するかというと、GCは一定の時間枠で(CPU使用量のごく短い一時的なスパイクに対してはヒステリシスを設けて)使用できるCPU時間の上限を設定します。この制限は現在約50%に設定されており、2 * GOMAXPROCS CPU秒のウィンドウです。GC CPU時間を制限することの結果は、GCの作業が遅延することであり、その間Goプログラムはメモリ制限を超えても新しいヒープメモリを割り当て続けることができます。
50%というGC CPU制限の背後にある直感は、十分な利用可能なメモリを持つプログラムに対する最悪のケースの影響に基づいています。メモリ制限が誤って低く設定された場合、GCがCPU時間の50%以上を占めることはないため、プログラムの速度は最大で2倍に低下します。
注: このページの可視化はGC CPU制限をシミュレートしません。
推奨される使用法
メモリ制限は強力なツールであり、Goランタイムは誤用による最悪の挙動を軽減するための措置を講じていますが、それでも思慮深く使用することが重要です。以下に、メモリ制限が最も有用で適用可能な場所と、良いことよりも害を及ぼす可能性のある場所に関する助言のコレクションを示します。
-
Goプログラムの実行環境が完全に制御下にあり、Goプログラムが特定のリソースセット(つまり、コンテナのメモリ制限のようなメモリ予約)にアクセスできる唯一のプログラムである場合、メモリ制限を活用してください。
良い例は、固定量の利用可能なメモリを持つコンテナにウェブサービスをデプロイする場合です。
この場合、Goランタイムが認識しないメモリソースを考慮して、5~10%の追加のヘッドルームを残すのが良い経験則です。
-
変化する状況に適応するために、リアルタイムでメモリ制限を自由に調整してください。
良い例は、Cライブラリが一時的に大幅に多くのメモリを使用する必要があるcgoプログラムです。
-
Goプログラムが限られたメモリの一部を他のプログラムと共有する可能性があり、それらのプログラムが一般的にGoプログラムから切り離されている場合、メモリ制限を設定してGOGCをオフにしないでください。代わりに、望ましくない一時的な動作を抑制するのに役立つ可能性があるため、メモリ制限は維持し、GOGCは平均的なケースでより小さい、合理的な値に設定してください。
共同テナントプログラムのためにメモリを「予約」しようとすることは魅力的かもしれませんが、プログラムが完全に同期されていない限り(例:Goプログラムがサブプロセスを呼び出し、呼び出し先が実行中にブロックする)、結果は信頼性が低くなります。 inevitableに両方のプログラムがより多くのメモリを必要とするからです。Goプログラムが必要ないときに少ないメモリを使用できるようにすることは、全体としてより信頼性の高い結果を生み出します。このアドバイスは、1台のマシンで実行されているコンテナのメモリ制限の合計が、マシンが利用可能な実際の物理メモリを超えるようなオーバーコミット状況にも適用されます。
-
制御できない実行環境にデプロイする場合、特にプログラムのメモリ使用量が入力に比例する場合、メモリ制限を使用しないでください。
良い例は、CLIツールやデスクトップアプリケーションです。どのような種類の入力が供給されるか、またはシステムで利用可能なメモリ量がどの程度であるかが不明な場合に、メモリ制限をプログラムに組み込むと、混乱を招くクラッシュやパフォーマンスの低下につながる可能性があります。さらに、上級エンドユーザーは、希望すればいつでもメモリ制限を設定できます。
-
プログラムがすでに環境のメモリ制限に近づいているときに、メモリ不足状態を避けるためにメモリ制限を設定しないでください。
これは効果的にメモリ不足のリスクを深刻なアプリケーションの速度低下のリスクに置き換えるものであり、Goがスラッシングを軽減するために努力したとしても、多くの場合好ましいトレードオフではありません。そのような場合、環境のメモリ制限を増やすか(そしてその後にメモリ制限を設定する可能性もあります)、GOGCを減らすか(スラッシング軽減よりもはるかにクリーンなトレードオフを提供します)のいずれかの方がはるかに効果的でしょう。
レイテンシ
このドキュメントの可視化では、GCが実行されている間、アプリケーションが一時停止しているとモデル化されています。GCの実装にはこのような動作をするものもあり、「ストップ・ザ・ワールド」GCと呼ばれます。
しかし、Go GCは完全にストップ・ザ・ワールドではなく、その作業のほとんどをアプリケーションと並行して行います。これは主にアプリケーションのレイテンシを削減するためです。具体的には、単一の計算単位(例:ウェブリクエスト)の最初から最後までの期間です。これまで、このドキュメントでは主にアプリケーションのスループット(例:1秒あたりに処理されるウェブリクエスト)を検討してきました。GCサイクルセクションの各例が、実行中のプログラムの総CPU期間に焦点を当てていたことに注意してください。しかし、このような期間は、例えばウェブサービスにとってははるかに意味がありません。スループットはウェブサービスにとって依然として重要ですが(つまり、1秒あたりのクエリ数)、個々のリクエストのレイテンシの方がはるかに重要であることもよくあります。
レイテンシの観点から見ると、ストップ・ザ・ワールドGCはマークフェーズとスイープフェーズの両方を実行するのにかなりの時間を要する可能性があり、その間、アプリケーション、そしてウェブサービスの文脈では、処理中のリクエストはそれ以上進行することができません。代わりに、Go GCは、グローバルなアプリケーションの一時停止の長さをヒープのサイズに比例させないようにし、コアなトレーシングアルゴリズムはアプリケーションがアクティブに実行されている間に実行されます。(一時停止はアルゴリズム的にはGOMAXPROCSに強く比例しますが、ほとんどの場合、実行中のゴルーチンを停止するのにかかる時間によって支配されます。)並行して収集することにはコストがかからないわけではありません。実際には、同等のストップ・ザ・ワールドガベージコレクターよりもスループットが低い設計になることがよくあります。ただし、レイテンシが低いことが本質的にスループットが低いことを意味するわけではないことに注意することが重要であり、Goガベージコレクターのパフォーマンスは、レイテンシとスループットの両方で、時間の経過とともに着実に向上しています。
Goの現在のGCの並行処理の性質は、これまでこのドキュメントで議論されたことを何も無効にするものではありません。どの記述もこの設計上の選択に依存していませんでした。GCの頻度は、スループットのためにGCがCPU時間とメモリをトレードオフする主要な方法であり、実際、レイテンシにおいてもこの役割を担っています。これは、GCのコストのほとんどがマークフェーズがアクティブな間に発生するためです。
したがって、重要なことは、GC頻度を減らすことでレイテンシの改善にもつながる可能性があるということです。これは、GOGCやメモリ制限の増加などのチューニングパラメータの変更によるGC頻度の削減だけでなく、最適化ガイドで説明されている最適化にも適用されます。
しかし、レイテンシはスループットよりも理解が複雑であることが多く、コストの集計だけでなく、プログラムの瞬間的な実行の産物だからです。その結果、レイテンシとGC頻度の関係はより間接的になります。以下に、より深く掘り下げたい人向けに、レイテンシの潜在的な原因のリストを示します。
- GCがマークフェーズとスイープフェーズの間で移行するときの短いストップ・ザ・ワールドポーズ
- マークフェーズ中にGCがCPUリソースの25%を消費するため、スケジューリングの遅延が発生する
- 高い割り当て率に応答して、ユーザーのゴルーチンがGCを支援している
- GCがマークフェーズ中にポインタの書き込みに追加の作業が必要となる、および
- 実行中のゴルーチンは、そのルートがスキャンされるために一時停止されなければなりません。
これらのレイテンシーの原因は、追加作業を必要とするポインタの書き込みを除いて、実行トレースで確認できます。
ファイナライザ、クリーンアップ、弱いポインタ
ガベージコレクションは、有限のメモリしか使用しないのに、無限のメモリという錯覚を提供します。メモリは割り当てられますが、明示的に解放されることはありません。これにより、素のCのような手動メモリ管理と比較して、よりシンプルなAPIと並行アルゴリズムが可能になります。(手動メモリ管理を行う一部の言語は、「スマートポインタ」やコンパイル時の所有権追跡などの代替アプローチを使用してオブジェクトが解放されるようにしますが、これらの機能はこれらの言語のAPI設計規則に深く組み込まれています。)
ライブなオブジェクト、つまりグローバル変数や何らかのゴルーチンでの計算から到達可能なオブジェクトだけが、プログラムの動作に影響を与えることができます。オブジェクトが到達不能(「デッド」)になった後は、いつでも安全にGCによって再利用できます。これにより、今日のGoが使用しているトレーシング設計のような、非常に多様なGC設計が可能になります。オブジェクトの死は、言語レベルで観測可能なイベントではありません。
しかし、Goのランタイムライブラリは、その幻想を打ち破る3つの機能を提供します。クリーンアップ、弱いポインタ、ファイナライザです。これらの各機能は、オブジェクトの死を観測して反応する何らかの方法を提供し、ファイナライザの場合、それを逆転させることさえできます。これはもちろんGoプログラムを複雑にし、GC実装に追加の負担をかけます。しかし、これらの機能は様々な状況で有用であるため存在し、Goプログラムはそれらを使用し、常に恩恵を受けています。
各機能の詳細については、それぞれのパッケージドキュメント(runtime.AddCleanup、weak.Pointer、runtime.SetFinalizer)を参照してください。以下に、これらの機能を使用するための一般的なアドバイス、各機能で遭遇する可能性のある一般的な問題の概要、およびこれらの機能の使用をテストするためのアドバイスを示します。
一般的なアドバイス
-
単体テストを作成する。
クリーンアップ、弱いポインタ、ファイナライザの正確なタイミングは予測が困難であり、多くの連続実行後でもすべてが機能していると自分を納得させるのは簡単です。しかし、微妙な間違いを犯すのも簡単です。それらのテストを書くのは難しいかもしれませんが、それらが使用するのが非常に微妙であることを考えると、テストは通常よりもさらに重要です。
-
一般的なGoコードでこれらの機能を直接使用することは避けてください。
これらは微妙な制限と動作を持つ低レベルの機能です。例えば、クリーンアップやファイナライザがプログラム終了時に実行される保証はまったくありません。それらのAPIドキュメントの長いコメントは警告と見なすべきです。Goコードの大部分は、これらの機能を直接使用することから利益を得ることはなく、間接的にのみ利益を得ます。
-
これらのメカニズムの使用をパッケージ内にカプセル化してください。
可能な限り、これらのメカニズムの使用がパッケージの公開APIに漏れ出ないようにしてください。ユーザーが誤用することが困難または不可能なインターフェースを提供してください。例えば、Cで割り当てられたメモリを解放するためにクリーンアップを設定するようユーザーに求める代わりに、ラッパーパッケージを作成し、その詳細を内部に隠してください。
-
ファイナライザ、クリーンアップ、および弱いポインタを持つオブジェクトへのアクセスを、それらを作成し適用したパッケージに限定してください。
これは前の点に関連していますが、これらの機能をエラーを起こしにくい方法で使用するための非常に強力なパターンであるため、明示的に言及する価値があります。たとえば、uniqueパッケージは内部的に弱いポインタを使用していますが、弱く指されるオブジェクトを完全にカプセル化しています。これらの値はアプリケーションの他の部分によって変更されることはなく、Valueメソッドを通じてのみコピーでき、パッケージユーザーには無限のメモリの錯覚が保たれます。
-
可能であれば、非メモリリソースのクリーンアップを決定論的に行い、ファイナライザとクリーンアップはフォールバックとして使用することを推奨します。
クリーンアップとファイナライザは、Cから割り当てられたメモリや
mmapマッピングへの参照など、外部で割り当てられたメモリリソースに適しています。Cのmallocで割り当てられたメモリは、最終的にCのfreeで解放されなければなりません。Cメモリのラッパーオブジェクトにアタッチされた、freeを呼び出すファイナライザは、ガベージコレクションの結果としてCメモリが最終的に回収されることを保証する合理的な方法です。しかし、ファイルディスクリプタのような非メモリリソースは、Goランタイムが一般的に認識しないシステム制限の対象となる傾向があります。さらに、特定のGoプログラムにおけるガベージコレクターのタイミングは、パッケージ作者がほとんど制御できないことがほとんどです(例えば、GCの実行頻度はGOGCによって制御され、これは実務上、オペレーターによって様々な異なる値に設定される可能性があります)。これらの2つの事実が相まって、クリーンアップとファイナライザを非メモリリソースを解放する唯一のメカニズムとして使用するのに適さないものにしています。
非メモリリソースをラップするAPIを公開するパッケージ作者であれば、クリーンアップやファイナライザを通じてガベージコレクターに依存するのではなく、リソースを決定論的に解放するための明示的なAPI(
Closeメソッドなど)を提供することを検討してください。代わりに、プログラマーのミスに対する最善の努力ハンドラとしてクリーンアップやファイナライザを使用することを推奨します。これは、os.Fileが行うようにリソースをクリーンアップするか、決定論的にクリーンアップできなかったことをユーザーに報告することによって行われます。 -
ファイナライザよりもクリーンアップを推奨します。
歴史的に、ファイナライザはGoコードとCコード間のインターフェースを簡素化し、非メモリリソースをクリーンアップするために追加されました。意図された使用方法は、Cメモリまたは他の非メモリリソースを所有するラッパーオブジェクトにそれらを適用し、Goコードがそれを使用し終えたらリソースを解放できるようにすることでした。これらの理由は、ファイナライザのスコープが狭い理由、任意のオブジェクトに1つのファイナライザしか設定できない理由、およびそのファイナライザがオブジェクトの最初のバイトにのみアタッチされなければならない理由を少なくとも部分的に説明しています。この制限はすでに一部のユースケースを妨げています。例えば、オブジェクトに関する情報を内部的にキャッシュしたいパッケージは、オブジェクトがなくなったときにその情報をクリーンアップできません。
しかし、それよりも悪いことに、ファイナライザは効率が悪く、エラーが発生しやすいのは、それらがアタッチされているオブジェクトを復活させるため、ファイナライザ関数に渡すことができ(さらにその後も生存し続けることさえあります)、非効率的でエラーが発生しやすいということです。この単純な事実が意味するのは、オブジェクトが参照サイクルの一部である場合、決して解放されず、オブジェクトをバックアップするメモリは少なくとも次のガベージコレクションサイクルまで再利用できないということです。
しかし、ファイナライザはオブジェクトを復活させるため、クリーンアップよりも実行順序が明確に定義されています。このため、ファイナライザは複雑な破棄順序要件を持つ構造体のクリーンアップに(まれですが)まだ役立つ可能性があります。
しかし、Go 1.24以降の他のすべての用途では、ファイナライザよりも柔軟で、エラーを起こしにくく、効率的なクリーンアップの使用を推奨します。
よくあるクリーンアップの問題
-
クリーンアップがアタッチされたオブジェクトは、クリーンアップ関数から到達可能であってはなりません(例えば、キャプチャされたローカル変数を介して)。これにより、オブジェクトが回収されず、クリーンアップが実行されなくなります。
f := new(myFile)
f.fd = syscall.Open(...)
runtime.AddCleanup(f, func(fd int) {
syscall.Close(f.fd) // Mistake: We reference f, so this cleanup won't run!
}, f.fd)
クリーンアップがアタッチされたオブジェクトは、クリーンアップ関数の引数から到達可能であってはなりません。これにより、オブジェクトが回収されず、クリーンアップが実行されなくなります。
f := new(myFile)
f.fd = syscall.Open(...)
runtime.AddCleanup(f, func(f *myFile) {
syscall.Close(f.fd)
}, f) // Mistake: We reference f, so this cleanup wouldn't ever run. This specific case also panics.
ファイナライザには明確に定義された実行順序がありますが、クリーンアップにはありません。クリーンアップは互いに並行して実行することもできます。
長時間実行されるクリーンアップは、他のクリーンアップの実行をブロックしないようにゴルーチンを作成する必要があります。
runtime.GCは、到達不能なオブジェクトのクリーンアップが実行されるまで待機せず、それらすべてがキューに入れられるまでのみ待機します。
弱いポインタでよくある問題
-
弱いポインタは、予期しないタイミングで
Valueメソッドからnilを返すことがあります。常にValueの呼び出しをnilチェックで保護し、代替案を用意してください。 -
弱いポインターがマップのキーとして使用される場合、マップの値の到達可能性には影響しません。したがって、弱いポインターのマップキーがマップの値からも到達可能なオブジェクトを指している場合、そのオブジェクトは依然として到達可能と見なされます。
よくあるファイナライザの問題
-
ファイナライザがアタッチされたオブジェクトは、いかなるパスからも自身から到達可能であってはなりません(言い換えれば、参照サイクルに含まれてはなりません)。これにより、オブジェクトが回収されず、ファイナライザが実行されなくなります。
f := new(myCycle)
f.self = f // Mistake: f is reachable from f, so this finalizer would never run.
runtime.SetFinalizer(f, func(f *myCycle) {
...
})
ファイナライザがアタッチされたオブジェクトは、ファイナライザ関数から到達可能であってはなりません(例えば、キャプチャされたローカル変数を介して)。これにより、オブジェクトが回収されず、ファイナライザが実行されなくなります。
f := new(myFile)
f.fd = syscall.Open(...)
runtime.SetFinalizer(f, func(_ *myFile) {
syscall.Close(f.fd) // Mistake: We reference the outer f, so this cleanup won't run!
})
ファイナライザがアタッチされたオブジェクトの参照チェーン(例えば、リンクリスト)をすべてクリーンアップするには、最低でもチェーン内のオブジェクト数と同じGCサイクル数が必要です。ファイナライザは浅く保ってください!
// Mistake: reclaiming this linked list will take at least 10 GC cycles.
node := new(linkedListNode)
for range 10 {
tmp := new(linkedListNode)
tmp.next = node
node = tmp
runtime.SetFinalizer(node, func(node *linkedListNode) {
...
})
}
パッケージ境界で返されるオブジェクトにファイナライザを設定することは避けてください。これにより、パッケージのユーザーがruntime.SetFinalizerを呼び出して、返されるオブジェクトのファイナライザを変更できるようになり、これはパッケージのユーザーが最終的に依存する可能性のある予期しない動作につながる可能性があります。
長時間実行されるファイナライザは、他のファイナライザの実行をブロックしないように、新しいゴルーチンを作成する必要があります。
runtime.GCは、到達不能なオブジェクトのファイナライザが実行されるまで待機せず、それらすべてがキューに入れられるまでのみ待機します。
オブジェクトの死をテストする
これらの機能を使用する場合、それらを使用するコードのテストを書くのは、時にはトリッキーなことがあります。これらの機能を使用するコードに対して堅牢なテストを書くためのヒントをいくつか紹介します。
- このようなテストを他のテストと並行して実行することは避けてください。決定論性を可能な限り高め、特定の時点での世界の状況を適切に把握することが非常に役立ちます。
runtime.GCを使用して、テストに入るときのベースラインを確立します。runtime.GCを使用して、弱いポインターをnilに強制し、クリーンアップとファイナライザをキューに入れて実行します。-
runtime.GCはクリーンアップとファイナライザの実行を待たず、それらをキューに入れるだけです。可能な限り堅牢なテストを作成するには、テストからクリーンアップまたはファイナライザでブロックする方法を注入します(たとえば、クリーンアップまたはファイナライザにオプションのチャネルをテストから渡し、実行が終了したらチャネルに書き込みます)。これが難しすぎるか不可能な場合は、代替案として、特定のクリーンアップ後の状態がtrueになるまでスピンすることです。たとえば、
osテストは、ファイルが到達不能になった後、ファイルが閉じられたかどうかをチェックするループでruntime.Goschedを呼び出します。 -
ファイナライザを使用するテストを作成していて、ファイナライザを使用するオブジェクトのチェーンがある場合、すべてのファイナライザが実行されるようにするには、テストが作成できる最も深いチェーンの長さ以上の
runtime.GC呼び出しが最低限必要になります。 -
競合モードでテストを実行し、並行するクリーンアップ間、およびクリーンアップとファイナライザコードとコードベースの他の部分との間の競合を発見します。
追加リソース
上記の情報は正確ですが、Go GCの設計におけるコストとトレードオフを完全に理解するための詳細が不足しています。詳細については、以下の追加リソースを参照してください。
- GCハンドブック—ガベージコレクター設計に関する優れた一般リソースおよび参考文献。
- TCMalloc—Goメモリ割り当てのベースとなっているC/C++メモリ割り当てTCMallocの設計ドキュメント。
- Go 1.5 GC発表—Go 1.5の並行GCを発表し、アルゴリズムをより詳細に説明したブログ投稿。
- Getting to Go—2018年までのGoのGC設計の進化に関する詳細なプレゼンテーション。
- Go 1.5 concurrent GC pacing—並行マークフェーズを開始するタイミングを決定するための設計ドキュメント。
- Smarter scavenging—Goランタイムがオペレーティングシステムにメモリを返す方法を改訂するための設計ドキュメント。
- Scalable page allocator—Goランタイムがオペレーティングシステムから取得したメモリを管理する方法を改訂するための設計ドキュメント。
- GC pacer redesign (Go 1.18)—並行マークフェーズを開始するアルゴリズムを改訂するための設計ドキュメント。
- Soft memory limit (Go 1.19)—ソフトメモリ制限の設計ドキュメント。
仮想メモリに関する注意
このガイドでは、主にGCの物理メモリ使用量に焦点を当ててきましたが、定期的に出てくる疑問は、それが正確に何を意味するのか、そして仮想メモリ(通常、topのようなプログラムでは「VSS」として表示される)と比較してどうかということです。
物理メモリとは、ほとんどのコンピューターに搭載されている実際の物理RAMチップに収容されているメモリです。仮想メモリは、プログラムを互いから隔離するためにオペレーティングシステムが提供する物理メモリの抽象化です。また、プログラムが物理アドレスにまったくマッピングされない仮想アドレス空間を予約することも一般的に許容されます。
仮想メモリは単にオペレーティングシステムによって維持されるマッピングであるため、物理メモリにマッピングされない大きな仮想メモリ予約を行うことは通常非常に安価です。
Goランタイムは、通常、仮想メモリのコストに関するこの見解にいくつかの方法で依存しています。
-
Goランタイムは、マップした仮想メモリを削除することはありません。代わりに、ほとんどのオペレーティングシステムが提供する特別な操作を使用して、特定の仮想メモリ範囲に関連付けられた物理メモリリソースを明示的に解放します。
この手法は、メモリ制限を管理し、Goランタイムが不要になったメモリをオペレーティングシステムに返すために明示的に使用されます。Goランタイムは、不要になったメモリもバックグラウンドで継続的に解放します。詳細については、追加リソースを参照してください。
-
32ビットプラットフォームでは、Goランタイムは断片化の問題を制限するために、ヒープ用に128 MiBから512 MiBのアドレス空間を事前に予約します。
-
Goランタイムは、いくつかの内部データ構造の実装で大きな仮想メモリアドレス空間予約を使用します。64ビットプラットフォームでは、これらは通常、約700 MiBの最小仮想メモリフットプリントを持ちます。32ビットプラットフォームでは、それらのフットプリントは無視できます。
結果として、topの「VSS」のような仮想メモリの指標は、Goプログラムのメモリフットプリントを理解する上で通常あまり有用ではありません。代わりに、「RSS」や類似の測定値に注目してください。これらは物理メモリ使用量をより直接的に反映します。
最適化ガイド
コストの特定
GoアプリケーションがGCとどのように相互作用するかを最適化しようとする前に、まずGCがそもそも大きなコストになっているかどうかを特定することが重要です。
Goエコシステムには、コストを特定し、Goアプリケーションを最適化するための多くのツールが提供されています。これらのツールの概要については、診断ガイドを参照してください。ここでは、これらのツールのサブセットと、GCの影響と動作を理解するためにそれらを適用する合理的な順序に焦点を当てます。-
CPUプロファイル
良い出発点はCPUプロファイリングです。CPUプロファイリングは、CPU時間がどこに費やされているかの概要を提供しますが、慣れない目には、GCが特定のアプリケーションで果たす役割の大きさを特定するのは難しいかもしれません。幸いなことに、GCがどのように適合するかを理解するには、主に`runtime`パッケージ内の異なる関数の意味を知ることに尽きます。以下は、CPUプロファイルを解釈するのに役立つこれらの関数の有用なサブセットです。
以下の関数はリーフ関数ではないため、
topコマンドでpprofツールが提供するデフォルトには表示されない場合があることに注意してください。代わりに、top -cumコマンドを使用するか、これらの関数に直接listコマンドを使用し、累積パーセント列に注目してください。 -
runtime.gcBgMarkWorker: バックグラウンドマークワーカーゴルーチンのエントリポイント。ここに費やされる時間は、GC頻度、オブジェクトグラフの複雑さ、およびサイズに比例します。これは、アプリケーションがマーキングとスキャンに費やす時間のベースラインを表します。これらのゴルーチン内では、ワーカーのタイプを示す
runtime.gcDrainMarkWorkerDedicated、runtime.gcDrainMarkWorkerFractional、およびruntime.gcDrainMarkWorkerIdleへの呼び出しが見つかることに注意してください。ほとんどアイドル状態のGoアプリケーションでは、Go GCは追加の(アイドル状態の)CPUリソースを使用して作業をより速く完了させようとします。これはruntime.gcDrainMarkWorkerIdleシンボルで示されます。結果として、ここでの時間はCPUサンプルの大部分を占める可能性があり、Go GCはそれをフリーであると信じています。アプリケーションがよりアクティブになると、アイドルワーカーでのCPU時間は減少します。これが起こる一般的な理由の1つは、アプリケーションが完全に1つのゴルーチンで実行されているが、GOMAXPROCSが1よりも大きい場合です。 -
runtime.mallocgc: ヒープメモリのメモリ割り当て器へのエントリポイント。ここに費やされる累積時間が大量(15%超)である場合、通常、大量のメモリが割り当てられていることを示します。 -
runtime.gcAssistAlloc: ゴルーチンが自身の時間の一部をGCのスキャンとマーキングを支援するために譲る関数。ここに費やされる累積時間が大量(5%超)である場合、アプリケーションが割り当ての速さに関してGCを上回っている可能性が高いことを示します。これはGCからの特に高い影響度を示し、アプリケーションがマーキングとスキャンに費やす時間も表します。これはruntime.mallocgcの呼び出しツリーに含まれているため、これも膨らませることに注意してください。 -
実行トレース
CPUプロファイルは、時間がどこで費やされているかを全体的に特定するのに優れていますが、より微妙でまれな、または具体的にレイテンシに関連するパフォーマンスコストを示すのにはあまり役立ちません。一方、実行トレースは、Goプログラムの短い実行期間を豊かで詳細な視点から提供します。Go GCに関連するさまざまなイベントを含み、特定の実行パスと、アプリケーションがGo GCとどのように相互作用するかを直接観察できます。追跡されるすべてのGCイベントは、トレースビューアでそのようにラベル付けされています。
実行トレースを開始する方法については、
runtime/traceパッケージのドキュメントを参照してください。 -
GCトレース
他のすべての手段が失敗した場合、Go GCはGCの動作に関するより深い洞察を提供するいくつかの異なる特定のトレースを提供します。これらのトレースは常にSTDERRに直接出力され、GCサイクルごとに1行で表示され、すべてのGoプログラムが認識する
GODEBUG環境変数を通じて設定されます。GCの実装の詳細に関するある程度の知識が必要であるため、主にGo GC自体をデバッグするのに役立ちますが、それでもGCの動作をよりよく理解するために時折役立つことがあります。コアGCトレースは
GODEBUG=gctrace=1を設定することで有効になります。このトレースによって生成される出力は、runtimeパッケージのドキュメントの環境変数セクションに記載されています。「ペーサートレース」と呼ばれる補助的なGCトレースはさらに深い洞察を提供し、
GODEBUG=gcpacertrace=1を設定することで有効になります。この出力の解釈には、GCの「ペーサー」の理解が必要です(追加リソースを参照)。これはこのガイドの範囲外です。
ヒープ割り当ての排除
GCからのコストを削減する1つの方法は、GCが管理する値を最初から減らすことです。以下に説明する手法は、パフォーマンスを大幅に向上させる可能性があります。なぜなら、GOGCセクションで示されたように、Goプログラムの割り当てレートは、このガイドで使用される主要なコスト指標であるGC頻度の主要な要因だからです。
ヒーププロファイリング
GCが重大なコストの原因であると特定した後、ヒープ割り当てを排除する次のステップは、それらのほとんどがどこから来ているかを突き止めることです。この目的のために、メモリプロファイル(実際にはヒープメモリプロファイル)は非常に有用です。それらの使用を開始する方法については、ドキュメントを参照してください。
メモリプロファイルは、プログラム内のヒープ割り当てがどこから来ているかを記述し、割り当て時のスタックトレースによってそれらを識別します。各メモリプロファイルは、メモリを4つの方法で分類できます。
inuse_objects—ライブなオブジェクトの数を分類します。inuse_space—ライブなオブジェクトが使用するメモリ量をバイト単位で分類します。alloc_objects—Goプログラムの実行開始以降に割り当てられたオブジェクトの数を分類します。alloc_space—Goプログラムの実行開始以降に割り当てられたメモリの総量を分類します。
ヒープメモリのこれらの異なるビュー間の切り替えは、pprofツールの-sample_indexフラグを使用するか、ツールを対話的に使用する際にsample_indexオプションを使用することで行うことができます。
注: メモリプロファイルはデフォルトでヒープオブジェクトのサブセットのみをサンプリングするため、すべてのヒープ割り当てに関する情報は含まれません。ただし、これはホットスポットを見つけるのに十分です。サンプリングレートを変更するには、runtime.MemProfileRateを参照してください。
GCコスト削減の目的では、alloc_spaceが割り当てレートに直接対応するため、通常最も有用なビューです。このビューは、最もメリットのある割り当てホットスポットを示します。
エスケープ解析
ヒーププロファイルの助けを借りて候補となるヒープ割り当てサイトが特定されたら、それらをどのように排除できるでしょうか?鍵は、Goコンパイラのエスケープ解析を利用して、Goコンパイラにこのメモリの代替となる、より効率的なストレージ(例えばゴルーチンスタックなど)を見つけさせることです。幸いなことに、Goコンパイラは、Go値をヒープにエスケープさせる決定を下した理由を記述する能力を持っています。その知識があれば、分析の結果を変更するためにソースコードを再編成する問題になります(これが最も難しい部分であることが多いですが、このガイドの範囲外です)。
Goコンパイラのエスケープ解析からの情報にアクセスする方法として、最も簡単なのは、Goコンパイラがサポートするデバッグフラグを使用することです。これは、特定のパッケージに適用された、または適用されなかったすべての最適化をテキスト形式で記述します。これには、値がエスケープするかどうかも含まれます。[package]がGoパッケージパスである以下のコマンドを試してください。
$ go build -gcflags=-m=3 [package]
この情報は、LSP対応エディタでオーバーレイとして視覚化することもできます。コードアクションとして公開されています。例えば、VS Codeでは、「Source Action... > Show compiler optimization details」コマンドを呼び出して、現在のパッケージの診断を有効にします。(「Go: Toggle compiler optimization details」コマンドを実行することもできます。)表示されるアノテーションを制御するには、この設定を使用します。
ui.diagnostic.annotationsをescapeを含むように設定して、エスケープ解析のオーバーレイを有効にします。
最後に、Goコンパイラはこの情報を機械可読な(JSON)形式で提供しており、追加のカスタムツールを構築するために使用できます。詳細については、Goソースコードのドキュメントを参照してください。
実装固有の最適化
Go GCはライブメモリの特性に敏感です。なぜなら、オブジェクトとポインタの複雑なグラフは並列処理を制限し、GCの作業を増やすからです。結果として、GCは特定の一般的な構造に対していくつかの最適化を含んでいます。パフォーマンス最適化に最も直接的に役立つものを以下に示します。
注:以下の最適化を適用すると、意図が不明確になるため、コードの可読性が低下する可能性があり、Goのリリース間で維持されない可能性があります。これらの最適化は、最も重要な場所にのみ適用することを推奨します。そのような場所は、コストを特定するセクションに記載されているツールを使用して特定できます。
-
ポインタのない値は他の値と分離されます。
結果として、厳密に必要としないデータ構造からポインターを排除することは有利かもしれません。これにより、GCがプログラムにかけるキャッシュ圧力が減少するからです。その結果、ポインター値に対するインデックスに依存するデータ構造は、型付けが不十分であるものの、より良いパフォーマンスを発揮する可能性があります。これは、オブジェクトグラフが複雑であり、GCがマーキングとスキャンに多くの時間を費やしていることが明らかである場合にのみ行う価値があります。
-
GCは、値の最後のポインタで値のスキャンを停止します。
その結果、構造体型の値のポインタフィールドを値の先頭にグループ化すると有利になる場合があります。これは、アプリケーションがマーキングとスキャンに多くの時間を費やしていることが明らかな場合にのみ行う価値があります。(理論的にはコンパイラがこれを自動的に行うことができますが、まだ実装されておらず、構造体フィールドはソースコードに書かれたとおりに配置されます。)
さらに、GCは、見るほぼすべてのポインタと対話する必要があるため、ポインタの代わりに、例えばスライスへのインデックスを使用すると、GCコストの削減に役立ちます。
Linux Transparent Huge Pages (THP)
プログラムがメモリにアクセスすると、CPUは使用する仮想メモリアドレスを、アクセスしようとしているデータを参照する物理メモリアドレスに変換する必要があります。これを行うために、CPUは、オペレーティングシステムによって管理される、仮想メモリから物理メモリへのマッピングを表すデータ構造である「ページテーブル」を参照します。ページテーブルの各エントリは、ページと呼ばれる分割不可能な物理メモリブロックを表しており、これが名前の由来です。
Transparent Huge Pages (THP) は、連続した仮想メモリ領域をバックアップする物理メモリページを、Huge Pagesと呼ばれるより大きなメモリブロックに透過的に置き換えるLinuxの機能です。より大きなブロックを使用することで、同じメモリ領域を表すのに必要なページテーブルエントリが少なくなり、ページテーブルのルックアップ時間が改善されます。ただし、より大きなブロックは、Huge Pageのごく一部しかシステムで使用されない場合、より多くの無駄が生じることを意味します。
本番環境でGoプログラムを実行する場合、LinuxでTransparent Huge Pagesを有効にすると、追加のメモリ使用量と引き換えにスループットとレイテンシを向上させることができます。ヒープが小さいアプリケーションはTHPの恩恵を受ける傾向がなく、かなりの量の追加メモリ(50%にも達する)を使用することになる可能性があります。しかし、ヒープが大きいアプリケーション(1GiB以上)は、追加のメモリオーバーヘッドがほとんどなく(1-2%以下)、かなりの恩恵(スループットが最大10%)を受ける傾向があります。いずれの場合もTHPの設定を認識しておくことは有用であり、常に実験が推奨されます。
Linux環境でTransparent Huge Pagesを有効または無効にするには、/sys/kernel/mm/transparent_hugepage/enabledを変更します。詳細については、公式のLinux管理ガイドを参照してください。Linux本番環境でTransparent Huge Pagesを有効にすることを選択した場合は、Goプログラム用に以下の追加設定をお勧めします。
-
/sys/kernel/mm/transparent_hugepage/defragをdeferまたはdefer+madviseに設定します。
この設定は、Linuxカーネルが通常のページをHuge Pagesにどれだけ積極的に統合するかを制御します。deferは、カーネルにHuge Pagesを遅延してバックグラウンドで統合するように指示します。より積極的な設定は、メモリ制約のあるシステムで停止を引き起こし、アプリケーションのレイテンシを損なうことがよくあります。defer+madviseはdeferに似ていますが、明示的にHuge Pagesを要求し、パフォーマンスのためにそれらを必要とするシステム上の他のアプリケーションに対してより友好的です。 -
/sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_noneを0に設定します。
この設定は、LinuxカーネルデーモンがHuge Pageを割り当てようとするときに、追加で割り当てることができるページ数を制御します。デフォルト設定は最大限に積極的であり、GoランタイムがOSにメモリを返すために行う作業を元に戻すことがよくあります。Go 1.21より前では、Goランタイムはデフォルト設定の悪影響を軽減しようとしましたが、CPUコストがかかりました。Go 1.21以降およびLinux 6.2以降では、GoランタイムはHuge Pageの状態を変更しなくなりました。
Go 1.21.1以降にアップグレードしたときにメモリ使用量が増加する場合は、この設定を適用してみてください。おそらく問題が解決するでしょう。追加の回避策として、プロセスレベルでHuge Pagesを無効にするためにPR_SET_THP_DISABLEを指定してPrctl関数を呼び出すか、ヒープメモリのHuge Pagesを無効にするためにGODEBUG=disablethp=1(Go 1.21.6およびGo 1.22で追加予定)を設定することができます。なお、GODEBUG設定は将来のリリースで削除される可能性があります。
付録
GOGCに関する追加の注意事項
GOGCセクションでは、GOGCを2倍にするとヒープメモリのオーバーヘッドが2倍になり、GCのCPUコストが半分になると主張しました。その理由を数学的に分解してみましょう。
まず、ヒープターゲットは、ヒープの総サイズを設定します。ただし、このターゲットは、アプリケーションにとってライブヒープが基本的なものであるため、主に新しいヒープメモリに影響を与えます。
目標ヒープメモリ = ライブヒープ + (ライブヒープ + GCルート) * GOGC / 100
総ヒープメモリ = ライブヒープ + 新しいヒープメモリ
⇒
新しいヒープメモリ = (ライブヒープ + GCルート) * GOGC / 100
これから、GOGCを2倍にすると、アプリケーションがサイクルごとに割り当てる新しいヒープメモリの量も2倍になり、これがヒープメモリのオーバーヘッドを捉えていることがわかります。ここで、ライブヒープ + GCルートは、GCがスキャンする必要があるメモリ量の近似値であることに注意してください。
次に、GCのCPUコストを見てみましょう。総コストは、ある期間TにおけるサイクルあたりのコストとGCの頻度を掛けたものに分解できます。
GCの総CPUコスト = (サイクルあたりのGCのCPUコスト) * (GCの頻度) * T
サイクルあたりのGCのCPUコストは、GCモデルから導出できます
サイクルあたりのGCのCPUコスト = (ライブヒープ + GCルート) * (バイトあたりのコスト) + 固定コスト
マークとスキャンのコストが支配的であるため、ここではスイープフェーズのコストは無視されていることに注意してください。
定常状態は、一定の割り当てレートとバイトあたりの一定のコストによって定義されるため、定常状態ではこの新しいヒープメモリからGC頻度を導出できます
GC頻度 = (割り当てレート) / (新しいヒープメモリ) = (割り当てレート) / ((ライブヒープ + GCルート) * GOGC / 100)
これをまとめると、総コストの完全な式が得られます
GCの総CPUコスト = (割り当てレート) / ((ライブヒープ + GCルート) * GOGC / 100) * ((ライブヒープ + GCルート) * (バイトあたりのコスト) + 固定コスト) * T
十分に大きなヒープ(ほとんどの場合に該当)では、GCサイクルの限界コストが固定コストを上回ります。これにより、GCの総CPUコストの公式が大幅に簡略化されます。
GCの総CPUコスト = (割り当てレート) / (GOGC / 100) * (バイトあたりのコスト) * T
この簡略化された公式から、GOGCを2倍にすると、GCの総CPUコストが半分になることがわかります。(このガイドの視覚化では固定コストをシミュレートしているため、GOGCを2倍にしても、そこから報告されるGCのCPUオーバーヘッドは正確に半分にはなりません。)さらに、GCのCPUコストは、主に割り当てレートとメモリをスキャンするバイトあたりのコストによって決定されます。これらのコストを具体的に削減する方法の詳細については、最適化ガイドを参照してください。
注:ライブヒープのサイズと、GCが実際にスキャンする必要があるそのメモリの量との間には不一致があります。同じサイズのライブヒープでも構造が異なると、CPUコストは異なりますが、メモリコストは同じになり、異なるトレードオフが生じます。これが、ヒープの構造が定常状態の定義の一部である理由です。ヒープターゲットは、GCがスキャンする必要があるメモリのより近い近似として、スキャン可能なライブヒープのみを含めるべきだと主張できますが、これはスキャン可能なライブヒープが非常に少ないがライブヒープは他に大きい場合に、退行的な動作につながります。