TL;DR

  • Go の GC はルートからポインタをたどって生きているオブジェクトに印を付け、残りを回収する、オブジェクトを動かさないマーク・スイープ方式のトレース型 GC である。
  • GC は CPU 時間とメモリを使うので、GOGC とメモリ上限(GOMEMLIMIT)でその配分を調整する。コストはプロファイルで調べ、ヒープ割り当てを減らして下げる。

内容

対象と前提

標準の gc ツールチェーンの GC を、Go を知っているが GC には詳しくない人向けに解説したドキュメントである。Go の仕様は GC を保証しておらず、ここで説明するのは 1 つの実装である。Go 1.19 時点の内容を基に、その後のリリースに合わせて更新されている。

GC のモデル

コンパイラが寿命を決められる値はスタックに置かれ、そうでない値はヒープに「エスケープ」して GC が管理する。GC はグローバル変数やローカル変数などのルートからポインタをたどり(スキャン)、到達できるオブジェクトに印を付け(マーク)、印のないメモリを再利用に回す(スイープ)。オブジェクトは移動しない。

GC のコストは物理メモリと CPU 時間の 2 つである。CPU 時間はサイクルごとの固定費と、生きているヒープの大きさに比例する費用の和で決まる。GC の頻度を下げればメモリが増え、CPU コストが減る。この時間と空間のトレードオフが全体の軸になる。

GOGC

GOGC は CPU コストとメモリのトレードオフを決める。目標のヒープは次の式で決まる。

Target heap memory = Live heap + (Live heap + GC roots) × GOGC / 100

Go 1.18 以降は、ゴルーチンのスタックなどのルートも計算に入る。GOGC を 2 倍にすると、ヒープのメモリの上乗せはほぼ 2 倍になり、GC の CPU コストはほぼ半分になる。環境変数 GOGC か debug.SetGCPercent で設定し、off にすると GC が止まる。

メモリ上限

Go 1.19 で入ったメモリ上限(GOMEMLIMIT、debug.SetMemoryLimit)は、上限に近づくと GC を頻繁に走らせて、メモリの使用量を上限内に収める。ただし GC が走り続けて処理が止まる状態(スラッシング)を避けるため、GC の CPU 使用率はおよそ 50% までに抑えられる。上限は守られるとは限らない、柔らかい上限である。

メモリ量が決まっているコンテナのように環境を自分で管理できるときに使い、5〜10% の余裕を残す。CLI ツールやデスクトップアプリのように環境を管理できないときや、すでにメモリが足りないプログラムの OOM 回避には使わない。

レイテンシ

Go の GC はほとんどの処理をアプリケーションと並行して行う。マークの間は CPU の 25% を使い、割り当てが速いとゴルーチンが GC を手伝う。GC の頻度を下げると、レイテンシもよくなりやすい。

コストを調べる

CPU プロファイル、実行トレース、GC トレースの順に使う。CPU プロファイルでは runtime.mallocgc が 15% を超えると割り当てが多く、runtime.gcAssistAlloc が 5% を超えると GC が追いついていない。GC トレースは GODEBUG=gctrace=1 で、GC のサイクルごとに 1 行を出力する。

ヒープ割り当てを減らす

割り当ての速さが GC の頻度を大きく左右する。ヒープのプロファイルでは、起動からの割り当て量を示す alloc_space が最も役に立つ。値がヒープに逃げる理由は、go build -gcflags=-m=3 によるエスケープ解析で調べる。

実装に依存した最適化

ポインタを持たない値は別に管理されるので、不要なポインタを減らすとスキャンの負担が減る。スキャンは値の最後のポインタで止まるので、構造体ではポインタのフィールドを先頭にまとめるとよい。ただし読みやすさが下がり、将来のリリースで効かなくなることもあるので、プロファイルで必要と分かった所だけに使う。

キーワード