「遅さ」の主因になりやすいのは GC そのものではなく、割り当て量・寿命・サイズが作る状況。
GC はその状況が数値として表に出た結果に出やすい。
先頭に 計測コマンドと早見表の置き先を先に決めておき、再調査でも同じ順で進められる形にする。
スクロールのカクつきでも、0.2章 の表で 5項目の増え方を見て行き先を決めると、原因候補が少ない手数で絞れる。
再調査でそのまま使えるよう、0章に「計測コマンド」と「最初に見る5項目 → 行き先」表を作成した。
このページの進み方
「C#が遅い」を、数値 → 方向 → 箇所の順で短い距離でつなぐための診断テンプレ。
- 最短で当たりを付ける: 0. 30秒切り分け
- 背景も含めて把握する: 1. 9割はGCじゃないの意味 → 2. 最初に見る3指標と全体像
- 当たりが付いた後:数値に対応する章(原因カード/長く残る/LOH/例外)へ合流する
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. 原因特定の手順
- 割り当て量
- 回収後に残る量
- サイズ
- 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.TimersDispatcherTimer-
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