0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【C#/Go】GC(ガベージコレクション)の仕組みを実測で理解する

0
Last updated at Posted at 2026-06-15

はじめに

C#とGoはGCの設計思想がかなり違います。

  • C#(.NET): 世代別(generational)GC。オブジェクトを世代で分けて管理し、若い世代を頻繁・安価に回収する。
  • Go: 非世代の並行mark-sweep GC。世代を持たず、到達可能な生存オブジェクト全体をたどって、アプリと並行して回収する。

この違いが実測でどう見えるのかを軸にまとめます。

環境

項目 バージョン
マシン Mac M3
C# .NET 10.0.8(Server GC + Background GC=ASP.NET Coreの既定構成)
Go go1.26.4

計測のために使用したコードは以下にあります。
https://github.com/Pokeyama/shimoyama-qiita-articles/tree/main/gc-benchmark

Workstation GCとServer GC(C#の前提)

C#のGCには2つのモードがあり、どちらで動くかで挙動(特にGCの頻度)が変わります。この記事のC#計測は、業務のサーバーアプリで多いServer GCで行いました。

モード 特徴 向き
Workstation GC ヒープは1つ。GCは基本、それを引き起こしたスレッドの上で動く。省メモリ・低レイテンシ寄り デスクトップ / クライアント
Server GC 論理CPUごとに専用ヒープとGCスレッドを持っていて、並列でガッと回収。スループット重視・メモリ多め サーバー / 高スループット

重要なのは、マシンの役割で自動的に決まるわけではなく、設定で決まる点です。そして既定値はアプリの種類で違います。

アプリの種類 既定のGC
コンソールアプリ Workstation
ASP.NET Core(Web API / MVC) Server
Worker Service / 汎用ホスト Workstation(明示しない限り)

設定はcsprojの<ServerGarbageCollection>、環境変数DOTNET_gcServerruntimeconfig.jsonSystem.GC.Serverなどで切り替わります。今どちらで動いているかは実行時に1行で確認できます。

Console.WriteLine($"IsServerGC = {System.Runtime.GCSettings.IsServerGC}");

大前提: GCは何をしているか

GC(ガベージコレクション)は、もう参照されていない=今後使われないオブジェクトを自動で見つけて、そのメモリを回収する仕組みです。手動でfreeしなくていい代わりに、ランタイムが定期的に「生きているオブジェクト」を辿り(mark)、辿れなかったものを回収します(sweep)。

ここで重要になるのが、アロケーション(オブジェクト生成)が多いほど、GCは頻繁に走る。ということかと思います。

C#のGC: 世代別(generational)

3つの世代とLOH

.NETのGCは「若いオブジェクトほどすぐ死ぬ」という経験則(世代仮説)に基づき、オブジェクトを世代で分けて管理します。

世代 中身 回収頻度 コスト
Gen0 生まれたて 高い 安い(速い)
Gen1 Gen0を1回生き延びた(緩衝地帯)
Gen2 長生き 低い 高い
LOH 85,000 byte以上の大きいオブジェクト Gen2と一緒 高い

新規オブジェクトはまずGen0に置かれ、Gen0が埋まると Gen0 GC が走ります。そこで生き残ったものがGen1へ、さらに生き残ればGen2へ昇格していきます。

実演1: 生き残ると世代が上がる

オブジェクトがGCを生き延びるたびに世代が上がる様子を、GC.GetGeneration()で観察できます。

gc-benchmark/csharp/Program.cs
var obj = new byte[100];
Console.WriteLine($"生成直後                : Gen{GC.GetGeneration(obj)}");
GC.Collect(0);  // Gen0 を回収
Console.WriteLine($"Gen0 GC を1回生き延びた : Gen{GC.GetGeneration(obj)}");
GC.Collect(1);  // Gen1 を回収
Console.WriteLine($"Gen1 GC を1回生き延びた : Gen{GC.GetGeneration(obj)}");

実行結果:

生成直後                : Gen0
Gen0 GC を1回生き延びた : Gen1
Gen1 GC を1回生き延びた : Gen2

objへの参照が生き続けているので、GCのたびにGen0 → Gen1 → Gen2と昇格しているのが分かります。

実演2: LOH(Large Object Heap)のしきい値

既定ではサイズが85,000 byte以上のオブジェクトは、通常ヒープ(SOH: Small Object Heap)ではなくLOHに置かれ、いきなりGen2扱いになります(しきい値も「Gen2扱い」も.NET公式ドキュメントに明記されています)。

gc-benchmark/csharp/Program.cs
var small = new byte[84_000];
var large = new byte[85_000];
Console.WriteLine($"byte[84000]  (< 85KB) : Gen{GC.GetGeneration(small)}");
Console.WriteLine($"byte[85000]  (>=85KB) : Gen{GC.GetGeneration(large)}");
byte[84000]  (< 85KB) : Gen0  (通常ヒープ / SOH)
byte[85000]  (>=85KB) : Gen2  (LOH = Gen2 扱い)

たった1,000 byteの差で、回収頻度の低いGen2行きになります。LOHは既定ではコンパクション(断片化解消の詰め直し)も行われないので、大きい配列を高頻度で作って捨てると断片化やメモリ肥大の温床になります。

実演3: アロケーション圧 → GC回数

ここが本題。同じ仕事量でも「毎回newする」か「使い回す」かでGC回数がどれだけ変わるかGC.CollectionCount()で測ります。new byte[64]を1000万回作ります。

gc-benchmark/csharp/Program.cs
// (A) 毎回 new(短命オブジェクトを大量生産)
for (int i = 0; i < N; i++)
{
    Blackhole = new byte[64];   // static フィールドへ代入してエスケープを強制
    Blackhole[0] = (byte)i;     // (ローカルのままだと JIT がアロケーションごと消す)
}

// (B) 1個を使い回す
var buf = new byte[64];
for (int i = 0; i < N; i++)
{
    buf[0] = (byte)i;
}

実行結果(N = 10,000,000、Server GC):

毎回 new byte[64] : Gen0=    8  Gen1=   0  Gen2=  0  alloc=   839 MB
1個を使い回し     : Gen0=    0  Gen1=   0  Gen2=  0  alloc=     0 MB
パターン Gen0 GC Gen1 GC Gen2 GC アロケーション
毎回new 8回 0 0 839 MB
使い回し 0回 0 0 0 MB

ポイントは2つです。

  • 使い回すとGC回数がゼロ(少なくともこの計測区間では)。アロケーションしなければGCは走らない。当たり前ですが。
  • 毎回newした方も、Gen0 GCだけでGen1 / Gen2は0回。短命なゴミは全部Gen0の安い回収で消えていて、上の世代に昇格していません。まさに世代仮説どおりの動きで、「短命ゴミは安く片付く」のが世代別GCなのかなと思います。

Workstation GCとの比較

冒頭で触れたGCモードの違いは、この実験で数字に出ます。同じ839MBのアロケーションを、GCモードだけ変えて流したときのGen0 GC回数です。

GCモード 毎回newのGen0 GC回数
Workstation 105回
Server 8回

Server GCは論理CPUごとに大きなGen0ヒープを持つので、同じゴミ量でもGCが走る回数が1桁以上少なくなります。(その代わりメモリは多めに使う)

実演4: GCは世界を止める(stop-the-world)

GCには、アプリのスレッドをピタッと止める stop-the-world (STW)の区間があります。Background GCだと処理の大半はアプリと並行してやってくれるんですが、それでも止まる区間は残ります。.NET 9以降ならGC.GetTotalPauseDuration()で、その止まってた時間の合計が取れます。(GC全体にかかった時間ではなく、あくまで止まってた分だけ)

new byte[64] を 20,000,000回: Gen0 GC 17回, GC 停止合計 4.0 ms

2000万回のアロケーションで、Server GCでは17回・合計4.0msぶん止まっていました(同じ処理をWorkstation GCで動かすと210回・4.7msでした)。1回あたりは短いですが、アロケーションが多いほどこの停止が積み上がり、レイテンシのスパイクになります。

C# GCのチューニングポイント

  • GCモード(Workstation / Server): 冒頭で触れたとおり、モードでGCの頻度が大きく変わる(同じゴミ量でもServerはGen0 GCが8回、Workstationは105回)。サーバーアプリはServer GCが既定なことが多い。
  • SOHはコンパクションあり: 通常ヒープは回収後にオブジェクトを詰め直して断片化を防ぐ(=オブジェクトのアドレスは動く)。一方 LOHは既定で非圧縮
  • 結局のところ、アロケーションを減らすのがメインになってくるのかなと思います。

GoのGC: 非世代の並行mark-sweep

設計思想がそもそも違う

GoのGCはC#とかなり違います。

  • 世代を持たない。Gen0/1/2のような区別はなく、若い領域だけに絞らず、根(roots)から到達できる生存オブジェクトのグラフ全体をマークする。
  • 並行(concurrent)。マーキングの大半をアプリと並行して実行し、stop-the-worldはごく短い区間だけ。設計目標が「低レイテンシ(短い停止時間)」に振られている。
  • 非移動(non-moving)。回収後にオブジェクトを詰め直さない(コンパクションしない)。サイズクラス別アロケータで断片化を抑える方針。

その代わり、GCをいつ走らせるかは GOGC というシンプルなノブで決まります。

GOGC=100(既定): ざっくり「前回GC後の生存ヒープが2倍に増えたら次のGC」。

ざっくりはこれでOKなんですが、実はGo 1.18以降はもうちょい細かくて、rootも込みでLive heap + (Live heap + GC roots) × GOGC / 100あたりが次の目標サイズになります(詳しくはGo GC Guide)。

実演1: アロケーション圧 → GC回数

C#と同じ実験をGoでも。runtime.MemStatsNumGC(GC回数)とPauseTotalNs(累計停止時間)を測ります。

gc-benchmark/go/main.go
// (A) 毎回 make
for i := 0; i < N; i++ {
    blackhole = make([]byte, 64) // パッケージ変数へ代入してエスケープを強制
    blackhole[0] = byte(i)
}

// (B) 1個を使い回す
buf := make([]byte, 64)
for i := 0; i < N; i++ {
    buf[0] = byte(i)
}

ある実行例(N = 10,000,000):

毎回 make([]byte,64): NumGC= 171  GC停止合計=   5.0 ms  TotalAlloc=  610 MB
1個を使い回し       : NumGC=   0  GC停止合計=   0.0 ms  TotalAlloc=    0 MB

C#と同じく、使い回せばGCはゼロ(これもこの区間での話ですが)。アロケーションがGCを駆動するのは両言語共通です。

ただし中身は違います。C#側は世代別なので、増えた分は安いGen0 GCだけで片付いていました(Server GCで8回、WorkstationでもGen0のみでGen1/Gen2は0)。一方Goの171回前後は世代がなく、毎回、生存グラフ全体を対象にしたGCです(その代わり並行実行で停止は短い)。「世代別で安く済ます」C#と「並行で止めずに済ます」Go、というアプローチの違いが出てますね。

実演2: GOGCのトレードオフ

同じ仕事量(500万回のmake)で、GOGCだけを変えてみます。

gc-benchmark/go/main.go
for _, pct := range []int{50, 100, 200, 400} {
    debug.SetGCPercent(pct)
    // ... 500万回 make して NumGC と停止時間を測る
}

ある実行例:

GOGC=  50 : NumGC= 191  GC停止合計=   4.8 ms
GOGC= 100 : NumGC=  87  GC停止合計=   2.3 ms
GOGC= 200 : NumGC=  41  GC停止合計=   1.0 ms
GOGC= 400 : NumGC=  20  GC停止合計=   0.5 ms
GOGC GC回数 GC停止合計 性格
50 191回 4.8 ms こまめに回収・メモリ節約・CPU多め
100(既定) 87回 2.3 ms バランス
200 41回 1.0 ms GCを減らす・メモリ多め
400 20回 0.5 ms さらにGCを減らす・メモリさらに多め

GOGCを2倍にするたびにGC回数がほぼ半分になっています。「メモリを多めに使う代わりにGCを減らしてCPUを浮かせる」という調整が、ノブ1つでできるのがGoの特徴です。メモリ上限を直接決めたい場合は GOMEMLIMIT(Go 1.19+のソフトリミット)も使えます。

実演3: gctraceでGCを生で見る

GODEBUG=gctrace=1を付けて実行すると、GCのたびに1行ログが出ます。

GODEBUG=gctrace=1 go run .

実際の出力(抜粋):

gc 2 @0.001s 3%: 0.009+0.066+0.014 ms clock, ..., 3->3->0 MB, 4 MB goal, ..., 10 P
gc 3 @0.002s 2%: 0.008+0.20+0.011 ms clock, ..., 3->4->0 MB, 4 MB goal, ..., 10 P
gc 4 @0.003s 3%: 0.008+4.6+0.23 ms clock, ..., 3->4->0 MB, 4 MB goal, ..., 10 P

読み方の要点:

  • gc 4 … 4回目のGC
  • 0.008+4.6+0.23 ms clockSTW(sweep終了)+並行マーク+STW(mark終了) の各時間。長い4.6msの部分は並行実行なので、アプリが完全に止まるのは前後の0.0080.23 msだけ。
  • 3->4->0 MB … GC開始時ヒープ → 終了時 → 生存サイズ。生存が(MB表示で)0なので、作ったものがほぼ全部ゴミ=短命だったと分かる。
  • 4 MB goal … GOGCから決まったGCを起こすヒープサイズの目標。

「重い処理は並行でやって、世界を止めるのは前後のわずかな区間だけ」というGo GCの設計が、ログからも読み取れます。

C#とGoのGCまとめ

観点 C#(.NET) Go
世代 あり(Gen0/1/2 + LOH) なし(生存グラフ全体をマーク)
回収方式 mark-sweep(SOHは通常compact、LOHは通常非compact) 並行mark-sweep(非移動
停止(STW) 世代を分けて停止を短く 並行実行で停止を極小に
主なチューニング Workstation/Server GC、アロケーション削減 GOGC / GOMEMLIMIT
思想 短命ゴミを安く片付ける 止めずに並行で片付ける

計測でわかったこと

  • 両言語に共通: アロケーションがGCを駆動する。使い回してアロケーションをなくせば、今回の計測区間ではGCがゼロになった。
  • C#: 短命なゴミはGen0で安く死ぬ(今回Gen1/Gen2は0回)。気をつけるのは長生きするオブジェクト(Gen2)と85KB以上のLOH行き
  • Go: GC回数は GOGC で素直にコントロールできる。停止時間は並行実行で短いが、頻発すればCPUを食う。メモリとCPUのトレードオフをGOGC/GOMEMLIMITで設定する。

まとめ

雰囲気で理解してたGCについてざっくりですが理解できた気がします。
実際に計測までしてみると言語ごとに違いがあっておもしろいですね。
このレベルでチューニングが実務で必要になるかは置いておくとしても、知識として持っておくことでコーディングの仕方も変わりそうです。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?