WPF BackgroundWorker RunWorkerCompletedの例外がcatchできない原因と対処法(Task/async-await移行)

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する場所」を工夫するのではなく、例外を呼び出し元が受け取れる形で“運ぶ”ように設計を組み替えることが、根本解決への近道です。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次