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の値がヒープにエスケープする必要がある理由はたくさんあります。理由の1つは、そのサイズが動的に決定される可能性があることです。たとえば、初期サイズが定数ではなく変数によって決定されるスライスのバッキング配列を考えてみてください。また、ヒープへのエスケープは推移的でなければならないことに注意してください。Go値への参照が、すでにエスケープすることが決定されている別のGo値に書き込まれている場合、その値もエスケープする必要があります。

Go値がエスケープするかどうかは、それが使用されるコンテキストとGoコンパイラーのエスケープ分析アルゴリズムの関数です。値を正確にいつエスケープするかを列挙しようとするのは、壊れやすく困難です。アルゴリズム自体がかなり洗練されており、Goリリース間で変更されるためです。どの値がエスケープするか、どの値がエスケープしないかを識別する方法の詳細については、ヒープ割り当ての削減のセクションを参照してください。

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

ガベージコレクションは、参照カウントなど、自動的にメモリを再利用するさまざまな方法を指す場合があります。このドキュメントのコンテキストでは、ガベージコレクションは、ポインターを推移的にたどることによって、使用中の、いわゆるライブオブジェクトを識別するトレースガベージコレクションを指します。

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

オブジェクトと他のオブジェクトへのポインターを合わせて、オブジェクトグラフを形成します。ライブメモリを識別するために、GCはプログラムのルート、つまりプログラムによって確実に使用されているオブジェクトを識別するポインターから始まるオブジェクトグラフをたどります。ルートの2つの例は、ローカル変数とグローバル変数です。オブジェクトグラフをたどるプロセスは、スキャンと呼ばれます。

この基本的なアルゴリズムは、すべてのトレースGCに共通です。トレースGCが異なるのは、メモリがライブであると発見した後に何をするかです。GoのGCはマークスイープ手法を使用します。つまり、進行状況を追跡するために、GCは遭遇した値をライブとしてマークします。トレースが完了すると、GCはヒープ内のすべてのメモリをウォークし、マークされていないすべてのメモリを割り当てに使用できるようにします。このプロセスはスイープと呼ばれます。

よく知られている別の手法として、オブジェクトをメモリの新しい部分に実際に移動させ、後でアプリケーションのすべてのポインターを更新するために使用される転送ポインターを残すという方法があります。この方法でオブジェクトを移動するGCを移動GCと呼びます。Goには非移動GCがあります。

GCサイクル

Go GCはマークスイープGCであるため、大きく分けて2つのフェーズで動作します。マークフェーズとスイープフェーズです。このステートメントはトートロジーのように見えるかもしれませんが、重要な洞察が含まれています。まだスキャンされていないポインターがオブジェクトをライブに保っている可能性があるため、すべてのメモリがトレースされるまで、割り当てのためにメモリを解放することはできません。その結果、スイープの行為はマークの行為から完全に分離する必要があります。さらに、GCに関連する作業がない場合、GCはまったくアクティブにならない場合もあります。GCは、スイープ、オフ、マークという3つのフェーズをGCサイクルとして知られるもので継続的にローテーションします。このドキュメントの目的上、GCサイクルはスイープから始まり、オフになり、マークになることを検討してください。

次のいくつかのセクションでは、ユーザーが自分の利益のためにGCパラメーターを調整するのを支援するために、GCのコストに関する直感を構築することに焦点を当てます。

コストの理解

GCは、さらに複雑なシステム上に構築された、本質的に複雑なソフトウェアです。GCを理解し、その動作を調整しようとすると、詳細に埋もれてしまうのは簡単です。このセクションは、Go GCのコストについて推論し、パラメーターを調整するためのフレームワークを提供することを目的としています。

まず、3つの単純な公理に基づいたGCコストのこのモデルを検討してください。

  1. GCには、CPU時間と物理メモリの2つのリソースのみが含まれます。

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

    注:ライブヒープメモリは、前のGCサイクルでライブであると判断されたメモリであり、新しいヒープメモリは、現在のサイクルで割り当てられたメモリであり、最終的にライブになる場合もならない場合もあります。

  3. GCのCPUコストは、サイクルごとの固定コストと、ライブヒープのサイズに比例してスケーリングする限界コストとしてモデル化されます。

    注:漸近的に言えば、スイープは、ライブではない(つまり「デッド」)と判断されたメモリを含む、ヒープ全体のサイズに比例する作業を実行する必要があるため、マークとスキャンよりも悪くスケーリングします。ただし、現在の実装では、スイープはマークとスキャンよりもはるかに高速であるため、この議論では関連するコストを無視できます。

このモデルはシンプルですが効果的です。GCの支配的なコストを正確に分類します。ただし、このモデルは、これらのコストの大きさや、それらがどのように相互作用するかについては何も述べていません。それをモデル化するには、ここから定常状態と呼ばれる次の状況を考えてください。

注: 定常状態は不自然に見えるかもしれませんが、これは一定のワークロード下でのアプリケーションの動作を代表するものです。当然ながら、アプリケーションの実行中にもワークロードは変化する可能性がありますが、一般的にアプリケーションの動作は、いくつかの一時的な動作を挟んで、これらの定常状態が連なったものと見なすことができます。

注: 定常状態はライブヒープに関する仮定をしていません。ライブヒープは、後続のGCサイクルごとに大きくなることもあれば、小さくなることも、または同じままになることもあります。しかし、これらの状況すべてを以下の説明に含めようとすると、冗長になり、あまり実用的ではないため、このガイドではライブヒープが一定のままである例に焦点を当てます。GOGCセクションでは、ライブヒープが一定でないシナリオについて、さらに詳しく説明します。

ライブヒープサイズが一定である定常状態では、GCが同じ時間が経過した後に実行される限り、すべてのGCサイクルはコストモデルにおいて同一に見えるでしょう。これは、一定の時間内に、アプリケーションによる一定の割り当てレートで、一定量の新しいヒープメモリが割り当てられるためです。したがって、ライブヒープサイズが一定で、新しいヒープメモリも一定である場合、メモリ使用量は常に同じになります。そして、ライブヒープのサイズが同じであるため、GCの限界的なCPUコストは同じになり、固定コストは一定の間隔で発生します。

ここで、GCが実行されるタイミングを遅くシフトさせた場合を考えてみましょう。すると、より多くのメモリが割り当てられますが、各GCサイクルは依然として同じCPUコストを発生させます。しかし、他の固定時間枠では、完了するGCサイクル数が減り、全体的なCPUコストが低くなります。GCが早く開始した場合、反対のことが言えます。つまり、割り当てられるメモリが少なくなり、CPUコストがより頻繁に発生します。

この状況は、GCが実行する頻度によって制御される、GCがCPU時間とメモリの間でトレードオフを行うという基本的な状況を表しています。言い換えれば、このトレードオフは完全にGC頻度によって定義されます。

もう1つ定義すべき詳細があります。それは、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サイクルがそのターゲットで正確に終了しない理由はいくつかあります。1つは、十分な大きさのヒープ割り当てが、ターゲットを超える可能性があるということです。ただし、このガイドでこれまで使用してきたGCモデルを超えるGC実装には、他の理由が現れます。詳細については、レイテンシーセクションを参照してください。ただし、完全な詳細は追加のリソースに記載されています。

GOGCは、すべてのGoプログラムが認識するGOGC環境変数、または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のメモリが利用可能なコンテナで実行されている場合、他のGCサイクルで余分なメモリを活用できるとしても、GOGCは100を超えて増やすことはできません。さらに、一部のアプリケーションでは、これらの過渡的なピークはまれであり予測が困難なため、時折、避けられず、コストのかかるメモリ不足の状態が発生する可能性があります。

そのため、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は完全なストップザワールドではなく、アプリケーションと同時にほとんどの作業を実行します。これは主に、アプリケーションのレイテンシを削減するためです。具体的には、単一の計算ユニット(Webリクエストなど)のエンドツーエンドの期間です。これまで、このドキュメントでは主にアプリケーションのスループット(1秒あたりに処理されるWebリクエストなど)について検討してきました。GCサイクルセクションの各例は、実行中のプログラムのCPU総期間に焦点を当てていたことに注意してください。ただし、このような期間は、たとえばWebサービスの場合にはあまり意味がありません。スループットはWebサービスにとっても依然として重要ですが(つまり、1秒あたりのクエリ数)、多くの場合、個々のリクエストのレイテンシがさらに重要になります。

レイテンシの観点から見ると、ストップザワールドGCでは、マークフェーズとスイープフェーズの両方を実行するのにかなりの時間が必要になる可能性があり、その間、アプリケーション、およびWebサービスのコンテキストでは、実行中のリクエストはそれ以上進行できません。代わりに、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. ルートをスキャンするために、実行中のゴルーチンを中断する必要がある。

これらのレイテンシ発生源は、ポインタ書き込みに追加の作業が必要となる場合を除き、実行トレースで確認できます。

追加リソース

上記の情報は正確ですが、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プロファイルを解釈するためのこれらの関数の便利なサブセットです。

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

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

      注: これらのゴルーチン内では、ワーカタイプを示すruntime.gcDrainMarkWorkerDedicatedruntime.gcDrainMarkWorkerFractional、およびruntime.gcDrainMarkWorkerIdleへの呼び出しが見られます。大部分がアイドル状態のGoアプリケーションでは、Go GCは追加の(アイドル)CPUリソースを使用してジョブをより高速に完了します。これはruntime.gcDrainMarkWorkerIdleシンボルで示されます。その結果、ここでの時間は、Go GCが空いていると考えるCPUサンプルの大部分を表す場合があります。アプリケーションがよりアクティブになると、アイドルワーカでの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]

この情報は、VS Code のオーバーレイとしても視覚化できます。このオーバーレイは、VS Code Go プラグインの設定で構成および有効化できます。

  1. ui.codelenses 設定で gc_details を含めるように設定してください。
  2. ui.diagnostic.annotationsescape を含むように設定することで、エスケープ解析のオーバーレイを有効にします。

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

実装固有の最適化

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

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

さらに、GC は、検出したほぼすべてのポインタと相互作用する必要があるため、たとえばポインタの代わりにスライスへのインデックスを使用すると、GC コストの削減に役立ちます。

Linux Transparent Huge Pages (THP)

プログラムがメモリにアクセスすると、CPU は、使用する仮想メモリアドレスを、アクセスしようとしているデータを参照する物理メモリのアドレスに変換する必要があります。これを行うために、CPU は「ページテーブル」を参照します。ページテーブルは、オペレーティングシステムによって管理される、仮想メモリから物理メモリへのマッピングを表すデータ構造です。ページテーブル内の各エントリは、ページと呼ばれる物理メモリの不可分なブロックを表します。これが名前の由来です。

Transparent Huge Pages (THP) は、連続した仮想メモリ領域をサポートする物理メモリのページを、巨大ページと呼ばれるより大きなメモリブロックに透過的に置き換える Linux の機能です。より大きなブロックを使用することで、同じメモリ領域を表すために必要なページテーブルエントリが少なくなり、ページテーブルの検索時間が短縮されます。ただし、より大きなブロックは、巨大ページのごく一部しかシステムで使用されない場合、より多くの無駄が発生することを意味します。

本番環境で Go プログラムを実行する場合、Linux で Transparent Huge Pages を有効にすると、メモリ使用量の増加を犠牲にして、スループットとレイテンシを向上させることができます。ヒープが小さいアプリケーションは、THP からのメリットが得られない傾向があり、かなりの量の追加メモリを使用する可能性があります (最大 50%)。ただし、ヒープが大きいアプリケーション (1 GiB 以上) は、追加のメモリオーバーヘッド (1 ~ 2% 以下) がそれほど多くなく、かなりのメリット (最大 10% のスループット) を得る傾向があります。いずれの場合も、THP の設定を認識しておくと役立ち、常に実験することをお勧めします。

/sys/kernel/mm/transparent_hugepage/enabled を変更することで、Linux 環境で Transparent Huge Pages を有効または無効にできます。詳細については、公式の 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 がスキャンする必要があるメモリのより近い近似値として、スキャン可能なライブヒープのみを含めるべきであると考えられますが、スキャン可能なライブヒープが非常に少量であるが、ライブヒープがそれ以外の場合に大きい場合に、縮退した動作につながります。