Go ガーベッジコレクタのガイド 翻訳

以下のページをdeeplにかけたもの

tip.golang.org

はじめに

このガイドは、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値のメモリを確保することを「スタック確保」と呼びますが、これはその領域がgoroutineのスタックに格納されるからです。

Goコンパイラがその寿命を判断できないため、この方法でメモリを確保できないGo値は、ヒープにエスケープされると言われています。「ヒープとは、囲碁の値をどこかに置かなければならないときのための、メモリ割り当ての受け皿のようなものだと思えばよいでしょう。ヒープにメモリを割り当てる行為は、一般に「動的メモリ割り当て」と呼ばれます。これは、コンパイラとランタイムの両方が、このメモリがどのように使用され、いつクリーンアップされるかについて、ほとんど仮定できないからです。そこでGCの出番です。GCは、動的なメモリ割り当てを明確に識別してクリーンアップするシステムです。

Goの値がヒープにエスケープされなければならない理由はたくさんあります。そのひとつは、サイズが動的に決定されるからです。たとえば、スライスのバックアレイの初期サイズが定数ではなく、変数によって決定される場合を考えてみましょう。ヒープへの退避は推移的でなければならないことに注意してください。あるGo値への参照が、退避がすでに決定されている別のGo値に書き込まれた場合、その値も退避しなければなりません。

Goの値がエスケープされるかどうかは、その値が使用されるコンテキストとGoコンパイラエスケープ分析アルゴリズムによるものです。値がエスケープされるタイミングを正確に列挙するのはもろく、困難です。アルゴリズム自体はかなり高度で、Goのリリースごとに変化します。どの値がエスケープされ、どの値がエスケープされないかを識別する方法の詳細については、ヒープ割り当ての除去のセクションを参照してください。

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

ガベージコレクションは、メモリを自動的にリサイクルする多くの異なる方法、例えば参照カウントを指すことがあります。この文書では、ガベージコレクションは、ポインターを推移的に追うことによって使用中の、いわゆるライブのオブジェクトを識別する、トレースガベージコレクションを指します。

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

  • オブジェクト-1つまたは複数のGo値を含む、動的に割り当てられたメモリの一部です。
  • ポインター - オブジェクト内の任意の値を参照するメモリアドレス。これには当然、*T 形式の Go 値が含まれますが、ビルトイン Go 値の一部も含まれます。文字列、スライス、チャンネル、マップ、およびインターフェイス値はすべて、GC が追跡する必要があるメモリ アドレスを含んでいます。

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

この基本的なアルゴリズムは、すべてのトレースGCに共通です。トレースGCが異なる点は、メモリが生きていることを発見したときに何をするかです。GoのGCはマークスイープ技術を使用します。これは、その進捗を追跡するために、GCはまた、それがライブとして遭遇する値をマークすることを意味します。トレースが完了すると、GCはヒープ内のすべてのメモリ上を歩いて、割り当てのために利用可能なマークされていないすべてのメモリを作成します。このプロセスは、スイープと呼ばれています。

あなたがよく知る代替技術の1つは、実際にメモリの新しい部分にオブジェクトを移動し、後ですべてのアプリケーションのポインタを更新するために使用される転送ポインタを残しておくことです。このようにオブジェクトを移動させる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は、2つのリソースのみを含む。CPU 時間と物理メモリです。
  2. GCのメモリコストは、ライブヒープメモリ、マークフェーズの前に割り当てられた新しいヒープメモリ、および以前のコストに比例するとしても、比較的に小さいメタデータのためのスペースで構成されています。(注): ライブヒープメモリは、前のGCサイクルによってライブであると決定されたメモリであり、新しいヒープメモリは現在のサイクルで割り当てられた任意のメモリであり、それは終了までにライブであるかどうかわからない。
  3. GC の CPU コストは、サイクルごとの固定コストと、ライブヒープ のサイズに比例してスケールする限界コストとしてモデル化されます。 (注):漸近的に言えば、掃引はマーキングやスキャンよりもスケールが悪く、生きていない(つまり「死んでいる」)と判断されたメモリを含むヒープ全体のサイズに比例して作業を実行しなければならないからです。しかし、現在の実装では、掃引はマーキングやスキャンよりも非常に高速であるため、この議論ではその関連コストを無視することができます。

このモデルは単純ですが効果的で、GC の支配的なコストを正確に分類しています。しかし、このモデルは、これらのコストの大きさについて、また、それらがどのように相互作用するかについて、何も述べていません。それをモデル化するために、以下の状況を考えてみましょう(以下、定常状態と呼びます)。

  • アプリケーションが新しいメモリを割り当てる速度(1秒あたりのバイト数)は一定です。

(注意):この割り当て速度は、新しいメモリがライブであるかどうかとは完全に別であることを理解することが重要です。どれもがライブである可能性もあるし、すべてがライブである可能性もあるし、一部がライブである可能性もあります。(さらに、古いヒープメモリも死ぬ可能性があるので、そのメモリが生きていれば生きているヒープサイズが大きくなるとは限りません)。もっと具体的に言うと、あるWebサービスが、処理する各リクエストに2MiBの総ヒープメモリを割り当てているとします。リクエストが処理されている間、2MiBのうちせいぜい512KiBが生き続け、サービスがリクエストの処理を終えると、そのメモリはすべて死んでしまいます。さて、簡単のために、各リクエストがエンドツーエンドで処理するのに1秒程度かかるとします。そして、安定したリクエストのストリーム、例えば毎秒100リクエストの場合、200MiB/sのアロケーションレートと50MiBのピークライブヒープが発生します。

  • アプリケーションのオブジェクトグラフは、毎回ほぼ同じに見えます(オブジェクトは同じような大きさで、ポインタの数はほぼ一定、グラフの最大深度はほぼ一定です)。

もう一つの考え方は、GC限界費用が一定であることです。

注:定常状態は作為的に見えるかもしれませんが、ある一定の作業負荷の下でのアプリケーションの挙動を代表しています。もちろん、アプリケーションの実行中にも作業負荷は変化しますが、一般的にアプリケーションの動作は、この定常状態の束をつなぎ合わせて、その間にいくつかの過渡的な動作を挟んだようなものになります。

注:定常状態は、ライブヒープについて何も仮定していません。ヒープはGCサイクルのたびに増大するかもしれませんし、縮小するかもしれませんし、同じかもしれません。しかし、この後の説明でこれらの状況をすべて網羅しようとすると面倒であり、あまり説明的でないため、このガイドではライブヒープが一定である例に焦点を当てます。GOGCのセクションでは、ライブヒープが一定でないシナリオをもう少し詳しく説明します。

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

ここで、GCが実行されるポイントを時間的に遅くずらしたとしたらどうでしょう。その場合、より多くのメモリが割り当てられますが、各 GC サイクルはまだ同じ CPU コストを発生させます。しかし、他の固定された時間枠では、より少ないGCサイクルが終了し、結果として全体的なCPUコストが低くなります。もしGCが時間的に早く開始することを決めたら、その逆で、より少ないメモリが割り当てられ、より頻繁にCPUコストが発生することになります。

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

もう一つの詳細は定義されるままであり、それはGCが開始することを決定すべき時です。これは、特定の定常状態におけるGCの頻度を直接設定し、トレードオフを定義することに注意してください。Goでは、GCがいつ開始されるべきかを決定することは、ユーザーが制御できる主なパラメータです。

GOGC

高いレベルでは、GOGC は GC の CPU とメモリの間のトレードオフを決定します。

それは、各GCサイクルの後にターゲットヒープサイズ、次のサイクルでの総ヒープサイズの目標値を決定することによって動作します。GCの目標は、ヒープサイズの合計がターゲットヒープサイズを超える前に収集サイクルを終了することです。総ヒープサイズは、前のサイクルの終了時のライブヒープサイズと、前のサイクル以降にアプリケーションによって割り当てられた任意の新しいヒープメモリとして定義されます。一方、ターゲットヒープメモリは次のように定義される。

ターゲットヒープメモリ = ライブヒープ + (ライブヒープ + GCルート) * GOGC / 100

例として、ライブヒープが8MiB、ゴルーチンスタックが1MiB、グローバル変数へのポインタが1MiBのGoプログラムを考えてみましょう。GOGC値が100の場合、次のGCが実行されるまでに割り当てられる新しいメモリの量は10MiB、つまり10MiBの作業の100%となり、ヒープの総フットプリントは18MiBとなります。GOGC値が50の場合は、50%、つまり5MiBになります。GOGCの値が200の場合は、200%、つまり20MiBになります。

注意: GOGC には、Go 1.18 以降、ルート セットのみが含まれます。以前は、ライブヒープのみをカウントしていました。多くの場合、goroutineスタックのメモリ量は非常に小さく、ライブヒープ・サイズはGC作業の他のすべての原因を支配していますが、プログラムに何十万ものgoroutineがある場合、GCは不適切な判断をしていました。

ヒープ・ターゲットはGCの頻度を制御します。ターゲットが大きければ大きいほど、GCは別のマーク・フェーズの開始をより長く待つことができ、その逆もまた然りです。正確な計算式は推定に役立ちますが、GOGCをその基本的な目的、すなわちGCのCPUとメモリのトレードオフのポイントを選ぶパラメータという観点から考えるのが最善です。重要なことは、GOGC を 2 倍にするとヒープメモリのオーバーヘッドが 2 倍になり、GC の CPU コストが約半分になり、その逆もまた然りということです。(理由についての完全な説明を見るには、付録を参照してください)。

注:目標ヒープサイズは単なる目標であり、GCサイクルがその目標ですぐには終了しないかもしれないいくつかの理由があります。1つは、十分に大きなヒープ割り当てが単にターゲットを超えることができます。しかし、他の理由は、このガイドがこれまで使用してきたGCモデルを超えるGC実装に現れます。いくつかの詳細については、レイテンシのセクションを参照してください、しかし、完全な詳細は、追加のリソースで見つけることができるかもしれません。

GOGCはGOGC環境変数(すべてのGoプログラムが認識する)、またはruntime/debugパッケージのSetGCPercent APIを通して設定されるかもしれません。

GOGCは、GOGC=offを設定するか、SetGCPercent(-1)を呼び出すことによって、GCを完全にオフにするために使用することもできます(メモリ制限が適用されない場合)ことに留意してください。概念的には、GCがトリガされる前の新しいメモリの量は無制限であるため、この設定はGOGCを無限大の値に設定することと同じです。

これまで説明したことをよりよく理解するために、先に説明したGCコスト・モデルに基づいて作られた以下のインタラクティブな視覚化を試してみてください。この可視化では、GC以外の作業に10秒のCPU時間を要するあるプログラムの実行を描いています。最初の1秒間は、定常状態に落ち着く前に、何らかの初期化ステップ(ライブヒープを成長させる)を実行します。このアプリケーションは、一度に20MiBをライブで、合計200MiBを割り当てます。これは、完了する唯一の関連するGC作業がライブヒープから来ること、および、(非現実的な)アプリケーションが追加のメモリを使用しないことを想定しています。

スライダーを使用して GOGC の値を調整し、総時間と GC オーバーヘッドの観点からアプリケーションがどのように応答するかを確認します。各GCサイクルは、新しいヒープがゼロに低下する間に終了します。新しいヒープがゼロになる間にかかる時間は、サイクル N のマークフェイズとサイクル N+1 のスイープフェイズを合わせた時間です。この可視化(およびこのガイドのすべての可視化)は、GC の実行中にアプリケーションが一時停止していると仮定しているので、GC の CPU コストは新しいヒープ・メモリがゼロになるまでの時間によって完全に表されることに注意してください。これは視覚化を簡単にするためだけで、同じ直感がまだ適用されます。X 軸は常にプログラムの完全な CPU 時間を表示するようにシフトします。GCによって使用される追加のCPU時間は、全体の時間を増加させることに注意してください。

[https://tip.golang.org/doc/gc-guide] を参照

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

注:このグラフは、プログラムを完了するための壁時計時間ではなく、CPU時間を表示しています。プログラムが1CPUで動作し、そのリソースをフルに活用する場合、これらは等価である。実際のプログラムは、マルチコアシステムで実行され、常にCPUを100%利用するわけではありません。このような場合、GCの壁時間の影響は小さくなります。

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

もう1つ、もう少しダイナミックで現実的な例を示します。この例でも、アプリケーションは GC なしで 10CPU 秒で完了しますが、定常状態の割り当て率は途中で劇的に増加し、ライブヒープ サイズは最初のフェーズで少しシフトします。この例は、ライブヒープサイズが実際に変化しているときに定常状態がどのように見えるか、そして、高い割り当て率はより頻繁なGCサイクルにつながるかを実証しています。

[https://tip.golang.org/doc/gc-guide] を参照

メモリの上限

Go 1.19 までは、GOGC は GC の動作を変更するために使用できる唯一のパラメータでした。これはトレードオフを設定する方法としては素晴らしいのですが、使用可能なメモリが有限であることは考慮されていません。ライブヒープサイズに一時的なスパイクがあるときに何が起こるかを考えてみましょう。GCはそのライブヒープサイズに比例する総ヒープサイズを選択するので、GOGCは、たとえ通常のケースでは高いGOGC値がより良いトレードオフを提供しても、ピークライブヒープサイズ用にそのように構成されなければなりません。

以下の図は、この過渡的なヒープスパイクの状況を示しています。

[https://tip.golang.org/doc/gc-guide] を参照

この例の作業負荷が、利用可能なメモリが 60 MiB を少し超えるコンテナで実行されている場合、残りの GC サイクルがその余分なメモリを使用するために利用可能なメモリを持っているにもかかわらず、GOGC は 100 を超えて増加することができない。さらに、アプリケーションによっては、このような過渡的なピークが稀に発生し、予測が困難なため、時折、避けられない、コストのかかるメモリ不足の状態になる可能性があります。

そのため、Go は 1.19 リリースで、ランタイムメモリ制限を設定するサポートを追加しました。メモリ制限は、すべての 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ランタイムのオーバーヘッドを10MiB追加して、メモリ制限を調整できるようにしています。GOGCとメモリ制限の両方を変更してみて、何が起こるか見てみてください。

[https://tip.golang.org/doc/gc-guide] を参照

メモリ制限をGOGCで決定されるピークメモリ(GOGCが100の場合は42MiB)より低くすると、ピークメモリを制限内に保つためにGCがより頻繁に実行されることに注意してください。

先ほどの一時的なヒープスパイクの例に戻ると、メモリ制限を設定し、GOGCを上げることで、メモリ制限の違反がなく、リソースの経済性が向上するという、両方の利点を手に入れることができるのです。以下のインタラクティブなビジュアライゼーションをお試しください。

[https://tip.golang.org/doc/gc-guide] を参照

GOGC とメモリ制限のある値では、ピークメモリ使用量はメモリ制限の値で停止しますが、プログラムの残りの実行は GOGC が設定したヒープサイズの合計のルールに従います。

GOGCをオフに設定しても、メモリ制限は守られるのです。実際、この特定の構成は、あるメモリ制限を維持するために必要な最小限のGC頻度を設定するため、リソース経済の最大化を意味します。この場合、プログラムのすべての実行は、メモリ制限を満たすためにヒープサイズを増加させます。

さて、メモリ制限は明らかに強力なツールですが、メモリ制限の使用にはコストがかかるため、確かにGOGCの有用性が無効となるわけではありません。

ライブヒープが十分に大きくなり、総メモリ使用量がメモリ制限に近づいた場合を考えてみましょう。上の定常状態の可視化で、GOGCをオフにしてから、ゆっくりとメモリ制限をさらに下げてみて、何が起こるかを見てみましょう。不可能なメモリ制限を維持するためにGCが常に実行されているため、アプリケーションが要する総時間が無制限に増加し始めることに注意してください。

このように、常にGCが巡回しているためにプログラムが合理的に進行しない状況を、スラッシングと呼びます。これは事実上プログラムを停止させるので、特に危険です。さらに悪いことに、GOGCで回避しようとしたのと全く同じ状況が発生する可能性があります:十分に大きな一時的ヒープスパイクは、プログラムを無制限にストールさせる可能性があります 過渡的ヒープスパイクの可視化でメモリ制限(約30MiB以下)を減らしてみて、最悪の動作が特にヒープスパイクで始まることに気づいてください。

多くの場合、無期限のストールはメモリ不足の状態よりも悪化し、より高速な障害につながる傾向があります。

このため、メモリの上限はソフトに定義されています。Goランタイムは、すべての状況下でこのメモリ制限を維持することを保証するものではなく、ある合理的な量の努力を約束するだけです。このメモリ制限の緩和は、GC に逃げ道を与えるので、スラッシング動作を回避するのに重要です:メモリ使用量を制限値より多くして、GC で多くの時間を費やすことを回避します。

これは内部的にどのように動作するかというと、GC はある時間ウィンドウで使用できる CPU 時間の量に上限を設定します(CPU 使用の非常に短い過渡スパイクのためのいくつかのヒステリシスを持つ)。この制限は現在、2 * GOMAXPROCS CPU-秒のウィンドウで、およそ50%に設定されています。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プログラムが何らかのサブプロセスを呼び出し、その呼び出し側が実行する間はブロックする)、必然的に両方のプログラムがより多くのメモリを必要とするので、結果はあまり信頼できないものになるでしょう。Goプログラムが必要としないときには、より少ないメモリを使用するようにすれば、全体としてより信頼性の高い結果を得ることができます。このアドバイスは、1 台のマシンで実行されているコンテナのメモリ制限の合計が、そのマシンで利用可能な実際の物理メモリを超える可能性のあるオーバーコミット状況にも当てはまります。

特に、プログラムのメモリ使用量が入力に比例する場合、自分がコントロールできない実行環境にデプロイするときは、メモリ制限を使わないでください。

良い例は、CLIツールやデスクトップアプリケーションです。どのような入力があるか、あるいはシステムで利用可能なメモリ量が不明なときに、プログラムにメモリ制限をかけると、混乱したクラッシュやパフォーマンスの低下を招きます。さらに、高度なエンドユーザーが望めば、いつでもメモリ制限を設定することができます。

プログラムがすでに環境のメモリ制限に近づいているときに、メモリ不足の状態を回避するためにメモリ制限を設定しないこと。

これは事実上、メモリ不足のリスクを深刻なアプリケーションのスローダウンのリスクに置き換えるもので、スラッシングを軽減するためにGoが努力しても、有利な取引とはならないことが多い。このような場合、環境のメモリ制限を増やすか(そしてメモリ制限を設定する可能性もあります)、GOGCを減らす(これはスラッシングの緩和よりもずっときれいなトレードオフを提供します)方がはるかに効果的でしょう。

## レイテンシー

この文書では、GC の実行中にアプリケーションが一時停止するように視覚化されています。このような動作をする GC の実装は存在し、それらは「Stop-the-World」GC と呼ばれ ています。

しかし、Go GCは完全にストップ・ザ・ワールドではなく、ほとんどの作業をアプリケーションと同時進行で行います。これは主に、アプリケーションのレイテンシーを減らすためです。具体的には、計算の単一ユニット(たとえば、Webリクエスト)のエンドツーエンドの持続時間です。これまで、この文書では主にアプリケーションのスループット(例:1 秒間に処理される Web リクエスト)を考慮しました。GC サイクルのセクションの各例では、実行中のプログラムの総 CPU 時間の長さに焦点を当てたことに注意してください。しかし、そのような持続時間は、例えば、ウェブサービスのためにはるかに少ない意味があります。Web サービスの場合、スループットはまだ重要ですが(つまり 1 秒あたりのクエリー)、多くの場合、個々のリクエストのレイテンシはもっと重要です。

待ち時間の点で、Stop-the-World GCは、そのマークと掃引フェーズの両方を実行するためにかなりの時間を必要とする可能性があり、その間、アプリケーション、そしてWebサービスのコンテキストでは、飛行中のリクエストは、さらに進行することができません。その代わり、Go GCは、グローバルなアプリケーションの休止の長さをヒープサイズに比例させることを避け、アプリケーションがアクティブに実行されている間、コアトレースアルゴリズムが実行されるようにします。(休止はアルゴリズム的にはGOMAXPROCSにもっと強く比例しますが、最も一般的には実行中のゴルーチンを停止するのにかかる時間に支配されています)。並行収集はコストがかからないわけではありません。実際には、同等のStop-the-Worldガベージコレクタよりも低いスループットを持つ設計になることがよくあります。しかし、レイテンシーが低いからといって、本質的にスループットが低いわけではないことに注意することが重要です。Goガベージコレクタのパフォーマンスは、レイテンシースループットの両方で、時間とともに着実に向上してきました。

Goの現在のGCの並列性は、これまでこの文書で議論されてきたことを無効にするものではありません。GCの頻度は、GCスループットのためにCPU時間とメモリをトレードオフする主要な方法であることに変わりはなく、実際、レイテンシについてもこの役割を担っています。これは、GCのコストのほとんどが、マークフェイズがアクティブな間に発生するためです。

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

しかし、レイテンシは、単なるコストの集計ではなく、プログラムの瞬間瞬間の実行の産物であるため、スループットよりも理解が複雑な場合が多いのです。その結果、レイテンシと GC の頻度との間の関連は、より直接的ではありません。以下に、レイテンシの原因として考えられるものを列挙しますので、より深く掘り下げたい方はご覧ください。

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

これらの待ち時間は、追加の作業を必要とするポインタの書き込みを除いて、実行トレースで見ることができます。

## 追加リソース

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

GCハンドブック-ガベージコレクタの設計に関する優れた一般的なリソースとリファレンスです。
TCMalloc-C/C++のメモリアロケータTCMallocの設計文書で、Goのメモリアロケータはこれに基づいています。
Go 1.5 GC 発表-Go 1.5 同時実行 GC を発表したブログ記事で、アルゴリズムの詳細が説明されています。
Getting to Go-2018年までのGoのGC設計の進化に関する詳細なプレゼンテーション。
Go 1.5 concurrent GC pacing-コンカレント マーク フェーズを開始するタイミングを決定するための設計文書です。
Smarter scavenging-Goランタイムがオペレーティングシステムにメモリを返す方法を修正するための設計文書です。
スケーラブル ページ アロケータ-Go ランタイムがオペレーティング システムから取得したメモリを管理する方法を修正するための設計ドキュメント。
GC pacer redesign (Go 1.18)- 同時実行マーク フェーズをいつ開始するかを決定するアルゴリズムを修正する設計ドキュメント。
ソフトメモリ制限(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の影響と動作を理解するために、これらのツールのサブセットとそれらを適用する合理的な順序に焦点を当てます。

  1. CPU プロファイル

    開始するのに良い場所は、CPU プロファイリングです。CPU プロファイリングは、CPU 時間が費やされる場所の概要を提供しますが、素人目には、 特定のアプリケーションで GC が果たす役割の大きさを識別することは困難かもしれません。幸運なことに、GCがどのようにフィットするかを理解することは、ほとんどの場合、 runtimeパッケージ内の異なる関数が何を意味するかを知ることに帰着します。以下は、CPUプロファイルを解釈するためのこれらの関数の有用なサブセットです。

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

    runtime.gcBgMarkWorker: 専用のマークワーカーゴルーチンへのエントリーポイント。ここで費やされる時間は、GCの頻度、オブジェクトグラフの複雑さと大きさによって変化します。これは、アプリケーションがマークとスキャンに費やす時間のベースラインを表します。
    
    注意: 大部分がアイドルのGoアプリケーションでは、Go GCは仕事を速く終わらせるために追加の(アイドルの)CPUリソースを使用することになります。その結果、このシンボルは、無料であると信じているサンプルの大きな割合を示すかもしれません。このようなことが起こる一般的な理由は、アプリケーションが完全に 1 つのゴルーチンで実行され、GOMAXPROCS が >1 である場合です。
    
    runtime.mallocgc: ヒープメモリのメモリアロケータへのエントリーポイント。ここで費やされる累積時間が大きい(15%以上)場合、一般的に多くのメモリが割り当てられていることを示します。
    
    runtime.gcAssistAlloc: 関数のゴルーチンは、スキャンとマーキングでGCを支援するために彼らの時間の一部を提供するために入力します。ここで費やされる累積時間が大きい(>5%)場合、アプリケーションは、割り当ての速さに関してGCを上回っている可能性があることを示します。これは、GC からの影響が特に大きいことを示し、また、アプリケーションがマーキングとスキャニングに費やす時間を表します。これは runtime.mallocgc のコールツリーに含まれるため、同様に膨張することに注意 してください。
    
  2. 実行トレース
    CPU プロファイルは全体としてどこに時間がかかっているかを特定するには最適ですが、より微妙な、まれな、あるいはレイテンシに特化したパフォーマンス コストを示すにはあまり役に立ちません。一方、実行トレースは、Go プログラムの実行の短いウィンドウを深く掘り下げて見ることができます。実行トレースには、Go GCに関連するさまざまなイベントが含まれており、特定の実行パスを、アプリケーションがGo GCとどのように相互作用するかと共に直接観察することができます。追跡されたすべての GC イベントは、トレースビューワでそのように便利にラベル付けされています。

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

  1. GCトレース

他のすべてが失敗した場合、Go GCは、GCの動作にはるかに深い洞察を提供するいくつかの異なる特定のトレースを提供します。これらのトレースは常にSTDERRに直接出力され、GCサイクルごとに1行、すべてのGoプログラムが認識するGODEBUG環境変数を通じて構成されています。これらは、GCの実装の細部に精通している必要があるため、Go GC自体をデバッグするために主に有用ですが、それでも、GCの動作をよりよく理解するために有用である場合もあります。

コアGCトレースは、GODEBUG=gctrace=1を設定することによって有効になります。このトレースによって生成された出力は、ランタイムパッケージのための文書の環境変数のセクションで文書化されています。

pacerトレース」と呼ばれる補足の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 [パッケージ] です。

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

  • ui.codelenses 設定に gc_details を含めるように設定します。
  • ui.diagnostic.annotations を include escape に設定して、エスケープ解析のためのオーバーレイを有効にします。

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

実装に特化した最適化

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

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

  • ポインターを持たない値は、他の値から分離されます。

    その結果、厳密にポインターを必要としないデータ構造からポインターを排除することは、GCがプログラムに与えるキャッシュの圧力を減らすことになり、有利になる場合があります。その結果、ポインタの値に対するインデックスに依存するデータ構造は、あまりよく型付けされていないものの、よりよいパフォーマンスを発揮する可能性があります。これは、オブジェクトグラフが複雑で、GCがマークとスキャンに多くの時間を費やしていることが明らかな場合にのみ実行する価値があります。

  • GCは値の最後のポインターで値のスキャンを停止します。

    その結果、構造体型の値のポインタフィールドを値の先頭でグループ化することが有利になる場合があります。これは、アプリケーションがマークとスキャンに多くの時間を費やすことが明らかな場合にのみ行う価値があります。(理論的にはコンパイラが自動的にこれを行うことができますが、まだ実装されておらず、構造体フィールドはソースコードに書かれているとおりに配置されます)。

さらに、GCは目にするほぼすべてのポインタと対話しなければならないので、ポインタの代わりに、例えばスライスへのインデックスを使用すると、GCコストを削減するのに役立ちます。

付録

GOGCに関する追記

GOGCの項では、GOGCを2倍にするとヒープメモリのオーバーヘッドが2倍になり、GCのCPUコストが半分になると主張しました。その理由を知るために、数学的に分解してみましょう。

まず、ヒープターゲットでは、ヒープサイズの合計の目標値が設定されます。しかし、このターゲットは主に新しいヒープメモリに影響します。なぜなら、ライブヒープはアプリケーションにとって基本的なものだからです。

ターゲット・ヒープ・メモリ = ライブ・ヒープ + (ライブ・ヒープ + GCルーツ) * GOGC / 100

総ヒープメモリ=ライブヒープ+新規ヒープメモリ

新規ヒープメモリ = (ライブヒープ + GC roots) * GOGC / 100

このことから、GOGC を 2 倍にすることで、アプリケーションがサイクルごとに割り当てる新しいヒープメモリの量も 2 倍になり、ヒープメモリのオーバーヘッドを捉えることができることがわかります。Live heap + GC roots は 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 root) * GOGC / 100) * ((ライブヒープ + GC roots) * (Cost per byte) + Fixed cost) * T

十分に大きなヒープ(これはほとんどの場合を表しています)の場合、GCサイクルの限界コストは、固定コストを支配します。これは、総 GC CPU コストの公式を大幅に簡略化することができます。

GC CPU コスト合計 = (アロケーション率) / (GOGC / 100) * (バイトあたりのコスト) * T

この単純化された式から、GOGC を 2 倍にすれば、GC の総 CPU コストが半分になることがわかります。(このガイドの可視化は固定コストをシミュレートしているので、GOGC が 2 倍になったときに報告される GC CPU のオーバーヘッドが正確に半分になるわけではないことに注意してください)。さらに、GC CPU コストは、主に割り当て率とメモリをスキャンするためのバイトあたりのコストによって決定されます。具体的にこれらのコストを削減する方法の詳細については、最適化ガイドを参照してください。

注:ライブヒープのサイズと、GC が実際にスキャンする必要があるメモリの量との間に不一致があります: 同じサイズのライブヒープでも、異なる構造を持つものは、異なる CPU コスト、しかし同じメモリコストになり、異なるトレードオフが生じます。これが、ヒープの構造が定常状態の定義の一部である理由です。ヒープターゲットは、GCがスキャンする必要があるメモリの近似として、スキャン可能なライブヒープのみを含むべきですが、これはスキャン可能なライブヒープが非常に少なく、ライブヒープが他に大きい場合に縮退した動作につながります。