C#チューニング完全ガイド|遅い処理を高速化する原因分析と実践テクニック

はじめに

C#アプリケーションの処理が遅いと、ユーザー体験の低下、サーバーコストの増加、バッチ処理時間の長期化、障害時の復旧遅延など、さまざまな問題につながります。特にWeb API、業務システム、データ処理、ゲーム、デスクトップアプリ、クラウドサービスでは、C#チューニングの重要性が高まります。

ただし、C#のチューニングは「とにかくコードを細かく書き換えること」ではありません。まず遅い原因を計測し、CPU・メモリ・GC・I/O・データベース・ネットワークなど、どこがボトルネックなのかを特定することが重要です。そのうえで、効果の大きい箇所から順番に改善していきます。

この記事では、C#チューニングの基本から、原因分析、LINQ、メモリ、GC、非同期処理、並列処理、データベース、Web API、主要ツール、実践チェックリストまで、遅い処理を高速化するための考え方と具体的なテクニックを解説します。

1. C#チューニングとは?まず押さえるべき基本

C#チューニングとは、C#で書かれたアプリケーションの処理速度、応答速度、メモリ使用量、CPU使用率、I/O効率などを改善する作業です。単に「速く動くコードを書く」だけでなく、ユーザーが体感する速度や、システム全体の安定性を改善することが目的です。

1-1. C#チューニングの目的は「体感速度」と「リソース効率」の改善

C#チューニングで最も重要なのは、ユーザーやシステムにとって意味のある改善を行うことです。たとえば、内部処理を100ミリ秒短縮できても、ユーザーが待っている画面表示に影響しなければ、優先度は高くないかもしれません。

一方で、APIのレスポンスが3秒から500ミリ秒に短縮される、夜間バッチが6時間から1時間になる、メモリ使用量が減ってサーバー台数を削減できる、といった改善は大きな価値があります。

C#チューニングの目的は、主に次の2つです。

1つ目は、体感速度の改善です。画面表示、APIレスポンス、検索、集計、ファイル出力など、ユーザーや他システムが待つ時間を短くします。

2つ目は、リソース効率の改善です。CPU、メモリ、ディスク、ネットワーク、データベース接続などの使用量を抑え、同じ環境でより多くの処理を安定して実行できるようにします。

1-2. 高速化すべき処理と高速化しなくてよい処理の見極め方

C#チューニングでは、すべてのコードを高速化しようとしないことが大切です。なぜなら、実行頻度が低い処理や、全体時間にほとんど影響しない処理を最適化しても、効果が小さいからです。

高速化すべき処理は、次のようなものです。

頻繁に実行される処理、ユーザーが待つ処理、大量データを扱う処理、CPUやメモリを大きく消費する処理、データベースや外部APIへのアクセスが多い処理、本番環境で遅延やタイムアウトの原因になっている処理です。

反対に、高速化しなくてよい処理もあります。たとえば、管理者だけが月に1回実行する処理、アプリ起動時に一度だけ実行される軽い処理、処理時間がすでに十分短いコードなどです。

チューニングは、可読性や保守性とのバランスも重要です。数ミリ秒の改善のために複雑で読みにくいコードにするより、まずはアルゴリズム、DBクエリ、I/O、GCなど、効果の大きい部分を見直すべきです。

1-3. C#アプリが遅くなる主な原因

C#アプリが遅くなる原因はさまざまですが、代表的なものは次のとおりです。

アルゴリズムの計算量が大きい、Listの線形検索を大量に繰り返している、不要なLINQやToListを多用している、オブジェクト生成が多くGCが頻発している、文字列結合で大量の一時オブジェクトを作っている、同期I/Oでスレッドをブロックしている、データベースでN+1問題が発生している、必要以上のカラムや行を取得している、HttpClientを不適切に生成している、ログ出力が多すぎる、例外処理を通常フローに使っている、正規表現やシリアライズ処理が重い、などです。

特に多いのは、アプリケーションコードだけを見て「C#が遅い」と判断してしまうケースです。実際には、データベースクエリ、ネットワーク待ち、ファイルI/O、外部API、GC、一部の重いLINQ処理などが原因であることがよくあります。

1-4. .NETのバージョン差がパフォーマンスに与える影響

C#のパフォーマンスは、使用している.NETのバージョンによって大きく変わることがあります。.NET Frameworkから.NET Core、さらに.NET 5以降のモダンな.NETでは、JITコンパイラ、GC、標準ライブラリ、Span<T>、System.Text.Json、非同期処理など、多くの領域で改善が進んでいます。

古い.NET Frameworkで動いているアプリを新しい.NETへ移行するだけで、コードを大きく変えなくても処理速度やメモリ効率が改善する場合があります。ただし、移行には互換性確認が必要です。特に、古いライブラリ、Windows依存API、ASP.NET MVC、WCF、AppDomain、古いORMなどを使っている場合は注意が必要です。

C#チューニングでは、コード単体の改善だけでなく、実行環境、ランタイム、フレームワーク、ライブラリのバージョンも確認しましょう。

2. C#の処理が遅い原因を特定する分析手順

C#チューニングで最も避けるべきなのは、推測だけでコードを書き換えることです。「LINQが遅そう」「DBが原因だろう」「GCが重いはず」と考えても、実際に計測すると別の場所が原因だった、ということはよくあります。

2-1. 推測で改善しないためのパフォーマンス計測の基本

パフォーマンス改善は、次の流れで進めます。

まず、現状の処理時間を計測します。次に、どこに時間がかかっているかを分解します。その後、最も影響が大きいボトルネックを改善します。最後に、改善前後を同じ条件で比較します。

単純な処理時間の確認であれば、Stopwatchを使えます。

C#
var sw = Stopwatch.StartNew();

ExecuteTargetProcess();

sw.Stop();
Console.WriteLine($"Elapsed: {sw.ElapsedMilliseconds} ms");

ただし、Stopwatchだけでは正確なベンチマークには不十分な場合があります。JITコンパイル、ウォームアップ、GC、CPU状態、データ量の違いなどによって結果がぶれます。小さな処理の比較には、BenchmarkDotNetを使うのが基本です。

2-2. CPU負荷・メモリ使用量・GC・I/O待ちの切り分け

処理が遅いときは、まずボトルネックの種類を切り分けます。

CPU使用率が高い場合は、計算処理、ループ、シリアライズ、暗号化、画像処理、正規表現、集計処理などが原因かもしれません。

メモリ使用量が増え続ける場合は、大量のオブジェクト生成、キャッシュの肥大化、解放漏れ、イベントハンドラの解除漏れ、IDisposableの未解放などを疑います。

GCの発生回数や停止時間が多い場合は、短命オブジェクトを大量に作っている、巨大オブジェクトを頻繁に生成している、不要なToListや文字列結合が多い、といった可能性があります。

CPU使用率が低いのに処理が遅い場合は、I/O待ちの可能性があります。データベース、ファイル、ネットワーク、外部API、ロック待ちなどを確認しましょう。

2-3. Visual Studio Profilerでボトルネックを見つける方法

Visual Studio Profilerは、C#アプリのパフォーマンス分析に使える代表的なツールです。CPU使用率、メモリ使用量、割り当て、データベースアクセス、UI応答性などを確認できます。

使い方の基本は、Visual Studioで対象プロジェクトを開き、パフォーマンスプロファイラーを起動し、CPU使用率やメモリ使用量などの分析項目を選んで実行します。その後、実際に遅い操作を再現し、収集された結果から時間のかかっているメソッドを確認します。

重要なのは、実際に遅い操作を再現することです。プロファイラーを起動しただけでは、原因は見つかりません。ユーザーが遅いと感じている画面操作、APIリクエスト、バッチ処理などを同じ条件で実行し、ホットパスを特定します。

2-4. BenchmarkDotNetで処理速度を正確に比較する方法

BenchmarkDotNetは、C#の小さな処理を正確に比較するための定番ライブラリです。JITのウォームアップ、繰り返し実行、統計情報の出力、メモリ割り当ての計測などを自動で行えます。

たとえば、LINQとfor文の処理速度を比較する場合は、次のように書けます。

C#
[MemoryDiagnoser]
public class SumBenchmark
{
private readonly int[] _numbers = Enumerable.Range(1, 10000).ToArray();

[Benchmark]
public int LinqSum()
{
return _numbers.Where(x => x % 2 == 0).Sum();
}

[Benchmark]
public int ForSum()
{
var sum = 0;
for (int i = 0; i < _numbers.Length; i++)
{
var x = _numbers[i];
if (x % 2 == 0)
{
sum += x;
}
}
return sum;
}
}

BenchmarkDotNetを使うと、平均時間だけでなく、メモリ割り当て量も確認できます。C#チューニングでは、速度だけでなく、GCに影響する割り当て量も重要です。

2-5. ログ・メトリクス・APMを使った本番環境の原因分析

開発環境では問題がなくても、本番環境でだけ遅くなることがあります。データ量、同時アクセス数、ネットワーク遅延、DB負荷、外部APIの応答時間、コンテナやVMのリソース制限などが異なるためです。

本番環境では、ログ、メトリクス、APMを使って原因を分析します。APIごとのレスポンスタイム、エラー率、DBクエリ時間、外部API呼び出し時間、GC回数、CPU使用率、メモリ使用量、スレッドプールの状態などを継続的に確認します。

Application Insights、OpenTelemetry、Datadog、New Relic、Grafana、Prometheusなどを使うと、C#アプリのパフォーマンスを可視化できます。

3. アルゴリズムとデータ構造によるC#チューニング

C#チューニングで最も効果が大きいのは、細かい構文の書き換えよりも、アルゴリズムとデータ構造の改善です。処理件数が少ないうちは問題なくても、データが増えると急激に遅くなるコードは少なくありません。

3-1. 処理速度を大きく左右する計算量の考え方

計算量とは、データ件数が増えたときに処理量がどれくらい増えるかを表す考え方です。

たとえば、1,000件なら問題ない処理でも、100万件になると急激に遅くなる場合があります。特に注意すべきなのは、ネストしたループです。

C#
foreach (var user in users)
{
foreach (var order in orders)
{
if (order.UserId == user.Id)
{
// 処理
}
}
}

usersが10,000件、ordersが100,000件あると、最大で10億回の比較が発生します。このような処理は、DictionaryやLookupを使って検索回数を減らすだけで大幅に高速化できます。

3-2. List・Dictionary・HashSetの使い分け

C#では、データ構造の選び方がパフォーマンスに大きく影響します。

List<T>は、順番にデータを保持したい場合に便利です。ただし、ContainsやFindで検索すると基本的に先頭から順番に探すため、件数が多いと遅くなります。

Dictionary<TKey, TValue>は、キーから値を高速に取得したい場合に向いています。IDからユーザー情報を取得する、コードから設定値を取得する、といった用途に適しています。

HashSet<T>は、値の存在確認や重複排除に向いています。大量データに対してContainsを何度も行う場合、List<T>よりHashSet<T>のほうが有利です。

C#
var targetIds = new HashSet<int>(ids);

foreach (var item in items)
{
if (targetIds.Contains(item.Id))
{
// 高速な存在確認
}
}

大量データの検索や重複チェックでは、List<T>を使い続けるのではなく、Dictionary<TKey, TValue>やHashSet<T>に変換できないかを検討しましょう。

3-3. ループ処理を高速化する書き方

C#のループ処理では、まず不要な処理をループの外に出すことが基本です。ループ内で毎回同じ値を計算している場合は、事前に計算しておきます。

C#
var threshold = GetThreshold();

foreach (var item in items)
{
if (item.Score >= threshold)
{
// 処理
}
}

また、ループ内で不要なメソッド呼び出し、文字列結合、LINQ、例外処理、ログ出力を行っていないか確認しましょう。

配列やList<T>を単純に処理する場合は、foreachでも十分高速ですが、極端に高頻度なホットパスではfor文のほうが制御しやすい場合があります。ただし、可読性を犠牲にしてまで細かい最適化をする前に、まず計算量とデータ構造を見直すべきです。

3-4. ネストしたループや重複検索を減らす改善例

次のように、ユーザーごとに注文を検索する処理があるとします。

C#
foreach (var user in users)
{
var userOrders = orders.Where(o => o.UserId == user.Id).ToList();
user.TotalAmount = userOrders.Sum(o => o.Amount);
}

このコードは、ユーザー数分だけorders全体を走査します。データ量が増えると遅くなります。

改善例は次のとおりです。

C#
var ordersByUserId = orders
.GroupBy(o => o.UserId)
.ToDictionary(g => g.Key, g => g.Sum(o => o.Amount));

foreach (var user in users)
{
user.TotalAmount = ordersByUserId.TryGetValue(user.Id, out var total)
? total
: 0;
}

このように、あらかじめDictionaryにまとめておくと、検索コストを大きく減らせます。C#チューニングでは、「同じコレクションを何度も検索していないか」を確認するだけでも、多くの改善ポイントが見つかります。

3-5. ソート・検索・集計処理の見直しポイント

ソートはコストが高い処理です。OrderByを何度も呼んでいる場合や、必要のない場所でソートしている場合は見直しましょう。最終表示にだけ順序が必要であれば、途中処理ではソートしないほうが効率的です。

検索処理では、List<T>.ContainsやFirstOrDefaultを大量に繰り返していないか確認します。ID検索が多いならDictionary、存在確認が多いならHashSetを使います。

集計処理では、同じ条件で何度もWhereやSumを実行していないか確認します。一度のループで複数の集計ができる場合もあります。

C#
var count = 0;
var total = 0m;

foreach (var order in orders)
{
if (order.Status == OrderStatus.Completed)
{
count++;
total += order.Amount;
}
}

LINQで書くと読みやすい一方、複数回走査している場合があります。大量データでは、一度の走査にまとめることも有効です。

4. LINQのパフォーマンス改善テクニック

LINQはC#の生産性を大きく高める便利な機能です。しかし、使い方によっては処理が遅くなったり、不要なメモリ割り当てが増えたりします。C#チューニングでは、LINQを禁止するのではなく、遅くなりやすいパターンを理解して適切に使うことが重要です。

4-1. LINQが遅くなる典型パターン

LINQが遅くなる典型例は、同じコレクションに対して何度もWhere、FirstOrDefault、Count、Any、Sumなどを繰り返すケースです。

C#
foreach (var category in categories)
{
var count = products.Count(p => p.CategoryId == category.Id);
}

このコードは、カテゴリごとにproductsを走査します。件数が多い場合は、事前にGroupByしておくほうが効率的です。

また、ToListやToArrayを必要以上に呼ぶ、OrderByを中間処理で何度も呼ぶ、Selectで匿名オブジェクトを大量に生成する、LINQチェーンが長くなりすぎて処理内容が見えにくくなる、といった点にも注意が必要です。

4-2. 遅延実行と即時実行を理解する

LINQの多くのメソッドは遅延実行です。WhereやSelectは、その時点では実際に処理されず、foreachやToList、ToArray、Count、Sumなどで列挙されたときに実行されます。

C#
var query = items.Where(x => x.IsActive);

// ここで初めて実行される
foreach (var item in query)
{
Console.WriteLine(item.Name);
}

遅延実行は便利ですが、同じqueryを複数回列挙すると、そのたびに処理が実行されます。

C#
var query = items.Where(x => x.IsActive);

var count = query.Count();
var list = query.ToList();

この場合、Whereの条件判定が2回実行されます。1回だけ実行して結果を使い回したいなら、ToListで明示的に確定させる選択もあります。

4-3. ToList・ToArrayの不要な呼び出しを減らす

ToListやToArrayは、LINQの結果をメモリ上に展開します。必要な場面では便利ですが、不要に呼び出すとメモリ使用量が増え、GCの負荷も高くなります。

悪い例は次のようなコードです。

C#
var names = users
.Where(u => u.IsActive)
.ToList()
.Select(u => u.Name)
.ToList();

途中のToListは不要です。

C#
var names = users
.Where(u => u.IsActive)
.Select(u => u.Name)
.ToList();

ToListは、結果を複数回使う場合、DBクエリを確定させたい場合、後続でコレクションを変更する可能性がある場合などに使います。何となく付けるのではなく、必要性を確認しましょう。

4-4. Where・Select・OrderByの使い方を見直す

Where、Select、OrderByの順番もパフォーマンスに影響します。基本的には、データ件数を減らすWhereを先に行い、必要な形に変換するSelectを後に行います。

C#
var result = users
.Where(u => u.IsActive)
.Select(u => new UserDto
{
Id = u.Id,
Name = u.Name
})
.ToList();

OrderByはコストが高いため、可能な限り対象件数を絞ってから実行します。

C#
var result = products
.Where(p => p.IsPublished)
.OrderByDescending(p => p.CreatedAt)
.Take(20)
.ToList();

Entity Framework Coreを使っている場合は、LINQがSQLに変換されます。そのため、ToListを早い段階で呼ぶと、DB側で絞り込めるはずの処理がアプリ側に移動してしまうことがあります。

4-5. LINQをfor文に置き換えるべきケース

LINQは読みやすさに優れていますが、すべての場面で最速ではありません。次のようなケースでは、for文やforeachへの置き換えを検討します。

大量データを高頻度で処理する場合、メモリ割り当てを極力減らしたい場合、複数の集計を一度のループで行いたい場合、ホットパスでBenchmarkDotNetによりLINQがボトルネックだと確認できた場合です。

たとえば、複数の条件集計をLINQで別々に行うと、コレクションを何度も走査することがあります。

C#
var activeCount = users.Count(u => u.IsActive);
var inactiveCount = users.Count(u => !u.IsActive);

一度のループにまとめると、走査回数を減らせます。

C#
var activeCount = 0;
var inactiveCount = 0;

foreach (var user in users)
{
if (user.IsActive)
activeCount++;
else
inactiveCount++;
}

ただし、LINQをfor文に置き換えるのは、計測して効果が確認できる場合に限定するのが望ましいです。

5. メモリ使用量とGCを意識したC#チューニング

C#はガベージコレクションによってメモリ管理を自動化しています。しかし、自動だからといってメモリを気にしなくてよいわけではありません。大量のオブジェクト生成や不要なメモリ割り当ては、GCの増加や一時停止につながり、処理速度を低下させます。

5-1. メモリ確保が処理速度に与える影響

C#では、newでオブジェクトを生成するとメモリが確保されます。短命オブジェクトであればGCにより回収されますが、生成数が多いとGCの頻度が増えます。

特に、ループ内で大量の文字列や一時オブジェクトを生成する処理は注意が必要です。

C#
foreach (var item in items)
{
var message = $"ID:{item.Id}, Name:{item.Name}";
logs.Add(message);
}

この程度であれば問題にならないことも多いですが、数百万回実行される処理やリアルタイム性が必要な処理では、割り当て量がボトルネックになる可能性があります。

5-2. 不要なインスタンス生成を減らす

不要なインスタンス生成を減らすには、ループ内で毎回同じオブジェクトを作っていないか確認します。

C#
foreach (var item in items)
{
var comparer = StringComparer.OrdinalIgnoreCase;
// 処理
}

このような値はループ外に出せます。

C#
var comparer = StringComparer.OrdinalIgnoreCase;

foreach (var item in items)
{
// 処理
}

また、コレクションの容量が事前に分かっている場合は、List<T>の初期容量を指定すると、内部配列の再確保を減らせます。

C#
var result = new List<UserDto>(users.Count);

小さな改善に見えますが、大量データ処理では効果が出ることがあります。

5-3. string結合はStringBuilderを使うべきか

C#では、stringは不変です。そのため、ループ内で文字列を何度も結合すると、そのたびに新しい文字列が生成されます。

C#
var text = "";

foreach (var item in items)
{
text += item.Name + ",";
}

このような処理では、StringBuilderを使うのが基本です。

C#
var sb = new StringBuilder();

foreach (var item in items)
{
sb.Append(item.Name);
sb.Append(',');
}

var text = sb.ToString();

ただし、少数の文字列結合であれば、文字列補間や+演算子でも問題ありません。StringBuilderを使うべきなのは、ループ内で繰り返し結合する場合や、大きな文字列を段階的に作る場合です。

5-4. Span・Memoryを使ったメモリ効率化

Span<T>やMemory<T>は、配列や文字列などの連続したメモリ領域をコピーせずに扱うための仕組みです。大量の文字列処理、バイナリ処理、パース処理、I/O処理などでメモリ割り当てを減らせます。

たとえば、文字列の一部をSubstringで取り出すと、新しい文字列が生成されます。一方、ReadOnlySpan<char>を使うと、元の文字列を参照したまま一部を扱えます。

C#
ReadOnlySpan<char> span = text.AsSpan();
var part = span.Slice(0, 5);

Span<T>は強力ですが、扱いには制約があります。通常の業務アプリでは必須ではありませんが、パフォーマンスが重要な文字列解析、CSV処理、プロトコル処理、バイナリ処理では有効な選択肢です。

5-5. GC発生回数を減らす設計と実装のポイント

GC発生回数を減らすには、まず不要な割り当てを減らすことが重要です。具体的には、ループ内の一時オブジェクト生成を減らす、ToListやToArrayを必要な場所だけにする、大量の文字列結合を避ける、キャッシュのサイズを管理する、巨大オブジェクトの頻繁な生成を避ける、といった対応があります。

また、オブジェクトプールを使う方法もあります。頻繁に使い回すバッファや一時オブジェクトは、ArrayPool<T>やObjectPoolを検討できます。

C#
var pool = ArrayPool<byte>.Shared;
var buffer = pool.Rent(4096);

try
{
// bufferを使った処理
}
finally
{
pool.Return(buffer);
}

ただし、プールは実装を複雑にします。通常の処理では、まず不要な生成を減らすことから始めましょう。

5-6. IDisposableとusingによるリソース解放

ファイル、ネットワーク、DB接続、Stream、HttpResponseMessageなど、アンマネージリソースや外部リソースを扱うオブジェクトは、適切に解放する必要があります。

C#
using var stream = File.OpenRead(path);
// streamを使った処理

usingを使えば、スコープを抜けるとDisposeが呼ばれます。Dispose漏れがあると、ファイルハンドルや接続が解放されず、パフォーマンス低下や障害につながります。

非同期で解放する必要がある場合は、await usingを使います。

C#
await using var stream = File.OpenRead(path);

C#チューニングでは、速度だけでなく、リソースを適切に解放して安定稼働させることも重要です。

6. 非同期処理・並列処理による高速化

C#では、async/await、Task、Parallelなどを使って非同期処理や並列処理を実装できます。ただし、非同期処理と並列処理は目的が異なります。正しく使い分けなければ、逆に遅くなったり、不安定になったりします。

6-1. async/awaitで改善できる処理とできない処理

async/awaitは、主にI/O待ちの効率化に向いています。データベースアクセス、HTTP通信、ファイルI/O、外部API呼び出しなど、待ち時間が発生する処理で効果があります。

一方、CPUで重い計算をしている処理は、async/awaitにしただけでは速くなりません。

C#
public async Task<string> GetDataAsync()
{
return await httpClient.GetStringAsync(url);
}

このようなI/O処理では、待っている間にスレッドを解放できるため、WebアプリやAPIサーバーではスループット改善につながります。

6-2. I/Oバウンド処理を非同期化する

I/Oバウンド処理では、同期メソッドではなく非同期メソッドを使います。

C#
var content = await File.ReadAllTextAsync(path);

データベースアクセスでも、Entity Framework CoreのToListAsync、FirstOrDefaultAsync、SaveChangesAsyncなどを使います。

C#
var users = await dbContext.Users
.Where(u => u.IsActive)
.ToListAsync();

ASP.NET Coreでは、同期I/Oでスレッドをブロックすると、同時リクエストが増えたときにスレッドプールが不足しやすくなります。I/O待ちが多い処理は、できるだけ非同期化しましょう。

6-3. CPUバウンド処理をParallelやTaskで並列化する

CPUバウンド処理は、複数のCPUコアを使って並列化することで高速化できる場合があります。たとえば、大量データの計算、画像処理、暗号化、圧縮、解析処理などです。

C#
Parallel.ForEach(items, item =>
{
ProcessItem(item);
});

ただし、並列化すれば必ず速くなるわけではありません。処理が軽すぎる場合、並列化のオーバーヘッドのほうが大きくなります。また、共有変数へのロックが多い場合も遅くなります。

CPUバウンド処理を並列化する場合は、CPUコア数、処理単位の重さ、共有リソース、例外処理、キャンセル制御を考慮しましょう。

6-4. Task.WhenAllで複数処理を効率化する

複数の独立した非同期処理を順番にawaitしている場合、Task.WhenAllで同時に待つことで全体時間を短縮できます。

悪い例です。

C#
var user = await GetUserAsync();
var orders = await GetOrdersAsync();
var notifications = await GetNotificationsAsync();

それぞれが独立しているなら、次のようにできます。

C#
var userTask = GetUserAsync();
var ordersTask = GetOrdersAsync();
var notificationsTask = GetNotificationsAsync();

await Task.WhenAll(userTask, ordersTask, notificationsTask);

var user = await userTask;
var orders = await ordersTask;
var notifications = await notificationsTask;

外部APIやDBに対して同時アクセスが増えるため、接続数やレート制限には注意が必要です。

6-5. 並列処理で遅くなるケースと注意点

並列処理で遅くなる代表的なケースは、処理単位が小さすぎる場合、ロック競合が多い場合、DBや外部APIに同時アクセスしすぎる場合、メモリ帯域がボトルネックになる場合、例外やキャンセル制御が不十分な場合です。

たとえば、DB更新をParallel.ForEachで大量に並列実行すると、DB接続プールやロックが詰まり、全体として遅くなることがあります。

並列度を制限したい場合は、ParallelOptionsやSemaphoreSlimを使います。

C#
var semaphore = new SemaphoreSlim(5);

var tasks = items.Select(async item =>
{
await semaphore.WaitAsync();
try
{
await ProcessAsync(item);
}
finally
{
semaphore.Release();
}
});

await Task.WhenAll(tasks);

並列処理は強力ですが、システム全体の負荷を考えて使う必要があります。

6-6. デッドロックやスレッド枯渇を防ぐ実装

非同期処理でよくある問題が、ResultやWaitによる同期ブロックです。

C#
var result = GetDataAsync().Result;

このようなコードは、デッドロックやスレッド枯渇の原因になることがあります。基本的には、asyncメソッドは呼び出し元までasync/awaitでつなげます。

C#
var result = await GetDataAsync();

また、ASP.NET Coreでは、長時間のCPU処理をリクエストスレッド上で実行すると、同時アクセス時に応答性が落ちることがあります。重い処理はバックグラウンドジョブやキューに分離する設計も検討しましょう。

7. データベースアクセスのC#チューニング

C#アプリの遅さの原因として非常に多いのが、データベースアクセスです。アプリ側のコードが遅いように見えても、実際にはSQL、インデックス、取得件数、N+1問題、接続プールなどがボトルネックになっていることがあります。

7-1. アプリ側が遅いのかDB側が遅いのかを切り分ける

まず、処理時間をアプリ側とDB側に分けて計測します。API全体が3秒かかっている場合、DBクエリが2.8秒なのか、アプリ内の変換処理が2.8秒なのかで対応が変わります。

Entity Framework Coreを使っている場合は、生成されるSQLや実行時間をログで確認します。SQL Server、PostgreSQL、MySQLなどのDB側でも、実行計画、インデックス使用状況、ロック待ち、スロークエリを確認しましょう。

アプリ側ではStopwatchやAPM、DB側ではクエリ分析ツールを使い、どちらに原因があるかを切り分けます。

7-2. Entity Framework Coreで発生しやすい遅い処理

Entity Framework Coreで遅くなりやすい処理には、必要以上のデータ取得、N+1問題、Includeの多用、ToListのタイミングミス、変更追跡の不要な使用、クライアント評価、巨大なLINQクエリなどがあります。

読み取り専用の処理では、AsNoTrackingを使うと変更追跡のコストを減らせます。

C#
var users = await dbContext.Users
.AsNoTracking()
.Where(u => u.IsActive)
.ToListAsync();

また、エンティティ全体ではなくDTOに必要な項目だけ投影することも重要です。

C#
var users = await dbContext.Users
.AsNoTracking()
.Select(u => new UserDto
{
Id = u.Id,
Name = u.Name
})
.ToListAsync();

7-3. N+1問題の原因と解決策

N+1問題とは、最初に1回のクエリで親データを取得し、その後、各親データごとに子データを取得してしまう問題です。たとえば、100件のユーザーを取得したあと、ユーザーごとに注文を取得すると、合計101回のクエリが発生します。

解決策としては、Includeで関連データをまとめて取得する、Selectで必要な形に投影する、GroupJoinを使う、明示的に一括取得してアプリ側でDictionaryにまとめる、などがあります。

C#
var users = await dbContext.Users
.AsNoTracking()
.Select(u => new UserDto
{
Id = u.Id,
Name = u.Name,
OrderCount = u.Orders.Count
})
.ToListAsync();

N+1問題は、本番データが増えてから急激に表面化することがあります。開発時からSQLログを確認する習慣を持ちましょう。

7-4. 必要なカラムだけ取得して転送量を減らす

データベースからエンティティ全体を取得すると、不要なカラムまで転送されます。特に、説明文、JSON、画像パス、大きなテキスト、バイナリデータなどを含むテーブルでは、転送量が増えて遅くなります。

一覧画面やAPIレスポンスでは、必要なカラムだけSelectで取得しましょう。

C#
var products = await dbContext.Products
.AsNoTracking()
.Where(p => p.IsPublished)
.Select(p => new ProductListDto
{
Id = p.Id,
Name = p.Name,
Price = p.Price
})
.ToListAsync();

不要なデータを取得しないことは、DB負荷、ネットワーク転送量、アプリ側メモリ使用量のすべてを減らす効果があります。

7-5. インデックス・クエリ・接続プールの見直し

DBチューニングでは、インデックスの確認が重要です。Where、Join、OrderByでよく使うカラムに適切なインデックスがないと、フルスキャンが発生して遅くなります。

ただし、インデックスを増やせばよいわけではありません。インデックスが多すぎると、INSERT、UPDATE、DELETEが遅くなります。実行計画を確認し、必要なインデックスを設計しましょう。

また、接続プールも確認が必要です。接続を適切に解放していない、同時アクセスが多すぎる、長時間トランザクションを保持している、といった問題があると、アプリ側で待ち時間が発生します。

7-6. 非同期DBアクセスで待ち時間を削減する

WebアプリやAPIでは、DBアクセスを非同期化することで、待ち時間中にスレッドを解放できます。

C#
var order = await dbContext.Orders
.AsNoTracking()
.FirstOrDefaultAsync(o => o.Id == orderId);

非同期化によって1件のクエリそのものが速くなるわけではありません。しかし、同時リクエストが多い環境では、スレッドプールの効率が上がり、スループット改善につながります。

ただし、DBに対する同時アクセスを無制限に増やすと、DB側が詰まります。アプリとDBの両方を見ながら調整しましょう。

8. ファイル・ネットワークI/Oのチューニング

ファイル読み書きやネットワーク通信は、CPU処理とは異なり、待ち時間が大きくなりやすい領域です。C#チューニングでは、I/Oを効率化し、不要なブロックや転送量を減らすことが重要です。

8-1. ファイル読み書きが遅い原因

ファイルI/Oが遅い原因には、ファイルを小刻みに読み書きしている、バッファが小さすぎる、同期I/Oでスレッドをブロックしている、大きなファイルを一度にメモリへ読み込んでいる、不要な文字コード変換をしている、ログ出力が過剰、ディスク自体が遅い、ネットワークドライブを使っている、などがあります。

小さなファイルならFile.ReadAllTextやFile.ReadAllLinesでも問題ありませんが、大きなファイルではStreamを使って少しずつ処理するほうが安定します。

8-2. Streamを使った効率的な入出力

大きなファイルを扱う場合は、Streamを使って読み書きします。

C#
using var input = File.OpenRead(inputPath);
using var output = File.Create(outputPath);

await input.CopyToAsync(output);

テキストファイルを1行ずつ処理する場合は、StreamReaderを使います。

C#
using var reader = new StreamReader(path);

while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync();
// 1行ずつ処理
}

大きなファイルをすべてメモリに読み込むと、メモリ使用量が増え、GCやOutOfMemoryExceptionの原因になります。処理内容に応じて、ストリーミング処理を検討しましょう。

8-3. バッファサイズの調整ポイント

Streamでは、バッファサイズによってI/O効率が変わることがあります。バッファが小さすぎると読み書き回数が増え、オーバーヘッドが大きくなります。一方で、大きすぎるバッファはメモリを無駄に使います。

多くの場合、標準のバッファサイズで十分ですが、大量ファイル処理や高スループットが必要な処理では、バッファサイズを変えてベンチマークする価値があります。

C#
var bufferSize = 81920;

using var input = new FileStream(inputPath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize, useAsync: true);
using var output = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize, useAsync: true);

await input.CopyToAsync(output);

バッファサイズは、環境やファイルサイズによって最適値が変わるため、推測ではなく計測して判断します。

8-4. HttpClientの正しい使い方

C#でHTTP通信を行う場合、HttpClientの使い方は非常に重要です。リクエストごとにHttpClientをnewして使い捨てると、ソケット枯渇や接続再利用の問題につながることがあります。

ASP.NET Coreでは、IHttpClientFactoryを使うのが一般的です。

C#
builder.Services.AddHttpClient("ExternalApi", client =>
{
client.BaseAddress = new Uri("https://api.example.com/");
client.Timeout = TimeSpan.FromSeconds(10);
});

利用側では、ファクトリからHttpClientを取得します。

C#
var client = httpClientFactory.CreateClient("ExternalApi");
var response = await client.GetAsync("users");

HttpClientを適切に再利用することで、接続確立のコストを減らし、安定した通信ができます。

8-5. タイムアウト・リトライ・接続再利用の最適化

ネットワークI/Oでは、タイムアウト、リトライ、接続再利用が重要です。タイムアウトが長すぎると、障害時にスレッドや接続が長時間保持されます。短すぎると、一時的な遅延でも失敗します。

リトライは有効ですが、無制限に実行すると相手サービスや自システムに負荷をかけます。指数バックオフやジッターを使い、適切な回数に制限しましょう。

また、外部APIのレスポンスが遅い場合、すべてをアプリ側のC#チューニングで解決できるわけではありません。キャッシュ、非同期ジョブ化、タイムアウト設計、フォールバックなども含めて検討します。

9. Webアプリ・APIにおけるC#チューニング

ASP.NET Coreなどで作られたWebアプリやAPIでは、レスポンスタイム、スループット、同時接続数、メモリ使用量が重要です。アプリケーションコードだけでなく、ミドルウェア、DB、キャッシュ、JSON処理、ログ、外部APIなどを総合的に見る必要があります。

9-1. ASP.NET Coreでレスポンスが遅くなる原因

ASP.NET Coreでレスポンスが遅くなる原因には、DBクエリが遅い、外部API待ちが長い、同期処理でスレッドをブロックしている、JSONシリアライズが重い、ミドルウェアが多い、認証・認可処理が重い、ログ出力が過剰、キャッシュが使われていない、例外が多発している、などがあります。

まずは、リクエスト全体の処理時間を分解しましょう。コントローラー、サービス層、DB、外部API、シリアライズ、ミドルウェアのどこに時間がかかっているかを確認します。

9-2. ミドルウェア構成の見直し

ASP.NET Coreのミドルウェアは、リクエストごとに実行されます。そのため、不要なミドルウェアや重い処理が入っていると、すべてのリクエストに影響します。

開発環境専用のミドルウェアが本番で有効になっていないか、静的ファイルやヘルスチェックにも不要な認証処理やログ処理が走っていないか、例外処理やリクエストログが過剰でないか確認しましょう。

また、ミドルウェアの順序も重要です。静的ファイル、ルーティング、認証、認可、エンドポイントなど、適切な順序で構成する必要があります。

9-3. キャッシュを使った高速化

同じデータを何度も取得・計算している場合、キャッシュは非常に効果的です。ASP.NET Coreでは、メモリキャッシュ、分散キャッシュ、レスポンスキャッシュ、CDNなどを使えます。

メモリキャッシュの例です。

C#
if (!memoryCache.TryGetValue("settings", out AppSettings settings))
{
settings = await LoadSettingsAsync();

memoryCache.Set("settings", settings, TimeSpan.FromMinutes(10));
}

キャッシュを使うとDBや外部APIへのアクセスを減らせます。ただし、古いデータを返してよいか、更新時にどう無効化するか、メモリを使いすぎないかを考慮する必要があります。

9-4. JSONシリアライズ処理の最適化

Web APIでは、JSONシリアライズとデシリアライズがパフォーマンスに影響します。レスポンスのオブジェクトが大きすぎる場合や、不要なプロパティを含んでいる場合、CPUとネットワーク転送量が増えます。

DTOを使って必要な項目だけ返すことが重要です。

C#
return Ok(new UserResponse
{
Id = user.Id,
Name = user.Name
});

循環参照を含むエンティティをそのまま返すのは避けましょう。Entity Framework Coreのエンティティを直接APIレスポンスにすると、不要なデータや関連オブジェクトまで含まれることがあります。

大量データを返す場合は、ページング、圧縮、ストリーミング、レスポンス項目の削減を検討します。

9-5. ログ出力がパフォーマンスに与える影響

ログは障害調査に不可欠ですが、出力しすぎるとパフォーマンスに影響します。特に、リクエストごとに大量のログを出す、ループ内でログを出す、大きなオブジェクトをシリアライズしてログに出す、同期的にファイルへ書き込む、といった実装は注意が必要です。

ログレベルを適切に使い分け、本番環境ではDebugやTraceを必要以上に出さないようにします。

C#
logger.LogInformation("Order created. OrderId: {OrderId}", orderId);

構造化ログを使うと、文字列結合を避けつつ検索しやすいログを出せます。ログは「必要な情報を、必要な粒度で、必要な場所に」出すことが大切です。

9-6. スケールアウト前に確認すべきアプリ側の改善点

レスポンスが遅いと、すぐにサーバー台数を増やす判断をしがちです。しかし、アプリ側に明らかなボトルネックがある場合、スケールアウトしてもコストだけが増え、根本解決にならないことがあります。

スケールアウト前に、DBクエリ、キャッシュ、非同期I/O、接続プール、GC、ログ、JSONサイズ、外部API待ち、N+1問題を確認しましょう。

特にDBがボトルネックの場合、アプリサーバーを増やすことでDBへの同時アクセスが増え、逆に遅くなることもあります。C#チューニングは、アプリ単体ではなくシステム全体で考えることが重要です。

10. C#コードの実践的な高速化テクニック

ここでは、C#コードでよく使われる実践的なチューニングテクニックを紹介します。いずれも、効果は状況によって変わるため、適用前後の計測が前提です。

10-1. boxing・unboxingを避ける

boxingとは、値型をobject型やインターフェース型として扱うためにヒープ上のオブジェクトへ変換することです。unboxingはその逆です。頻繁に発生すると、メモリ割り当てやGCの原因になります。

C#
int value = 10;
object boxed = value;
int unboxed = (int)boxed;

通常の業務コードでは過度に気にしすぎる必要はありませんが、大量データ処理や高頻度処理では注意が必要です。ジェネリックコレクションを使うことで、不要なboxingを避けられます。

C#
var numbers = new List<int>();

古いArrayListのような非ジェネリックコレクションは避け、List<T>やDictionary<TKey, TValue>を使いましょう。

10-2. 例外処理を通常フローに使わない

例外は、異常系を扱うための仕組みです。通常の分岐処理に例外を使うと、パフォーマンスが悪化します。

悪い例です。

C#
try
{
var number = int.Parse(text);
}
catch
{
// 数値でない場合
}

通常はTryParseを使います。

C#
if (int.TryParse(text, out var number))
{
// 数値の場合
}

ファイル、日付、数値、Enumなど、失敗が想定される処理では、Try系メソッドを使うと効率的です。

10-3. 正規表現の使い方を最適化する

正規表現は便利ですが、複雑なパターンや大量データに対して使うと重くなることがあります。特に、ループ内で毎回Regexインスタンスを生成している場合は注意が必要です。

C#
private static readonly Regex EmailRegex = new(
@"^[^@\s]+@[^@\s]+\.[^@\s]+$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);

頻繁に使う正規表現は再利用しましょう。また、単純な前方一致、後方一致、Containsで済む処理に正規表現を使っていないか確認します。

C#
if (text.StartsWith("ABC", StringComparison.Ordinal))
{
// 正規表現不要
}

正規表現は強力ですが、単純な文字列操作のほうが速く読みやすい場合があります。

10-4. キャッシュ化・メモ化で再計算を減らす

同じ入力に対して同じ結果を返す重い処理は、キャッシュ化やメモ化で高速化できます。

C#
private readonly Dictionary<int, decimal> _priceCache = new();

public decimal CalculatePrice(int productId)
{
if (_priceCache.TryGetValue(productId, out var price))
{
return price;
}

price = CalculatePriceCore(productId);
_priceCache[productId] = price;
return price;
}

ただし、キャッシュには注意点があります。データの鮮度、メモリ使用量、スレッドセーフ性、無効化タイミングを考える必要があります。

マルチスレッド環境では、ConcurrentDictionaryやIMemoryCacheを使うほうが安全です。

10-5. 値型と参照型を適切に使い分ける

C#では、structなどの値型と、classなどの参照型があります。値型は小さく不変のデータに向いています。一方、サイズが大きい値型を頻繁にコピーすると、パフォーマンスが悪化することがあります。

小さな座標、日付、ID、金額のような値を表す場合はstructが適することがあります。しかし、状態を持つ大きなオブジェクトや継承を使う設計ではclassが自然です。

値型を使えば必ず速くなるわけではありません。コピーコスト、boxing、コレクションでの扱い、readonly structの利用などを考慮しましょう。

10-6. record・struct・classのパフォーマンス上の違い

recordは、値の比較や不変データの表現に便利です。record classは参照型、record structは値型です。DTOや設定値、イベントデータなどではrecordが読みやすい選択肢になります。

classは参照型で、オブジェクトの共有や継承に向いています。structは値型で、小さなデータを効率よく扱える場合があります。ただし、大きなstructはコピーコストが高くなるため注意が必要です。

パフォーマンスだけで選ぶのではなく、データの意味、変更可能性、比較方法、メモリ配置、コピー頻度を考えて選択します。C#チューニングでは、「structにすれば速い」「recordは遅い」と単純に判断せず、計測して確認することが重要です。

11. C#チューニングで使える主要ツール

C#チューニングでは、ツールを使って客観的に分析することが重要です。ここでは、代表的なツールを紹介します。

11-1. Visual Studio Profiler

Visual Studio Profilerは、C#アプリのCPU使用率、メモリ使用量、割り当て、ホットパスなどを確認できるツールです。Visual Studio上で使えるため、開発中の分析に向いています。

特に、どのメソッドに時間がかかっているか、どの処理が多く呼ばれているかを確認するのに便利です。まずはVisual Studio Profilerで大まかなボトルネックを見つけるとよいでしょう。

11-2. BenchmarkDotNet

BenchmarkDotNetは、メソッド単位の性能比較に適したベンチマークライブラリです。LINQとfor文、DictionaryとList、文字列処理、アルゴリズムの比較などに使えます。

平均時間、標準偏差、メモリ割り当て量などを確認できるため、細かいチューニングの効果測定に向いています。C#チューニングで「どちらの書き方が速いか」を判断するときは、BenchmarkDotNetで確認しましょう。

11-3. dotnet-counters

dotnet-countersは、実行中の.NETアプリのメトリクスをリアルタイムに確認できるコマンドラインツールです。CPU使用率、GCヒープサイズ、GC回数、例外数、スレッドプールなどを確認できます。

本番に近い環境で、アプリの状態を軽量に観察したい場合に便利です。特に、GCやスレッドプールの問題を切り分けるときに役立ちます。

11-4. dotnet-trace

dotnet-traceは、.NETアプリのトレース情報を収集するツールです。CPUプロファイルやイベント情報を取得し、後から解析できます。

開発環境だけでなく、サーバー上で発生するパフォーマンス問題を調査する際にも使えます。Visual Studioだけで再現できない問題や、コンテナ・Linux環境での分析にも有効です。

11-5. PerfView

PerfViewは、.NETアプリの詳細なパフォーマンス分析に使える強力なツールです。CPU、GC、メモリ割り当て、スタックトレースなどを深く調べられます。

使いこなすには学習コストがありますが、GCやメモリ割り当ての詳細分析、複雑なパフォーマンス問題の調査では非常に有用です。大規模なC#アプリのチューニングでは、覚えておきたいツールです。

11-6. Application Insights

Application Insightsは、Azure環境やASP.NET Coreアプリの監視に使えるAPMツールです。リクエスト時間、依存関係、例外、ログ、可用性、ユーザー操作などを可視化できます。

本番環境で「どのAPIが遅いか」「どのDBクエリが重いか」「どの外部APIで待っているか」を確認するのに役立ちます。C#チューニングでは、開発環境のベンチマークだけでなく、本番環境の継続監視も重要です。

12. C#チューニングの実践手順とチェックリスト

C#チューニングは、思いついた改善を順番に試すのではなく、手順に沿って進めると効果的です。

12-1. 現状の処理時間を計測する

まず、現状の処理時間を測ります。APIならレスポンスタイム、バッチなら全体時間とステップごとの時間、画面なら初期表示時間や操作時間を記録します。

このとき、データ件数、実行環境、同時アクセス数、DB状態なども合わせて記録しておくと、改善後の比較がしやすくなります。

12-2. ボトルネックを1つずつ特定する

次に、処理時間を分解します。CPU処理、DBアクセス、外部API、ファイルI/O、JSONシリアライズ、ログ出力、GCなど、どこに時間がかかっているかを確認します。

ボトルネックは一度に複数改善しようとせず、1つずつ特定して対応するのが基本です。複数の変更を同時に行うと、どの変更が効果を出したのか分からなくなります。

12-3. 改善前後をベンチマークで比較する

改善したら、必ず改善前後を比較します。体感で速くなったように見えても、実際には変わっていないことがあります。逆に、一部のケースでは速くなっても、別のデータ条件では遅くなることもあります。

小さな処理はBenchmarkDotNet、アプリ全体はAPMや負荷テスト、DBは実行計画やスロークエリログを使って確認します。

12-4. 可読性・保守性を損なわない範囲で最適化する

C#チューニングでは、可読性と保守性を犠牲にしすぎないことが重要です。過度に最適化されたコードは、バグの原因になりやすく、将来の変更も難しくなります。

まずは、アルゴリズムの改善、データ構造の見直し、DBクエリの最適化、不要なI/O削減、キャッシュなど、効果が大きく分かりやすい改善を優先しましょう。

細かい構文レベルの最適化は、計測でボトルネックだと分かった場合に限定するのがおすすめです。

12-5. 本番環境で効果を継続監視する

開発環境で速くなっても、本番環境で同じ効果が出るとは限りません。本番環境では、データ量、同時アクセス、ネットワーク、DB負荷、外部APIの状態が異なります。

リリース後は、レスポンスタイム、エラー率、CPU、メモリ、GC、DBクエリ時間、外部API時間を監視します。改善によって別の箇所に負荷が移動していないかも確認しましょう。

12-6. C#チューニングでよくある失敗と回避策

よくある失敗は、計測せずに改善することです。推測でLINQをすべてfor文に置き換えたり、キャッシュを追加したりしても、効果がない場合があります。

次に多いのは、局所最適化に偏ることです。数ミリ秒のコード改善に時間をかける一方で、DBクエリが数秒かかっているケースは珍しくありません。

また、キャッシュの無効化を考えずに導入して古いデータを返してしまう、並列処理でDBや外部APIに負荷をかけすぎる、可読性を大きく下げて保守不能にする、といった失敗もあります。

C#チューニングでは、次のチェックリストを意識しましょう。

  • 処理時間を計測したか

  • CPU、メモリ、GC、I/O、DBのどこが遅いか切り分けたか

  • データ構造は適切か

  • ネストしたループや重複検索はないか

  • 不要なToListやToArrayはないか

  • DBでN+1問題が起きていないか

  • 必要なカラムだけ取得しているか

  • 同期I/Oでスレッドをブロックしていないか

  • HttpClientを適切に使っているか

  • ログを出しすぎていないか

  • GCが頻発していないか

  • 改善前後を同じ条件で比較したか

  • 本番環境で継続監視しているか

まとめ

C#チューニングは、単にコードを短くしたり、LINQをfor文に置き換えたりする作業ではありません。重要なのは、遅い原因を正しく計測し、ボトルネックに対して効果のある改善を行うことです。

まずは、Visual Studio Profiler、BenchmarkDotNet、dotnet-counters、APMなどを使って、CPU、メモリ、GC、I/O、データベース、外部APIのどこに問題があるかを確認します。そのうえで、アルゴリズム、データ構造、LINQ、メモリ割り当て、非同期処理、並列処理、DBクエリ、ファイルI/O、Web API設計を見直します。

特に効果が大きいのは、計算量の改善、DictionaryやHashSetの活用、N+1問題の解消、不要なデータ取得の削減、非同期I/O、キャッシュ、GC負荷の低減です。一方で、細かい最適化は、計測して本当に効果がある場合に行うべきです。

C#チューニングの基本は、「計測する」「原因を分ける」「1つずつ改善する」「改善前後を比較する」「本番で監視する」です。この流れを守れば、遅い処理を効率的に高速化し、安定したC#アプリケーションを実現できます。