WPF(.NET Framework 4.8)でBackgroundWorkerを使っていると、RunWorkerCompletedでthrowした例外が呼び出し元のtry-catchに届かず、想定外に次の処理へ進んでしまうことがあります。本記事では原因を「スレッド」と「コールスタック」から整理し、例外を確実に伝搬させる実装パターンを具体例つきで解説します。
起きている現象:RunWorkerCompletedでthrowしても呼び出し元でcatchできない
状況を整理すると、よくある構造は次のようなものです。
- WPF(.NET Framework 4.8)アプリ
BackgroundWorkerを継承したAbortableBackgroundWorkerを利用- 「プロトコル全体」を進行する
ProtocolProcessor用のワーカー(外側)がある - その外側の処理中に、PCR処理を担当するワーカー(内側:
_pcrBackgroundWorker)を起動している(ネスト構造) - PCRワーカーの
RunWorkerCompletedで、エラーがあればthrow e.Error;している - 呼び出し元(
ProtocolProcessor.Execute())はtry-catchで囲っており、PCRの失敗を捕まえて後続マクロ(transferPcrMacro.Execute()など)を止めたい - しかし実際には、
throw e.Error;は呼び出し元のcatchに届かず、次のマクロへ進んでしまう
問題のコードは概ね次のイメージです(簡略化)。
_pcrBackgroundWorker.RunWorkerCompleted += (sender, e) =>
{
if (e.Error != null)
{
WriteToRunLog("!! PCR Exception " + e.Error.Message);
throw e.Error; // ここで例外を投げても、呼び出し元のtry-catchには届かない
}
};
なぜ呼び出し元のcatchで拾えないのか
結論から言うと、例外が発生している場所(スレッド/呼び出し経路)が、呼び出し元のtry-catchと別物だからです。try-catchが捕まえられるのは「そのtryブロックの実行中に、同じスレッドの同じ呼び出し経路で発生した例外」だけです。
BackgroundWorkerはRunWorkerAsyncを呼んだ時点で戻る(非同期)
BackgroundWorker.RunWorkerAsync()は、内部で別スレッドに仕事を投げたらすぐに戻ります。そのため、次のようなコードを書いた場合、RunWorkerAsync()の直後にある処理は「PCRが終わるのを待たずに」進みます。
try
{
_pcrBackgroundWorker.RunWorkerAsync();
// ここはPCRが終わる前に実行され得る
transferPcrMacro.Execute();
}
catch (Exception ex)
{
// PCR側の例外は、基本的にここに来ない
}
throwが実行されるのは「後で」別のイベント経路
throw e.Error;が実行されるのは、PCRワーカーが完了してRunWorkerCompletedが発火した瞬間です。この時点では、呼び出し元のExecute()はとっくに戻っていて、try-catchブロックも抜けています。つまり、catchが待ち構えている場所(コールスタック上)に、例外が存在しません。
| タイミング | 実行場所 | 起きていること | ProtocolProcessor.Execute()のtry-catchは有効? |
|---|---|---|---|
| PCR開始 | ProtocolProcessor側 | RunWorkerAsyncでPCRを起動し、すぐ戻る | 有効(ただし、ここで例外が出ない) |
| 次の処理へ | ProtocolProcessor側 | transferPcrMacroなど次の処理へ進む | 有効だがPCRの結果はまだ |
| PCR完了 | RunWorkerCompleted側 | イベントが発火し、e.Errorを確認する | 無効(Execute()のtry-catchはもう終わっている) |
| throw実行 | RunWorkerCompleted側 | イベントハンドラで例外を投げる | 捕まえられない(別経路の例外) |
補足:RunWorkerCompletedは「UIスレッド」で呼ばれることが多い
WPFでは、BackgroundWorkerをUIスレッドから起動しているケースが多く、RunWorkerCompleted(やProgressChanged)はUIスレッドにマーシャリングされます。すると、RunWorkerCompleted内でthrowした例外は、呼び出し元のtry-catchではなく、WPFのDispatcher側(たとえばApplication.DispatcherUnhandledException)で扱われる対象になります。
もしアプリ側でDispatcherUnhandledExceptionを捕まえてe.Handled = true;にしていると、例外が「握りつぶされた」ように見え、結果として処理が進んでしまいます。ここが、現場で混乱が起きやすいポイントです。
RunWorkerCompletedでthrowする設計が危険な理由
RunWorkerCompletedでthrowすると、少なくとも次のデメリットが出ます。
- 呼び出し側に伝搬しない(本記事の主題)
- 例外の扱い先が「グローバル例外ハンドラ」になりやすい(UIが落ちる/握りつぶされる等の不安定要因)
- スタックトレースが読みにくくなる(throwの場所がRunWorkerCompletedになり、発生源が追いにくい)
- フロー制御に例外を使うことになり、後続処理の停止条件が曖昧になる
BackgroundWorkerで例外を扱う基本は、DoWorkで例外を発生させて、Completedのe.Errorで受けることです。Completedでthrowするのは「例外伝搬」を期待すると失敗しやすく、意図しない場所に飛びます。
根本方針:例外を「投げる」のではなく「運ぶ」
非同期処理では、例外をそのまま投げても呼び出し元へ戻りません。必要なのは、次のどちらかです。
- 呼び出し元が「完了まで待つ」仕組みを持ち、完了時に例外を受け取って再スローできるようにする
- 待てないなら、「次の処理を開始する場所」を完了イベント側へ移し、エラー時は次へ進めない(チェーン型)にする
ここからは、現場で使いやすい順に具体策を紹介します。
解決策:Task / async-awaitへ移行して例外を自然に伝搬させる(推奨)
BackgroundWorkerは歴史のあるAPIですが、現代的な「非同期+例外伝搬」の扱いは、Taskとasync-awaitの方が圧倒的に素直です。awaitは、非同期処理の完了を待ち、失敗していればその場で例外を再スローしてくれます。これが「呼び出し元のcatchで拾える」状態です。
ステップ:PCR処理をTask化する
たとえば、PCRマクロが同期メソッドExecute()だとしても、Task.Runで包めます。
public Task RunPcrMacroAsync(CancellationToken token)
{
return Task.Run(() =>
{
token.ThrowIfCancellationRequested();
_pcrMacro.Execute(token); // ここで例外が出ればTaskがFaultedになる
}, token);
}
呼び出し側:awaitで待ってから次へ進む
ProtocolProcessor側をasyncにできるなら、次のように「順番をawaitで表現」できます。PCRが失敗すれば、awaitの行で例外が再スローされ、後続マクロは実行されません。
public async Task ExecuteAsync(CancellationToken token)
{
try
{
if (_protocolProcessorParams.PerformPcrMacro)
{
await RunPcrMacroAsync(token);
}
if (_protocolProcessorParams.PerformTransferPcrMacro)
{
_transferPcrMacro.Execute(token);
}
}
catch (Exception ex)
{
WriteToRunLog("!!! Exception Occurred in Macro !!! - " + ex);
throw;
}
}
UI側:イベントハンドラでawaitして例外を表示する
WPFのボタンクリックなど、UIイベントはasync voidにしやすいので、UI側で次のように扱うと、例外表示やUI復帰も素直になります。
private async void RunButton_Click(object sender, RoutedEventArgs e)
{
try
{
using (var cts = new CancellationTokenSource())
{
await _protocolProcessor.ExecuteAsync(cts.Token);
}
MessageBox.Show("完了");
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
}
Wait()を使う場合の注意(デッドロックとAggregateException)
同期メソッドのままにしたい理由でtask.Wait()を使うケースもありますが、WPFのUIスレッドでWaitするとデッドロックを起こす典型パターンがあります。また、Waitは例外をAggregateExceptionで包むため、扱いがやや面倒です。
可能なら、awaitに寄せるのが最終的な保守性を高めます。どうしても同期にしたい場合は、UIスレッドから直接Waitしない(バックグラウンドから呼ぶ/もしくはGetAwaiter().GetResult()などで慎重に)といった設計が必要です。
解決策:BackgroundWorkerをTask化して「待てる形」にする(既存資産を残す)
「AbortableBackgroundWorkerを簡単に捨てられない」「既存のProgressChanged更新が多い」などの事情でBackgroundWorkerを残したい場合でも、TaskCompletionSourceでTaskに変換すると、呼び出し側はawaitできます。
BackgroundWorkerをTaskに変換するヘルパー例
1回の実行をTaskとして待てるようにします(再利用する場合はイベントの付け外しに注意)。
public static class BackgroundWorkerExtensions
{
public static Task RunWorkerTaskAsync(this BackgroundWorker worker, object argument = null)
{
var tcs = new TaskCompletionSource<object>();
RunWorkerCompletedEventHandler handler = null;
handler = (s, e) =>
{
worker.RunWorkerCompleted -= handler;
if (e.Cancelled)
{
tcs.TrySetCanceled();
return;
}
if (e.Error != null)
{
tcs.TrySetException(e.Error);
return;
}
tcs.TrySetResult(e.Result);
};
worker.RunWorkerCompleted += handler;
worker.RunWorkerAsync(argument);
return tcs.Task;
}
}
呼び出し側:awaitして例外をcatchする
public async Task ExecuteAsync(CancellationToken token)
{
try
{
if (_protocolProcessorParams.PerformPcrMacro)
{
await _pcrBackgroundWorker.RunWorkerTaskAsync();
}
if (_protocolProcessorParams.PerformTransferPcrMacro)
{
_transferPcrMacro.Execute(token);
}
}
catch (Exception ex)
{
WriteToRunLog("!!! Exception Occurred in Macro !!! - " + ex);
throw;
}
}
この方法だと、例外はe.ErrorからTaskに乗り、awaitで再スローされます。「RunWorkerCompletedでthrowする」必要がなくなります。
重要:UIスレッドでWaitしない
上記のTask化をした場合でも、UIスレッドでWaitすると同じくデッドロックの危険があります。WPFでは、基本はawaitで待ち、UIをブロックしないのが安全です。
解決策:ネストしたBackgroundWorkerをやめ、外側ワーカーのDoWorkで同期実行する
「外側ですでにバックグラウンドスレッドで動いている」のであれば、内側でさらにBackgroundWorkerを起動する必要がないケースが多いです。ネストをやめ、外側のDoWork内でPCR処理を同期実行すれば、例外はそのまま外側ワーカーのe.Errorに乗ります。
private void ProtocolWorker_DoWork(object sender, DoWorkEventArgs e)
{
try
{
if (_protocolProcessorParams.PerformPcrMacro)
{
_pcrMacro.Execute(/* tokenなど */); // ここで例外が出ればcatchへ
}
if (_protocolProcessorParams.PerformTransferPcrMacro)
{
_transferPcrMacro.Execute(/* tokenなど */);
}
}
catch (Exception ex)
{
WriteToRunLog("!!! Exception Occurred in Macro !!! - " + ex);
throw; // 外側BackgroundWorkerがe.Errorに載せてくれる
}
}
この構造に変えると、「順次実行したい」という要件とBackgroundWorkerの性質が噛み合いやすくなります。特に、PCRの完了を待つ必要があるなら、最初から同じワーカー内で順に実行する方がシンプルです。
解決策:例外を保存して後で投げ直す(最小変更だが注意が必要)
どうしても構造を大きく変えられない場合、「例外を共有変数に保存し、呼び出し側が都合の良いタイミングで取りに行って投げ直す」という手もあります。ただし、static変数で持つのは競合しやすいため、できればインスタンスフィールドに閉じ込めます。
例外を保持するフィールドを用意する
private readonly object _errorLock = new object();
private Exception _pcrException;
private void SetPcrException(Exception ex)
{
lock (_errorLock)
{
_pcrException = ex;
}
}
private Exception GetAndClearPcrException()
{
lock (_errorLock)
{
var ex = _pcrException;
_pcrException = null;
return ex;
}
}
PCR側:throwせず保存する
_pcrBackgroundWorker.RunWorkerCompleted += (sender, e) =>
{
if (e.Error != null)
{
WriteToRunLog("!! PCR Exception " + e.Error.Message);
SetPcrException(e.Error); // 保存だけ
}
};
呼び出し側:次の処理に進む前に確認し、必要なら再スローする
ただし、この方法は「確認するタイミング」が重要です。PCR完了前に次へ進んでしまうと意味がないため、結局はどこかで待つか、処理の開始地点をCompleted側に移す必要があります。
「待つ」例(外側がバックグラウンドスレッドで動いている前提)を示します。UIスレッドでWaitしないでください。
private readonly ManualResetEventSlim _pcrDone = new ManualResetEventSlim(false);
private void StartPcrAndWait()
{
_pcrDone.Reset();
_pcrBackgroundWorker.RunWorkerCompleted += (s, e) =>
{
if (e.Error != null) SetPcrException(e.Error);
_pcrDone.Set();
};
_pcrBackgroundWorker.RunWorkerAsync();
_pcrDone.Wait(); // バックグラウンドで待つ
var ex = GetAndClearPcrException();
if (ex != null) throw ex;
}
この方式は「最小変更」で動かせる反面、待機・イベント解除・多重起動・キャンセル時の扱いなど、気を付ける点が多いので、可能ならTask/awaitへ寄せる方が結果的に安全です。
スタックトレースを保ちたい場合:ExceptionDispatchInfo
保存して後から投げ直す場合、単純にthrow ex;するとスタックトレースが投げ直し地点に置き換わります。原因調査を容易にするには、ExceptionDispatchInfoで捕捉した例外を投げ直す方法が有効です。
using System.Runtime.ExceptionServices;
private ExceptionDispatchInfo _pcrEdi;
private void SetPcrException(Exception ex)
{
lock (_errorLock)
{
_pcrEdi = ExceptionDispatchInfo.Capture(ex);
}
}
private void ThrowIfPcrFailed()
{
ExceptionDispatchInfo edi;
lock (_errorLock)
{
edi = _pcrEdi;
_pcrEdi = null;
}
edi?.Throw();
}
どの方法を選ぶべきか:実務目線の比較
| 方法 | 変更量 | 例外の伝搬 | 順次実行のしやすさ | おすすめ度 |
|---|---|---|---|---|
| Task / async-awaitへ移行 | 中〜大 | 非常に良い(awaitで自然にcatchできる) | 非常に良い(awaitで直列表現) | 高 |
| BackgroundWorkerをTask化(TCS) | 中 | 良い(e.ErrorをTaskに載せる) | 良い(awaitで直列化) | 中〜高 |
| ネストをやめて外側DoWorkで同期実行 | 中 | 良い(外側e.Errorに乗る) | 良い(同一スレッドで直列) | 中 |
| 例外を保存して後で投げ直す | 小 | 条件付き(待つ設計が必要) | 難しい(待機や状態管理が増える) | 低〜中 |
よくある落とし穴(「直したつもり」で再発しやすいポイント)
- RunWorkerAsyncの直後に次の処理を書く:非同期なので、順番が保証されません。待つか、Completed側で次を開始してください。
- UIスレッドでWaitする:WPFのSynchronizationContextと相性が悪く、デッドロックの原因になります。基本はawait。
- RunWorkerCompletedにイベントを付けっぱなしにする:多重登録で二重実行・二重ログ・二重例外が起きます。ワンショットなら解除を徹底。
- staticで例外を保持する:複数プロトコル同時実行や再実行で混線しやすいです。インスタンスに閉じ込め、必要ならロック。
- throw ex;:スタックトレースが潰れて原因追跡が難しくなります。再スローは
throw;、跨いで投げ直すならExceptionDispatchInfoを検討。
まとめ:BackgroundWorkerの例外は「勝手に呼び出し元へ戻らない」
RunWorkerCompletedでthrow e.Error;しても、呼び出し元のtry-catchには届きません。理由はシンプルで、非同期で起動した処理の例外は、呼び出し元のコールスタック上に存在しないからです。
確実に後続マクロを止めたいなら、以下のどれかに寄せるのが安全です。
- Task / async-awaitで順次awaitし、例外をcatchする(最も保守しやすい)
- BackgroundWorkerをTask化してawaitできる形にする(既存資産を残す)
- ネストをやめて外側ワーカーで同期実行し、外側のe.Errorに集約する
「throwする場所」を工夫するのではなく、例外を呼び出し元が受け取れる形で“運ぶ”ように設計を組み替えることが、根本解決への近道です。

コメント