OLTA TECH BLOG

テクノロジーと好奇心で事業を成長させる

TECH BLOG

GoのGC(Garbage Collection)入門

OLTAでINVOYカードを開発しているakitaです。
過去にOut Of Memoryを起こした良い思い出(?)があるので一度Goのメモリ管理について勉強してみました。

GCとは?

ガーベジコレクション(Garbage Collection)とは、プログラムが動的に確保したメモリ領域のうち、「もう使われない(参照されない)」メモリを自動的に解放する仕組みです。 これにより、開発者はC言語のようにmalloc/freeを手動で管理する必要がなくなり、 メモリリークやダングリングポインタによるクラッシュなどの問題を抑制することができます。

GCがない世界:C言語の例

C言語ではGCがありません。そのため、動的確保したメモリを使用後に明示的にfreeで解放しなければなりません。 以下はメモリリークを防ぐ例です。

#include <stdio.h>
#include <stdlib.h>

void create_no_leak() {
    // メモリを動的に割り当てる
    int *data = (int *)malloc(sizeof(int) * 100);
    if (data == NULL) {
        fprintf(stderr, "メモリの割り当てに失敗しました。\n");
        exit(1);
    }

    // メモリを使用
    for (int i = 0; i < 100; i++) {
        data[i] = i;
    }

    // 使用済みメモリを解放
    free(data);
    data = NULL;
}

int main() {
    create_no_leak();
    // 他の処理 ...
    return 0;
}

このように、C言語ではメモリ割り当て(malloc)と解放(free)を明示的に行う必要があります。

GoのGCについて

Go言語の初期バージョン(Go 1.4以前)は、典型的なMark and Sweep GCを用いていました。

Markフェーズ
ルート(グローバル変数やスタック上の変数、レジスタ)から参照可能なオブジェクトを探索し、「参照中」のオブジェクトにマークを付ける。

Sweepフェーズ
マークがないオブジェクト(参照されていない)を解放してフリーリスト(再利用可能なメモリ領域)に戻す。

しかし、初期のGCはアプリケーションの実行を一時停止してGCを行うため、 Stop-The-World(STW)時間が長くなり、応答時間が悪化する問題がありました。

Concurrent Mark and Sweep

Go 1.5以降は「並行(Concurrent)なMark and Sweep」が導入され、 GCとアプリケーションが同時に動作できるようになりました。 これによりSTW時間は大幅に短縮され、よりスムーズな動作が可能となっています。

並行GCを支える技術としては以下があります。

Tri-color marking
オブジェクトを白(未探索)・灰(探索中)・黒(探索済み)の3色で管理する手法。 並行環境下でも正確な到達解析が可能になります。

Write Barrier(ライトバリア)
GC中にアプリケーションがオブジェクト参照を変更した場合でも、 GCが見落としをしないようにする仕組み。参照書き換え時に特定の処理を挟むことで、 マーク漏れを防ぎます。

GCサイクルとコストモデル

GoのGCは、メモリを効率的に管理するために、「マーク→スイープ→休止(オフ)」という3つの段階を繰り返すサイクルで動作します。 まず「マーク」フェーズで参照中(ライブ)のオブジェクトを特定し、「スイープ」フェーズで不要なメモリを解放します。その後、一時的に作業がなければ「休止」状態となります。このサイクルを絶えず繰り返すことで、Goは動的メモリを自動的に整理し続けます。

しかし、GCはCPU時間とメモリという2つのリソースを消費します。コストを理解し、適切なパラメータ調整を行うには、簡略化したモデルを用いるとわかりやすくなります。

メモリコストは主に3つの要素から構成されます。

ライブヒープメモリ (前回のGC終了時点で「ライブ(生存中)」と判定されたヒープ領域)
ヒープメモリ (現在のGC中に新たに割り当てられたメモリ)
メタデータ

一方、CPUコストはサイクルごとに一定の処理量がかかる固定コストに加え、ライブヒープサイズに比例して増大する部分があります。 ここで、アプリケーションが一定ペースでメモリを割り当て、ライブヒープサイズが安定している「定常状態」を想定してみましょう。 このとき、GCの起動頻度を下げて実行すれば、GCサイクルあたりのCPUコストは減少しますが、それまでの間に割り当てたメモリは増えるため、メモリ使用量は大きくなります。 逆に、GCを頻繁に行えばメモリ使用量は抑えられますが、CPUコストが増加します。

つまり、GCには「CPU時間を減らす代わりにメモリ使用量が増える」か「メモリ使用量を抑える代わりにCPU時間を増やす」のいずれかを選ぶ基本的なトレードオフが存在します。Goはこのトレードオフを調整するために「GOGC」というパラメータを提供しており、これによって各アプリケーションの要求に応じたGC動作を実現できます。

GOGCを使用したチューニング

GoでGCをチューニングする主な手段が環境変数GOGCです。 GOGCは「ヒープ成長率」に基づいてGCのトリガーを調整します。 デフォルト値は100で、これは「前回のGC時のヒープサイズの100%増加(2倍)でGCを起動」という意味です。 値を大きくするとGC発火頻度を下げ、メモリ使用量は増えますがGCコストを削減できます。 逆に小さくすると頻繁にGCが走り、メモリ使用量は抑えられますがCPU負荷が増える可能性があります。

GOGC設定例

package main

import (
    "fmt"
    "runtime"
    "runtime/debug"
)

func main() {
    // 現在のGOGC値を取得(-1で取得のみ)
    initialGOGC := debug.SetGCPercent(-1)
    fmt.Printf("Initial GOGC: %d\n", initialGOGC)

    // GOGCを200に設定(前回ヒープサイズの3倍でGC発火)
    debug.SetGCPercent(200)
    fmt.Println("GOGC set to 200")

    // 強制的にGC実行
    runtime.GC()
    fmt.Println("Forced GC executed")
}

リアルタイム性が求められるアプリケーションでは、GOGC調整でGCの影響を軽減できますし、 クラウド環境でメモリコストを意識したい場合もGOGC設定は有用です。

メモリリミット(Memory Limit)の導入(Go 1.19以降)

GOGCはあくまでヒープ成長率に基づく制御ですが、使用可能メモリが実際には有限な場合、一時的なピークに合わせてGOGCを非常に低く設定する必要が出てきます。 これでは通常時のパフォーマンス効率が低下します。

Go 1.19で導入された「メモリリミット(GOMEMLIMITまたはSetMemoryLimit)」機能により、 ヒープサイズが特定の上限を超えないようにGCが動的に頻度を上げ、 「絶対超えてはいけないメモリ上限」を設定できるようになりました。

ただし、メモリリミットが小さすぎるとGCが頻発し、プログラムがほとんど進まない「スラッシング(thrashing)」状態になります。 そのためメモリリミットはソフトとして定義され、極端な場合には制限を緩めてスラッシングを回避します。

メモリリミットは使用可能メモリが明確な環境で特に有効です。

おまけ:スタックとヒープ

Goでは変数がスタックに割り当てられるか、ヒープに割り当てられるかが重要です。

スタック
LIFO構造で、関数呼び出し時に引数やローカル変数が積まれ、関数終了時に一括で取り除かれる。 メモリアクセスが非常に高速。メモリのフラグメンテーションが起きにくい。 サイズが固定的なデータしか扱えないため、大きすぎるデータを割り当てるとスタックオーバーフローのリスクがある。

ヒープ
動的メモリ割り当てが可能で、サイズが不定なデータ構造(スライスやマップなど)を扱える。 メモリは非連続的に割り当てられ、GCによる解放や再利用が行われる。 関数スコープ外までデータ寿命を拡張できるため、柔軟なメモリ管理が可能。

参考文献

A Guide to the Go Garbage Collector
Getting to Go: The Journey of Go's Garbage Collector
Go GC: Prioritizing low latency and simplicity

最後に

OLTAではユーザーに価値を提供し、事業を成長させるサービスを一緒に作る仲間を募集しています。

もし、この投稿にご興味を持っていただけたら、是非カジュアルにお話しさせてください。

corp.olta.co.jp