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のリリース間で変化します。どの値がエスケープし、どの値がエスケープしないかを識別する方法の詳細については、ヒープ割り当ての排除のセクションを参照してください。

トレーシングガベージコレクション

ガベージコレクションは、メモリを自動的に再利用する様々な方法を指すことがあります。例えば、参照カウントなどです。この文書の文脈では、ガベージコレクションはトレーシングガベージコレクションを指します。これは、ポインタを推移的に辿ることで、使用中の、いわゆるライブオブジェクトを特定します。

これらの用語をより厳密に定義しましょう。

オブジェクトと他のオブジェクトへのポインタは、まとめてオブジェクトグラフを形成します。ライブメモリを特定するために、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コストのこのモデルを考えてみましょう。

  1. GCは物理メモリとCPU時間の2つのリソースのみを対象とする。

  2. GCのメモリコストは、ライブヒープメモリ、マークフェーズ前に割り当てられた新しいヒープメモリ、および前のコストに比例するとしても比較的小さいメタデータのためのスペースから構成されます。

    サイクルNのGCメモリコスト = サイクルN-1からのライブヒープ + 新しいヒープ

    ライブヒープメモリとは、前回のGCサイクルによってライブと判断されたメモリであり、新しいヒープメモリとは、現在のサイクルで割り当てられたメモリであり、サイクルの終わりまでにライブであるかどうかわからないものです。特定の時点でのライブメモリの量は、プログラムの特性であり、GCが直接制御できるものではありません。

  3. GCのCPUコストは、サイクルあたりの固定コストと、ライブヒープのサイズに比例して変動する限界コストとしてモデル化されます。

    サイクルNのGC CPU時間 = サイクルあたりの固定CPU時間コスト + バイトあたりの平均CPU時間コスト * サイクルNで見つかったライブヒープメモリ

    サイクルあたりの固定CPU時間コストには、次のGCサイクル用のデータ構造の初期化など、各サイクルで定数回発生することが含まれます。このコストは通常小さく、完全性のために含まれています。

    GCのCPUコストのほとんどは、マーキングとスキャンであり、これは限界コストによって捕捉されます。マーキングとスキャンの平均コストは、GCの実装だけでなく、プログラムの動作にも依存します。例えば、ポインタが多いほどGCの作業量が増えます。なぜなら、少なくともGCはプログラム内のすべてのポインタを訪れる必要があるからです。リンクリストやツリーのような構造は、GCが並行して走査するのがより困難であり、バイトあたりの平均コストを増加させます。

    このモデルは、スイープコストを無視しています。スイープコストは、デッドなメモリ(割り当てに使用可能にする必要がある)を含む、総ヒープメモリに比例します。Goの現在のGC実装では、スイープはマーキングとスキャンよりもはるかに高速であるため、そのコストは比較して無視できます。

このモデルはシンプルですが効果的です。GCの主要なコストを正確に分類しています。また、ガベージコレクターの総CPUコストは、特定の時間枠における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時間が全体的な期間を増加させることに注意してください。

GOGC

GCは常にCPUとピークメモリのオーバーヘッドを発生させることに注意してください。GOGCを増やすと、CPUオーバーヘッドは減少しますが、ピークメモリはライブヒープサイズに比例して増加します。GOGCを減らすと、ピークメモリ要件は、追加のCPUオーバーヘッドを犠牲にして減少します。

注: グラフはプログラムの完了までの経過時間ではなく、CPU時間を表示します。プログラムが1つのCPUで実行され、そのリソースを完全に利用する場合、これらは同等です。現実世界のプログラムは、マルチコアシステムで実行され、常にCPUを100%利用するわけではありません。これらの場合、GCの経過時間への影響は小さくなります。

注:Go GCの最小合計ヒープサイズは4 MiBであるため、GOGCで設定されたターゲットがそれ以下になる場合、切り上げられます。この可視化はこの詳細を反映しています。

これはもう少し動的で現実的な別の例です。ここでも、アプリケーションはGCなしで完了するまでに10 CPU秒かかりますが、定常状態の割り当てレートは途中で劇的に増加し、ライブヒープサイズは最初のフェーズで少し変動します。この例は、ライブヒープサイズが実際に変化している場合の定常状態がどのように見えるか、そして割り当てレートが高いほどGCサイクルが頻繁になる様子を示しています。

GOGC

メモリ制限

Go 1.19までは、GOGCがGCの動作を変更できる唯一のパラメータでした。トレードオフを設定する方法としては優れていますが、利用可能なメモリが有限であることを考慮していません。ライブヒープサイズが一時的に急増した場合に何が起こるかを考えてみてください。GCはそのライブヒープサイズに比例して合計ヒープサイズを選択するため、通常のケースでGOGC値が高い方がより良いトレードオフを提供したとしても、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によって決定されるピークメモリ(GOGCが100の場合42 MiB)を下回ると、GCはピークメモリを制限内に維持するためにより頻繁に実行されることに注意してください。

一時的なヒープスパイクの前の例に戻りましょう。メモリ制限を設定し、GOGCを上げることで、両方の良い点を享受できます。メモリ制限の違反がなく、リソースの節約も向上します。以下のインタラクティブな可視化を試してみてください。

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ランタイムは誤用による最悪の挙動を軽減するための措置を講じていますが、それでも思慮深く使用することが重要です。以下に、メモリ制限が最も有用で適用可能な場所と、良いことよりも害を及ぼす可能性のある場所に関する助言のコレクションを示します。

レイテンシ

このドキュメントの可視化では、GCが実行されている間、アプリケーションが一時停止しているとモデル化されています。GCの実装にはこのような動作をするものもあり、「ストップ・ザ・ワールド」GCと呼ばれます。

しかし、Go GCは完全にストップ・ザ・ワールドではなく、その作業のほとんどをアプリケーションと並行して行います。これは主にアプリケーションのレイテンシを削減するためです。具体的には、単一の計算単位(例:ウェブリクエスト)の最初から最後までの期間です。これまで、このドキュメントでは主にアプリケーションのスループット(例:1秒あたりに処理されるウェブリクエスト)を検討してきました。GCサイクルセクションの各例が、実行中のプログラムの総CPU期間に焦点を当てていたことに注意してください。しかし、このような期間は、例えばウェブサービスにとってははるかに意味がありません。スループットはウェブサービスにとって依然として重要ですが(つまり、1秒あたりのクエリ数)、個々のリクエストのレイテンシの方がはるかに重要であることもよくあります。

レイテンシの観点から見ると、ストップ・ザ・ワールドGCはマークフェーズとスイープフェーズの両方を実行するのにかなりの時間を要する可能性があり、その間、アプリケーション、そしてウェブサービスの文脈では、処理中のリクエストはそれ以上進行することができません。代わりに、Go GCは、グローバルなアプリケーションの一時停止の長さをヒープのサイズに比例させないようにし、コアなトレーシングアルゴリズムはアプリケーションがアクティブに実行されている間に実行されます。(一時停止はアルゴリズム的にはGOMAXPROCSに強く比例しますが、ほとんどの場合、実行中のゴルーチンを停止するのにかかる時間によって支配されます。)並行して収集することにはコストがかからないわけではありません。実際には、同等のストップ・ザ・ワールドガベージコレクターよりもスループットが低い設計になることがよくあります。ただし、レイテンシが低いことが本質的にスループットが低いことを意味するわけではないことに注意することが重要であり、Goガベージコレクターのパフォーマンスは、レイテンシとスループットの両方で、時間の経過とともに着実に向上しています。

Goの現在のGCの並行処理の性質は、これまでこのドキュメントで議論されたことを何も無効にするものではありません。どの記述もこの設計上の選択に依存していませんでした。GCの頻度は、スループットのためにGCがCPU時間とメモリをトレードオフする主要な方法であり、実際、レイテンシにおいてもこの役割を担っています。これは、GCのコストのほとんどがマークフェーズがアクティブな間に発生するためです。

したがって、重要なことは、GC頻度を減らすことでレイテンシの改善にもつながる可能性があるということです。これは、GOGCやメモリ制限の増加などのチューニングパラメータの変更によるGC頻度の削減だけでなく、最適化ガイドで説明されている最適化にも適用されます。

しかし、レイテンシはスループットよりも理解が複雑であることが多く、コストの集計だけでなく、プログラムの瞬間的な実行の産物だからです。その結果、レイテンシとGC頻度の関係はより間接的になります。以下に、より深く掘り下げたい人向けに、レイテンシの潜在的な原因のリストを示します。

  1. GCがマークフェーズとスイープフェーズの間で移行するときの短いストップ・ザ・ワールドポーズ
  2. マークフェーズ中にGCがCPUリソースの25%を消費するため、スケジューリングの遅延が発生する
  3. 高い割り当て率に応答して、ユーザーのゴルーチンがGCを支援している
  4. GCがマークフェーズ中にポインタの書き込みに追加の作業が必要となる、および
  5. 実行中のゴルーチンは、そのルートがスキャンされるために一時停止されなければなりません。

これらのレイテンシーの原因は、追加作業を必要とするポインタの書き込みを除いて、実行トレースで確認できます。

ファイナライザ、クリーンアップ、弱いポインタ

ガベージコレクションは、有限のメモリしか使用しないのに、無限のメモリという錯覚を提供します。メモリは割り当てられますが、明示的に解放されることはありません。これにより、素のCのような手動メモリ管理と比較して、よりシンプルなAPIと並行アルゴリズムが可能になります。(手動メモリ管理を行う一部の言語は、「スマートポインタ」やコンパイル時の所有権追跡などの代替アプローチを使用してオブジェクトが解放されるようにしますが、これらの機能はこれらの言語のAPI設計規則に深く組み込まれています。)

ライブなオブジェクト、つまりグローバル変数や何らかのゴルーチンでの計算から到達可能なオブジェクトだけが、プログラムの動作に影響を与えることができます。オブジェクトが到達不能(「デッド」)になった後は、いつでも安全にGCによって再利用できます。これにより、今日のGoが使用しているトレーシング設計のような、非常に多様なGC設計が可能になります。オブジェクトの死は、言語レベルで観測可能なイベントではありません。

しかし、Goのランタイムライブラリは、その幻想を打ち破る3つの機能を提供します。クリーンアップ弱いポインタファイナライザです。これらの各機能は、オブジェクトの死を観測して反応する何らかの方法を提供し、ファイナライザの場合、それを逆転させることさえできます。これはもちろんGoプログラムを複雑にし、GC実装に追加の負担をかけます。しかし、これらの機能は様々な状況で有用であるため存在し、Goプログラムはそれらを使用し、常に恩恵を受けています。

各機能の詳細については、それぞれのパッケージドキュメント(runtime.AddCleanupweak.Pointerruntime.SetFinalizer)を参照してください。以下に、これらの機能を使用するための一般的なアドバイス、各機能で遭遇する可能性のある一般的な問題の概要、およびこれらの機能の使用をテストするためのアドバイスを示します。

一般的なアドバイス

よくあるクリーンアップの問題

弱いポインタでよくある問題

よくあるファイナライザの問題

オブジェクトの死をテストする

これらの機能を使用する場合、それらを使用するコードのテストを書くのは、時にはトリッキーなことがあります。これらの機能を使用するコードに対して堅牢なテストを書くためのヒントをいくつか紹介します。

追加リソース

上記の情報は正確ですが、Go GCの設計におけるコストとトレードオフを完全に理解するための詳細が不足しています。詳細については、以下の追加リソースを参照してください。

仮想メモリに関する注意

このガイドでは、主にGCの物理メモリ使用量に焦点を当ててきましたが、定期的に出てくる疑問は、それが正確に何を意味するのか、そして仮想メモリ(通常、topのようなプログラムでは「VSS」として表示される)と比較してどうかということです。

物理メモリとは、ほとんどのコンピューターに搭載されている実際の物理RAMチップに収容されているメモリです。仮想メモリは、プログラムを互いから隔離するためにオペレーティングシステムが提供する物理メモリの抽象化です。また、プログラムが物理アドレスにまったくマッピングされない仮想アドレス空間を予約することも一般的に許容されます。

仮想メモリは単にオペレーティングシステムによって維持されるマッピングであるため、物理メモリにマッピングされない大きな仮想メモリ予約を行うことは通常非常に安価です。

Goランタイムは、通常、仮想メモリのコストに関するこの見解にいくつかの方法で依存しています。

結果として、topの「VSS」のような仮想メモリの指標は、Goプログラムのメモリフットプリントを理解する上で通常あまり有用ではありません。代わりに、「RSS」や類似の測定値に注目してください。これらは物理メモリ使用量をより直接的に反映します。

最適化ガイド

コストの特定

GoアプリケーションがGCとどのように相互作用するかを最適化しようとする前に、まずGCがそもそも大きなコストになっているかどうかを特定することが重要です。

Goエコシステムには、コストを特定し、Goアプリケーションを最適化するための多くのツールが提供されています。これらのツールの概要については、診断ガイドを参照してください。ここでは、これらのツールのサブセットと、GCの影響と動作を理解するためにそれらを適用する合理的な順序に焦点を当てます。

  1. CPUプロファイル

    良い出発点はCPUプロファイリングです。CPUプロファイリングは、CPU時間がどこに費やされているかの概要を提供しますが、慣れない目には、GCが特定のアプリケーションで果たす役割の大きさを特定するのは難しいかもしれません。幸いなことに、GCがどのように適合するかを理解するには、主に`runtime`パッケージ内の異なる関数の意味を知ることに尽きます。以下は、CPUプロファイルを解釈するのに役立つこれらの関数の有用なサブセットです。

    以下の関数はリーフ関数ではないため、topコマンドでpprofツールが提供するデフォルトには表示されない場合があることに注意してください。代わりに、top -cumコマンドを使用するか、これらの関数に直接listコマンドを使用し、累積パーセント列に注目してください。

    • runtime.gcBgMarkWorker: バックグラウンドマークワーカーゴルーチンのエントリポイント。ここに費やされる時間は、GC頻度、オブジェクトグラフの複雑さ、およびサイズに比例します。これは、アプリケーションがマーキングとスキャンに費やす時間のベースラインを表します。

      これらのゴルーチン内では、ワーカーのタイプを示すruntime.gcDrainMarkWorkerDedicatedruntime.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の呼び出しツリーに含まれているため、これも膨らませることに注意してください。

  2. 実行トレース

    CPUプロファイルは、時間がどこで費やされているかを全体的に特定するのに優れていますが、より微妙でまれな、または具体的にレイテンシに関連するパフォーマンスコストを示すのにはあまり役立ちません。一方、実行トレースは、Goプログラムの短い実行期間を豊かで詳細な視点から提供します。Go GCに関連するさまざまなイベントを含み、特定の実行パスと、アプリケーションがGo GCとどのように相互作用するかを直接観察できます。追跡されるすべてのGCイベントは、トレースビューアでそのようにラベル付けされています。

    実行トレースを開始する方法については、runtime/traceパッケージのドキュメントを参照してください。

  3. 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つの方法で分類できます。

ヒープメモリのこれらの異なるビュー間の切り替えは、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」コマンドを実行することもできます。)表示されるアノテーションを制御するには、この設定を使用します。

  1. ui.diagnostic.annotationsescapeを含むように設定して、エスケープ解析のオーバーレイを有効にします。

最後に、Goコンパイラはこの情報を機械可読な(JSON)形式で提供しており、追加のカスタムツールを構築するために使用できます。詳細については、Goソースコードのドキュメントを参照してください。

実装固有の最適化

Go GCはライブメモリの特性に敏感です。なぜなら、オブジェクトとポインタの複雑なグラフは並列処理を制限し、GCの作業を増やすからです。結果として、GCは特定の一般的な構造に対していくつかの最適化を含んでいます。パフォーマンス最適化に最も直接的に役立つものを以下に示します。

注:以下の最適化を適用すると、意図が不明確になるため、コードの可読性が低下する可能性があり、Goのリリース間で維持されない可能性があります。これらの最適化は、最も重要な場所にのみ適用することを推奨します。そのような場所は、コストを特定するセクションに記載されているツールを使用して特定できます。

さらに、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プログラム用に以下の追加設定をお勧めします。

付録

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がスキャンする必要があるメモリのより近い近似として、スキャン可能なライブヒープのみを含めるべきだと主張できますが、これはスキャン可能なライブヒープが非常に少ないがライブヒープは他に大きい場合に、退行的な動作につながります。