🐿️

GoはいつGCするのか?

5 min read

TL;DR

  • Go(のランタイム)は以下のタイミングで自動的にGCを実行する
    • 前回のGC後に占有していたメモリと同量を新たに確保したとき
    • 前回のGCから2分後
  • cgroupなどでメモリ制限しているときは、メモリ使用量が制限の50%以上になったらruntime.GC()を呼び手動でGCすべきである

前置き: GoとOOMのこれまで

以下はGo 1.16での調査結果です。Goのバージョンが異なった場合は事情が異なる可能性があります。

Goでプログラムを書く際に、使用メモリ量を気にしなければならないシーンはGCのおかげでそう多くはありません。実際それは間違いではないのですが、運用まで視野に入れるとそうは言ってられないことがあるのもまた現実です。昨今はコンテナの利用が当たり前になったことに伴い、OOMによりプロセスが強制的に終了させられることもあり、それを避けるために一定量以下のメモリで動くことが重視されたりもします。

筆者は過去にこのGoとDockerとOOMに関連して以下の2つの記事を書いています。これらの記事ではGoのメモリ確保における前提とDockerの前提がそれぞれVSSとRSSの違いを理由にうまく機能しないこと、およびそれを緩和させる方法について紹介しました。

https://www.kaoriya.net/blog/2020/01/18/golang-on-docker-oom/
https://www.kaoriya.net/blog/2020/08/16/golang-vs-oom-part2/

本記事の主題はこの2つの記事の延長線上で発覚した問題とその部分的な解決方法です。興味があればこの2記事も併せて読んでみてください。

問題: それでもOOMに殺される

先の記事で紹介した「物理メモリのサイズ(RSS)を監視し一定ラインを超えたらお行儀よく終了(graceful shutdown)する戦略」はある時期までは上手く機能していました。しかしある頃から頻繁にgraceful shutdownしたりOOM Killerに殺されるようになりました。ログなどをよく調べてみると、とあるAPIリクエストが大きなJSONを返した後にOOMが発生する確率が高いことがわかりました。大きなJSONを返せるようにしてしまったのは明らかに設計ミスなのですが、本記事ではそれには言及しません。

GoにはGCがあるのにおかしいですね。対象としているのは普通(?)のWeb APIサーバーですので、リクエスト間のメモリに依存関係はなく、GCが機能していれば、たとえ大きなJSONを返したところでそのリクエストさえ終わってしまえば回収・再利用可能なメモリであり、OOMに陥ることなどないと期待されます。

にも拘わらずOOMが発生しているということは、GCされていないと逆説的に結論できます。ここで考えてみれば、GoのGCがいつ実行されるかは正確には知らないな、ということに気が付き調べてみたのです。

調査: GoはいつGCするのか?

GoのGCは内部的にruntime.gcStart()関数を呼ぶことで開始します。それを呼んでいる箇所は3箇所ありました。以下にその3箇所のコードを示します。

  1. mgc.go#L1159より - runtime.GCの実装から

    gcStart(gcTrigger{kind: gcTriggerCycle, n: n + 1})
    
  2. malloc.go#L1165-L1167より - runtime.mallocgcの実装から

    if t := (gcTrigger{kind: gcTriggerHeap}); t.test() {
    	gcStart(t)
    }
    
  3. proc.go#L282 - runtime.forcegchelperの実装から

    gcStart(gcTrigger{kind: gcTriggerTime, now: nanotime()})
    

このコードを見るだけでもGCを開始するトリガーにはTick, Heap, Timeの3種類の戦略があることがわかります。このうちTickはruntime.GC()の呼び出しで使うものでした。

HeapトリガーGC

Heapトリガー戦略のGCを詳しく追っていくと最終的にruntime.gcSetTriggerRatio()の次のコード(mgc.go#L831)にたどり着きます。

goal = memstats.heap_marked + memstats.heap_marked*uint64(gcpercent)/100

これはつまるところGOGC環境変数の実態です。ここでGOGCの説明を見てみましょう。

The GOGC variable sets the initial garbage collection target percentage. A collection is triggered when the ratio of freshly allocated data to live data remaining after the previous collection reaches this percentage. The default is GOGC=100.

以下はその荒訳です。

GOGC環境変数はGCのターゲット割合の初期値を設定します。前回のGC完了時に確保済みだったヒープメモリ量に対して、この割合のヒープメモリ量が新たに追加で確保された際に次回のGCを実行します。デフォルトはGOGC=100(訳注:つまり利用ヒープが倍になったら)です。

ここでcgroup下でRSSが制限されている状況を考えてみましょう。この状況で仮に制限の90%のメモリが確保されたとします。この場合、次のHeapトリガーによるGCが起こるのはその倍、制限の180%のメモリを確保した時となる可能性がでてきます。つまりGCよりも先にOOMが発生する確率が極めて高くなります。

TimeトリガーGC

Timeトリガー戦略の内容はとてもシンプルです。mgc.go#L1277-L1278を見れば一目瞭然で、前回のGCから一定時間(forcegcperiod)以上経過した後にGCを実行することがわかります。

lastgc := int64(atomic.Load64(&memstats.last_gc_nanotime))
return lastgc != 0 && t.now-lastgc > forcegcperiod

focegcperiodproc.go#L5133-L5138で次のように定義されているので、2分であることがわかります。

// forcegcperiod is the maximum time in nanoseconds between garbage
// collections. If we go this long without a garbage collection, one
// is forced to run.
//
// This is a variable for testing purposes. It normally doesn't change.
var forcegcperiod int64 = 2 * 60 * 1e9

OOMの発生条件と回避方法

以上のことからcgroupによるRSS制限環境下では、制限量の50%以上を使っている状況から、2分以内に、同量未満で制限量を超えるメモリを新たに確保(=合計で倍量未満)すると、自動ではGCが実行されずにOOMが発生することがわかります。

これに対処する方法は文字にすると極めて単純で「メモリ使用量が50%以上になったらruntime.GC()を呼ぶ(手動GCする)」です。50%未満であれば2分以内に倍量のメモリを確保した際でも、OOMになる前にHeapトリガーにより自動でGCが実行されます。そのため「使用量が50%以上になったら」なのです。

ただやみくもにGCを手動実行してしまうとパフォーマンスに影響があることが予想されます。そのため私はkoron-go/phymemを使って15秒おきにRSSを監視し、50%以上になったらメモリ使用量に応じて前回からの経過時間=インターバルを開けて手動GCを行うようにしました。具体的なインターバルは90%以上なら15秒(毎監視ごと)、75%以上なら30秒(監視2回に1回)、50%以上なら60秒(監視4回に1回)です。

ただこの方法も完璧とは言えません。RSS監視の15秒のインターバルの間に一気にメモリを確保されてしまった場合、やはりOOM状態に陥ることが予想されます。これに対処するには監視インターバルを短くすることが考えられますが、やはりパフォーマンスとのトレードオフとなるでしょう。

まとめ

以上、本記事ではGoの自動GCの実行タイミングを解説し、それに基づき物理メモリRSS制限環境下における手動GCの実行戦略を紹介しました。

この記事に贈られたバッジ