1. はじめに
絶対に落ちないプログラムを書くなんて、IDEがバグを恐れていると信じるようなものです。実際には、コードが複雑になるほど(特にマルチスレッドや非同期環境で)、バグは狡猾になり、例外も巧妙になります。スレッドやタスクの例外を無視すると、リソースリーク、ハング、データ損失、あるいは実行から数時間後に突発的なクラッシュを招くことがよくあります。
このレクチャーでは、C#のマルチスレッド/非同期プログラミングにおけるエラー処理のベストプラクティスを一つにまとめます。どうやって例外を適切に捕まえるか、複数の同時エラーをどう分解するか(AggregateException的な扱い)、「放置された」タスクにはどう対処するか、そして例外を無視することがなぜ危険で無意味なのかを学びます。
なぜ単純ではないのか
- エラーが発生するスレッドは、try-catch があるスレッドと異なることがある。
- 非同期タスクは例外をすぐには投げない — 例外は「パッケージ化」され、await(または同期的な待ち)で処理されるまで待機する。
- 複数のタスクを同時に扱うと(例: Task.WhenAll)、複数のエラーが発生する可能性があり、それらを考慮する必要がある。
- fire-and-forget のような操作は、明示的なハンドラがないと例外を完全に「失う」ことがある。
この特性は、実行コンテキストの分離によるものです。プログラムを複数のリングがあるサーカスと想像してください: あるリングで火災が起きても、すぐには他のリングから見えません。こうした「火」を正しく追跡して消すことが重要です。
2. タスク内の例外: Task と Task<TResult>
タスクはどうやってエラーを知らせるか
タスク内で未処理の例外が発生しても、それはすぐ外側に投げられません。タスクは Faulted 状態になり、例外は内部に保持されます。取得する方法は:
- await で完了を待つ(または task.Wait()/task.Result を使う — ただし推奨しません);
- プロパティ task.Exception をチェックする — そこには AggregateException が入っている。
例
// エラーが発生する非同期メソッド
async Task FailAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("何かがうまくいきませんでした");
}
async Task MainAsync()
{
try
{
await FailAsync();
}
catch (Exception ex)
{
Console.WriteLine($"エラー: {ex.Message}");
}
}
もし try-catch を置かなければ、プログラムはクラッシュします。置いておけば、例外は正しく捕まえられます — たとえエラーが別のタスクで発生していても。
AggregateException:まとめて来るエラー
await で Task.WhenAll(tasks) を待つと、複数タスクのエラーは一つの AggregateException にまとめられ(その InnerExceptions に入る)、扱う必要があります。
async Task MultiFailAsync()
{
Task t1 = Task.Run(() => throw new InvalidOperationException("エラー 1"));
Task t2 = Task.Run(() => throw new ArgumentException("エラー 2"));
try
{
await Task.WhenAll(t1, t2);
}
catch (Exception ex)
{
if (ex is AggregateException agg)
{
foreach (var e in agg.InnerExceptions)
Console.WriteLine($"例外: {e.Message}");
}
else
{
Console.WriteLine($"単一のエラー: {ex.Message}");
}
}
}
注意: await で Task.WhenAll(tasks) を使うと .NET は AggregateException を「展開」します。catch では最初の例外が受け取られることがあります。完全な一覧は、タスクがエラーで終了していれば task.Exception.InnerExceptions から参照できます。
3. 「Fire and Forget」: タスクを放置してはいけない理由
「起動して忘れる」は隠れた失敗につながりやすいです。例:
Task.Run(() => { throw new Exception("ドカン!"); }); // エラーが虚空に消える。
最新のランタイムはタスク内のエラーを保持しており、未観測の例外が原因でプロセスが終了することがあります。ベストはタスクの参照を保持するか、TaskScheduler.UnobservedTaskException イベントにサブスクライブすることです。
どうすべきか?
- タスクを保持して終了を待ち、エラーを処理する;
- fire‑and‑forget の場合はデリゲート内で例外ハンドリングを置く。
Task.Run(() => {
try
{
// 例外を投げる可能性のあるコード
}
catch (Exception ex)
{
// ログに残したり通知したりする。外には投げない。
Console.WriteLine($"fire-and-forget 内のエラー: {ex.Message}");
}
});
4. スレッド内のエラー (Thread): 外側で捕まえられないの?
新しい Thread で発生した例外は、外側の try-catch では捕まえられません — スレッド本体の内部でしか捕まえられません。
var thread = new Thread(() =>
{
try
{
throw new Exception("スレッド内のエラー");
}
catch (Exception ex)
{
Console.WriteLine($"スレッド内で例外を捕捉: {ex.Message}");
}
});
thread.Start();
ハンドラを置かないと、その例外はそのスレッドだけを終了させます(バックグラウンドスレッドなら: thread.IsBackground = true)。フォアグラウンドスレッドで未処理の例外があるとプロセス全体が終了する可能性があります。常にスレッド内部に try-catch を置きましょう。
スレッドから結果やエラーを返すには?
- 結果やエラーの受け渡しにはキューやコレクションを使う;
- イベントモデルを使う;
- 可能なら Task に移行する — タスクの方がエラー処理が楽。
5. 並列ループ: 特別なエラーの扱い方
並列ループでは、異なるブランチのエラーが AggregateException に集まります。
try
{
Parallel.For(0, 5, i =>
{
if (i % 2 == 0)
throw new Exception($"イテレーション {i} のエラー");
});
}
catch (AggregateException ex)
{
foreach (var e in ex.InnerExceptions)
Console.WriteLine($"[並列ループ] エラー: {e.Message}");
}
ローカルな失敗をログして他のブランチを続行したい場合は、各ブランチ内に個別の try-catch を置いてください。
6. キャンセル時のエラー処理
CancellationToken によるキャンセルでは、慣習として OperationCanceledException が投げられます — これは障害ではなく正常な停止です。
async Task DoWorkAsync(CancellationToken token)
{
for (int i = 0; i < 10; i++)
{
token.ThrowIfCancellationRequested();
await Task.Delay(100);
}
}
// どこかのコード:
var cts = new CancellationTokenSource();
var task = DoWorkAsync(cts.Token);
cts.Cancel(); // 約200ms後くらいに
try
{
await task;
}
catch (OperationCanceledException)
{
Console.WriteLine("操作はキャンセルされました!");
}
ThrowIfCancellationRequested() 以外にも、トークンをサポートする多くのメソッド(例: Task.Delay, HttpClient.GetAsync)が自動的に OperationCanceledException を投げます。キャンセルサポートを確認しましょう。
7. 役立つ細かいポイント
すべてのケースに対するアプローチ
- 非同期メソッドの上位レベルに try-catch を置く — そうすれば「逃げた」エラーを拾える。
- タスクを無視しない: 参照を保持し、完了を待ち、エラーをログする。
- 並列操作(Task.WhenAll / Parallel.ForEach)では AggregateException に注意する。
- キャンセルと障害を区別する: OperationCanceledException を別に捕まえる。
- 特に「静かな」エラーはログに残すこと。
- 詳細を残す: 最初の例外だけでなく、全ての InnerExceptions をログする。
- その場でエラーを処理する: 何をすべきか分からないなら、せめてログを残す。
マルチスレッド/非同期コードではどこでエラーを捕まえるか
flowchart TD
A[メインスレッド / UI] -->|タスクを起動| B[Task/async]
B -->|タスク内部| C[非同期メソッド内の try/catch]
B -->|メインで await| D[await のまわりの try/catch]
A -->|Thread を起動| E[Thread]
E -->|内部で| F[スレッド内の try/catch]
B -->|多数のタスク| G[Task.WhenAll / Parallel.ForEach]
G -->|エラー| H[AggregateException]
8. よくあるミスとハマりどころ
ミス №1: .Result や .Wait() でタスクを待つこと。デッドロックや予期しない AggregateException が発生する可能性がある。
ミス №2: 内部の try-catch を置かないで fire‑and‑forget を起動する — タスクは静かに落ち、診断ができない。
ミス №3: 並列ループ内の見逃したエラー — 仕事の一部が実行されていないのに気づかない。
ミス №4: キャンセル(OperationCanceledException)と実際の障害を区別しないこと。
ミス №5: 複数タスクのうち最初のエラーだけをログする — 残りは「影」に隠れる。
ミス №6: 全てのタスクで同じ Exception オブジェクトを使い回すこと — 各エラーは固有のインスタンスを持つべき。
ミス №7: UIスレッドで例外処理がないこと — バックグラウンドのエラーが見逃され、UIが「幽霊のように」振る舞う。
GO TO FULL VERSION