1
1

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#が遅い原因の切り分け(30秒診断)|GC(ガベージコレクション)だけを疑わない:割り当て×寿命×サイズで最短特定【保存版】【鍛錬K38】

1
Last updated at Posted at 2026-03-02

「遅さ」の主因になりやすいのは GC そのものではなく、割り当て量・寿命・サイズが作る状況。
GC はその状況が数値として表に出た結果に出やすい。
先頭に 計測コマンドと早見表の置き先を先に決めておき、再調査でも同じ順で進められる形にする。

スクロールのカクつきでも、0.2章 の表で 5項目の増え方を見て行き先を決めると、原因候補が少ない手数で絞れる。
再調査でそのまま使えるよう、0章に「計測コマンド」と「最初に見る5項目 → 行き先」表を作成した。

このページの進み方

「C#が遅い」を、数値 → 方向 → 箇所の順で短い距離でつなぐための診断テンプレ。


0. 30秒切り分け

コマンド → 最初に見る5項目 → 行き先の順で、最初の当たりを付ける。
(最初に見る5項目:Allocation Rate / Gen 0 GC Count / GC Heap Size / Gen 2 GC Count / LOH Size。dotnet-counters 導入済みを想定)

前提(環境)
項目 前提 補足
OS Windows 10/11
.NET .NET 8(Windows Desktop)
TargetFramework net8.0-windows
UI WinForms
IDE Visual Studio 2022(17.8以上を目安)
C# C# 12(プロジェクトでバージョンを決める)

0.1 計測(PID → 開始)

dotnet-counters ps
dotnet-counters monitor -p <pid> System.Runtime
補足(未導入/一覧)
dotnet tool install --global dotnet-counters
dotnet-counters list -p <pid>

仕様: https://learn.microsoft.com/ja-jp/dotnet/core/diagnostics/dotnet-counters


0.2 最初に見る5項目

増えている項目の行を選び、右列のリンクへ進む。

最初に見る項目 見方(短い判定) 行き先
Allocation Rate 高いまま・増え続ける 原因カード(①〜⑤)
Gen 0 GC Count 間隔が短いまま・増え続ける 原因カード(①〜⑤)
GC Heap Size 下がりにくい・増え続ける 長く残る場合
Gen 2 GC Count 増え続ける 長く残る場合
LOH Size 増え続ける・戻らない サイズが原因(LOH)

例外の当たり

  • CPU と Allocation Rate が同時に上がり続ける場合は、例外多発 へ進む

体感から入る場合(早見)

体感 最初に見る項目 行き先
細かく止まる Allocation Rate / Gen 0 GC Count 原因カード(①〜⑤)
たまに長く止まる GC Heap Size / Gen 2 GC Count 長く残る場合
操作時だけ止まる LOH Size サイズが原因(LOH)
じわじわ遅くなる GC Heap Size 長く残る場合
体感の例
  • 細かく止まる
    0.1〜0.5秒くらいの小さな停止が、短い間隔で何度も出る。
    例:スクロールがカクつく/入力が一瞬ずつ詰まる/アニメーションが細切れになる。

  • たまに長く止まる
    1〜3秒以上の停止が、たまに出る。
    例:クリック後に画面が固まる/操作受付が戻るまで待たされる/ウィンドウが無反応に見える。

  • 操作時だけ止まる
    特定操作(画像表示・圧縮・読込・エクスポート等)の瞬間だけ止まり、普段は軽い。

  • じわじわ遅くなる
    起動直後は軽いが、時間経過や作業量の増加で少しずつ重くなる。


0.3 最初に広げないこと

0.2章 の表 で当てはまる行を選び、右列のリンク先へ進む。
動きが重いときほど、最初に手を広げると時間が溶けやすい。

  • いきなりコード全体へ入る
  • いきなり最適化の書き換えへ入る
  • いきなり GC の設定やチューニングへ飛ぶ
  • いきなり「世代」や「LOH」だけを原因名として追う

1. 9割はGCじゃないの意味

ここでの「9割」は統計ではなく、現場で頻出する傾向をまとめた目安。
GC は数値として見えるため最初に確認されやすい。
ただし起点は多くの場合、次のどれかになりやすい。

  • 割り当てが多い
  • 長く残る
  • サイズが大きい

つまり GC は原因名というより、状況を映す指標として表面に出やすい


2. 最初に見る3指標と全体像

遅さ ≒ 生成量 × 回収までの時間(+サイズ)
まず3指標を確認 → GCは裏付け確認に使う。


3. 原因特定の手順

  1. 割り当て量
  2. 回収後に残る量
  3. サイズ
  4. GC挙動(整合チェック)

4. 数値と原因の対応

数値が増えている方向で、疑いを決める。

指標 原因方向
Allocation Rate増 生成量が多い
GC Heap Size増 回収されにくい
LOH Size増 サイズが大きい
Gen 0 GC Count増 短命生成が増える
Gen 2 GC Count増 長く残る or 大きい確保

5. 原因箇所特定に使うツール

次の段階で「発生元」や「参照の根」を特定する。

ツール 用途
Visual Studio Profiler 生成元特定
PerfView ヒープ内訳確認

6. 世代とLOHは原因名ではない

世代や LOH は原因ではなく、状態の分類ラベル。
仕様: https://learn.microsoft.com/ja-jp/dotnet/standard/garbage-collection/large-object-heap

区分 意味
Gen0 すぐ消える
Gen1 中間
Gen2 長く残る
LOH 大きい

7. よく出る原因① 文字列連結 ループ内

症状
件数が増えるほど、処理がじわっと遅くなる。
ログ生成・CSV生成・UI文字列組み立てで出やすい。


何が起きるか
string は不変。+= は毎回「新しい string を作ってコピーする」動きになる。
連結回数に比例して 割り当て量とコピー量 が膨らむ。


計測での見え方

  • Allocation Rate が上がる
  • Gen0 の回転が増えやすい
  • Allocation 上位に System.String が並びやすい
using System;

// 連結回数ぶん string を作り直す(コピーも増える)
var s = "";
for (int i = 0; i < n; i++)
    s += items[i];

次の1手
Profiler で Allocation の発生元を辿り、+= が集中する呼び出し点を 1 か所へ絞る。


改善

  • ループで連結:StringBuilder
  • 区切り結合:string.Join
  • 断片が少ない:補間や Concat でも影響が出にくい場面がある(回数が増える場合は StringBuilder が安定)
using System.Text;

// string の確定を最後の1回にする
var sb = new StringBuilder();
for (int i = 0; i < n; i++)
    sb.Append(items[i]);

var s2 = sb.ToString();

8. よく出る原因② LINQ 具象化(ToList / ToArray)

症状
クエリ自体は軽そうでも、呼び出し回数が増えると遅くなる。
UIの更新周期・集計ループで出やすい。


何が起きるか
ToList() / ToArray() は結果を全部メモリに載せるために List<T> / 配列を確保する。
「具象化を繰り返す」構造だと 割り当て量が回数で積み上がる


計測での見え方

  • Allocation Rate が上がる
  • List<T> / 配列 / iterator / delegate が Allocation 上位に出やすい
  • 結果が大きい場合は LOH に入りやすい
// 具象化が発生する(結果を全部 materialize)
var xs = query.Where(x => x.Hot).ToList();

次の1手
具象化の呼び出し回数(どの周期・どのイベントで何回)を数え、ホットな呼び出し点を 1 本へ絞る。


改善

  • 「毎回 ToList」:外側で 1 回だけ作る/更新周期を落とす/差分更新へ切り替える
  • 「保持が目的」:上限(最大件数)を決める/スナップショットを使い回す
  • 「列挙できれば足りる」:具象化を避ける(foreach で回す)

9. よく出る原因③ 例外多発

症状
入力が荒い/外部データが入る場面で、急に CPU が上がる。
ログも増えがち。


何が起きるか
例外は「分岐」ではなく、生成・スタックトレース構築などの処理を含む。
発生回数が増えると CPU と割り当てが同時に増えやすい


計測での見え方

  • System.Exception 周辺や関連文字列が Allocation 上位に出やすい
  • CPU 使用率と Allocation Rate が同時に上がる傾向

次の1手
例外の発火点(どの入力・どの分岐で増えるか)をログやカウンタで切り分け、頻発ルートを 1 本へ絞る。


改善

  • 例外を通常制御にしない:TryParse / TryGetValue を使う
  • 入力の事前バリデーションで落ち方を決める
  • エラー収集はサンプリングや集約で回数を抑える(例外オブジェクト自体を溜めない)
int v = int.TryParse(s, out var t) ? t : 0;

10. よく出る原因④ boxing

症状
軽い処理のはずが、繰り返し回数が増えるほどじわっと遅くなる。
UI更新やログ出力のループで影響が出やすい。


何が起きるか
値型が object / interface / params object[] へ渡る経路で boxing が入ると、小さい割り当てが大量発生しやすい。
回数が増えるほど Gen0 が忙しくなりやすい。


計測での見え方

  • 小さい Alloc が高頻度で出る(合計量も増える)
  • Gen0 の回転が増えやすい

確認箇所

  • object 引数 API
  • params object[]
  • interface 受け渡し(ホットパス)
object o = 123; // 代表例(boxing が入りやすい経路)

次の1手
Profiler で「割り当ての発生元」を辿り、object/interface/params に落ちる呼び出し点を特定する。


直し方

  • 文字列化が目的:ToString() の場所をまとめ、ログ API の引数型を見直す
  • interface 経由:ジェネリック化や型分岐で boxing 経路を減らす
  • params object[]:頻出呼び出しは専用オーバーロードへ分ける

11. よく出る原因⑤ IEnumerable 境界

症状
ロジックは単純でも、LINQ・iterator・interface をまたぐと重くなる。
ループ回数が増えるほど影響が出やすい。


何が起きるか
IEnumerable<T> 境界では、列挙子生成・MoveNext 呼び出し・デリゲート呼び出しなどの細いコストが積み上がる。
多段 Where/Select や iterator は境界回数が増えやすい。


計測での見え方

  • 列挙子やクロージャの Alloc が入ることがある(状況依存)
  • 回数が主戦場になりやすい

確認箇所

  • Where / Select 多段
  • iterator(yield return
  • interface 境界回数(IEnumerable<T> / IEnumerator<T>
IEnumerable<int> seq = list;
foreach (var x in seq) { }

次の1手
処理時間の上位に MoveNext / iterator が並ぶかを確認し、ホットな列挙チェーンを 1 本へ絞る。


直し方

  • ループ回数が大きい:LINQ をループへ書き換える/中間列挙を減らす
  • 境界が必要:List<T> / 配列へ一度まとめてから回す
  • iterator 多用:生成物の形をまとめる(必要なら一度だけ materialize)

12. 長く残る場合に疑う場所

症状
しばらく動かすとだんだん重くなる。
数時間後に急に遅くなる。
再起動で戻る。


何が起きるか
参照が切れないと回収されず、Gen2 側に残り続ける。
結果としてヒープが増え、以降の GC やページングのコストが増えやすい。


計測での見え方

  • GC Heap Size が下がりにくい(ピーク後に戻らない)
  • Gen 2 GC Count が増えやすい/Full GC が重い

確認箇所

static 参照
  • シングルトン
  • グローバル一覧
  • ConcurrentDictionary など
キャッシュ
  • 無制限
  • キーが増え続ける
  • 失効ルール無し
イベント購読解除漏れ

Publisher が長生きすると Subscriber が残り続ける。

タイマー/コールバック
  • System.Timers
  • DispatcherTimer
  • Task.Delay ループなど

次の1手
メモリスナップショットを 2 回取り、増え続ける型を確認する。
その後、参照の根(どこから掴まれているか)を辿って発火点を 1 つへ絞る。


改善

  • キャッシュに上限と失効(件数/TTL/LRU)を入れる
  • イベントは解除・弱参照(必要な箇所だけ)・購読スコープを短くする
  • static に置く参照を減らし、ライフサイクル境界(画面/セッション)で破棄する

13. サイズが原因になるケース LOH

症状
普段は軽いが、特定操作で一気に止まる。
画像・圧縮・通信バッファ・一括読込で出やすい。


何が起きるか
大きい確保が続くと、LOH(Large Object Heap)側が膨らむ。
GCやメモリ管理のコストが目立ちやすい。
断片化が入ると戻りにくくなる。


観測

  • LOH Size が上がる
  • 大きな byte[] / char[] / string が Allocation 上位に出る
// 例: 大きい確保(目安:85,000 bytes 以上は LOH に入りやすい)
var buf = new byte[200000];

次の1手
大きい配列/文字列の確保元を辿り、「どの操作で」「どのサイズが」「どの頻度で」発生しているかを決める。


改善

  • 繰り返し使うバッファ:ArrayPool<T> で借りて返す(返却は finally に入れる)
  • 要求サイズをある程度の単位で丸め、同程度の大きさを使い回しやすくする
  • 内容漏れ対策:必要な場面だけ clearArray:true(常時クリアは CPU 側の負担が出る)
using System;
using System.Buffers;

var pool = ArrayPool<byte>.Shared;
var buf2 = pool.Rent(200000);

try
{
    // buf2 を使う
}
finally
{
    pool.Return(buf2, clearArray: true);
}

14. GC数値は整合チェックに使う

0章の 最初に見る5項目 で方向を決め、この章は整合チェックとして見る。

状態 判断
Allocation Rate↑ + Gen 0 GC Count↑ 生成量が多い(短命が増えやすい)
GC Heap Size↑ + Gen 2 GC Count↑ 回収されにくい(長く残る/LOHが入る可能性)
LOH Size↑ サイズが大きい確保が入る

15. まとめ

遅さがGCに見えても、起点はほぼ 割り当て・寿命・サイズ
最初に3指標を確認すると、原因特定までの時間が短くなりやすい。


連載Index(読む順・公開済リンクが最新): S00_門前の誓い_総合Index

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?