WPF(.NET Framework 4.8)でBackgroundWorkerのRunWorkerCompletedがx64 Releaseで発火しない原因と対策(Thread.Abort問題)

WPF(.NET Framework 4.8)でBackgroundWorkerを使っていると、x64 Release だけ RunWorkerCompleted が発火しない…という報告があります。原因の多くは Thread.Abort による強制終了で、x86で“たまたま動いた”挙動がタイミング差で崩れます。本記事では仕組みと安全なキャンセル+後片付け順の固定方法を具体例で解説します。

目次

症状:x64 Releaseだけ RunWorkerCompleted が呼ばれない

WPFアプリ(.NET Framework 4.8)で BackgroundWorker を使い、例えば「ProtocolProcessor」と「PCR」のような2つのマクロ処理を別々のバックグラウンド処理で並行実行している状況を想定します。

旧版(x86 Release)では、異常時に Thread.Abort でスレッドを強制終了しても、処理が終わったように見え、RunWorkerCompleted も呼ばれていました。ところが新版(x64 Release)では、同じように Thread.Abort を使うと RunWorkerCompleted が呼ばれない ケースが再現する、というのが今回の主題です。

観点x86 Release(旧版)x64 Release(新版)
Thread.Abort を異常停止に使用完了したように見える/完了イベントも来ることがある完了イベントが来ないケースが再現しやすい
協調的キャンセル(CancellationToken等)に変更動く動く
後片付け(finally)順序偶然、意図した順に見えることがある並行実行のため順序が崩れることがある

「x86とx64でSystem.dllが違うから挙動が変わるのでは?」と疑いたくなりますが、結論から言うと “DLLが別物だから” という整理は本質ではありません。差が出るとしたら、実行時のスケジューリングやJIT最適化によるタイミング差、そして Thread.Abort 自体の非決定性が大きいです。

System.dllの違いではなく「実行のされ方」が違う

.NET Frameworkの多くのライブラリは共通の中間コード(IL)を持ち、実行時にプラットフォーム(x86/x64)に合わせて動作します。つまり、x86とx64で“別のSystem.dllにすり替わって全く別のロジックが動く”というより、

  • JIT(実行時コンパイル)や最適化の違いで命令列や割り込み可能なタイミング(安全点)が変わる
  • CPUレジスタやメモリ配置の違いでスレッドの進み方・割り込みの入り方が変わる
  • Releaseビルドで最適化されるほど“たまたまの順序” が再現しにくくなる

といった要因で、結果として同じアプリでも「x86では起きにくい/x64では起きやすい」現象が見えることがあります。ここを押さえると、後述する Thread.Abort の危険性が腹落ちしやすくなります。

BackgroundWorker の完了イベントは「UIスレッドにポストされる」

BackgroundWorkerは、DoWork をバックグラウンドで実行し、完了したら RunWorkerCompleted を呼びます。しかし重要なのは、完了イベントがバックグラウンドスレッドで直接呼ばれるのではなく、多くのケースでUIスレッド側(Dispatcher/SynchronizationContext)へ通知がポストされてから実行される、という点です。

段階何が起きるかつまずきやすいポイント
開始UIスレッドで RunWorkerAsync を呼ぶ開始時点でUIスレッドのコンテキストが“通知先”として捕捉される
実行DoWork が別スレッドで動く(多くの場合スレッドプール)UIとは別なので、UIに触ると例外やフリーズの原因になる
完了通知完了(成功/失敗/キャンセル)をUIコンテキストにポストUIスレッドが詰まっていると通知が処理されず“呼ばれないように見える”
完了イベントUIスレッドで RunWorkerCompleted が実行されるここでUI更新・状態遷移・次処理開始などを行いがち

つまり、RunWorkerCompleted が来ないように見える場合でも、実際には「バックグラウンド側は終わっているが、UI側で通知が処理されていない」可能性があります。そして Thread.Abort は、この“通知のポスト”や“UI側での処理”が成立する前提を簡単に壊します。

根本原因:Thread.Abort は「強制終了」なので順序も通知も保証できない

Thread.Abort は対象スレッドに対して 非同期に例外(ThreadAbortException)を注入し、スレッドの実行を止めようとします。ここがポイントで、スレッドがいつ・どこで止まるかは、実行環境やタイミングに左右されます。

特に次のような状況では、完了イベントを“期待通り”に扱うのが難しくなります。

  • Abortが DoWork のユーザーコード中ではなく、BackgroundWorker内部の完了処理の途中で入る
  • Abortが スレッドプールスレッドに入る(スレッドの再利用や管理状態に影響)
  • Abortによってロック解放やfinally実行が絡み、後片付けの順序が揺らぐ
  • UI側が別の例外や負荷で詰まり、通知が処理されない
Thread.Abort に期待してはいけないことなぜ危険か起きやすい実害
「必ず finally が意図通りの順で走る」並行実行は順序非保証。Abortは割り込み地点も非決定片方が先に解放してほしい資源をもう片方が掴んだまま、など
「必ず RunWorkerCompleted が来る」完了通知のポスト前に中断され得る。UI側で未処理にもなり得る画面が“処理中”のまま、ボタンが戻らない、状態が更新されない
「Abortしてもアプリ全体は健全」中断された側が保持していた状態が不整合になり得る次回処理で不定エラー、メモリリーク、ハングの温床

なぜx64 Releaseで起きやすくなるのか(考え方)

x86とx64、DebugとReleaseで「同じAbortでも結果が違う」ことがあります。これは“x64が悪い”のではなく、Thread.Abortがそもそも再現性の低い操作だからです。x64 Releaseでは最適化やスケジューリングの差で、たとえば次のような“嫌な瞬間”にAbortが入りやすくなります。

  • DoWork のユーザー処理は戻ったが、完了通知を投げる直前
  • 例外捕捉はしたが、例外情報を詰めて通知する処理の途中
  • UIに通知は投げたが、UIスレッドが詰まっていて処理される前にアプリが終了

結果として「x86では完了イベントが来たのに、x64では来ない」という見え方になります。ここで “x64版System.dllのバグ” を疑っても、根は Thread.Abort の非決定性なので、長期的には同じ問題に戻ってきます。

正攻法:協調的キャンセルに寄せる(BackgroundWorkerの流儀に合わせる)

強制終了ではなく、処理側が自分で止まる「協調的キャンセル」に寄せるのが安定します。ポイントは、CancellationTokenだけを止めて終わりにしないことです。BackgroundWorkerを使うなら、CancelAsync()WorkerSupportsCancellation を含めて “BackgroundWorker流” に揃えると、完了イベント・キャンセル状態の伝播が整理できます。

なお、CancellationTokenは値型で、よくある誤解として「token.Value」のような書き方をしてしまうケースがありますが、CancellationTokenに.Valueはありませんtoken.IsCancellationRequestedtoken.ThrowIfCancellationRequested() を使うのが自然です。

やること目的よくある落とし穴
WorkerSupportsCancellation = trueBackgroundWorkerにキャンセル対応を宣言宣言しないまま CancelAsync しても意味が薄い
キャンセル要求側で CancelAsync() も呼ぶCancellationPending を立てて、BWCの文脈に合わせるTokenだけCancelして「BWCは知らない」状態にすると設計が散る
DoWorkCancellationPending / token を定期チェック“止まれる地点”を作るループが長い/ブロッキングI/Oでチェックできないと止まらない
キャンセル時は e.Cancel = trueRunWorkerCompleted 側で e.Cancelled が立つ例外で止めると e.Error と混ざり、UIが誤判定しやすい

以下は「BackgroundWorkerのキャンセル」と「CancellationToken」の両方を使い、x86/x64で挙動を揃えやすくする最小例です。

private readonly BackgroundWorker _worker = new BackgroundWorker
{
    WorkerSupportsCancellation = true
};

private CancellationTokenSource _cts;

public void Start()
{
    if (_worker.IsBusy) return;

    _cts = new CancellationTokenSource();

    _worker.DoWork -= Worker_DoWork;
    _worker.RunWorkerCompleted -= Worker_RunWorkerCompleted;

    _worker.DoWork += Worker_DoWork;
    _worker.RunWorkerCompleted += Worker_RunWorkerCompleted;

    _worker.RunWorkerAsync(_cts.Token);
}

public void Cancel()
{
    if (!_worker.IsBusy) return;

    // BackgroundWorker側の“キャンセル要求”も立てる
    _worker.CancelAsync();

    // 処理側にもキャンセルを通知
    _cts.Cancel();
}

private void Worker_DoWork(object sender, DoWorkEventArgs e)
{
    var bw = (BackgroundWorker)sender;
    var token = (CancellationToken)e.Argument;

    try
    {
        // 例:長い処理を小さな単位に分割して、止まれる地点を作る
        while (true)
        {
            if (bw.CancellationPending || token.IsCancellationRequested)
            {
                e.Cancel = true;
                return;
            }

            DoOneStep(); // 1ステップ分だけ進める
        }
    }
    catch (OperationCanceledException)
    {
        e.Cancel = true;
    }
    catch (Exception)
    {
        // RunWorkerCompleted の e.Error に回る
        throw;
    }
}

private void Worker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
    if (e.Cancelled)
    {
        // キャンセル後のUI更新
        return;
    }

    if (e.Error != null)
    {
        // 例外時のUI更新(ログ、ダイアログ、状態遷移)
        return;
    }

    // 正常完了時のUI更新
}

この形にしておくと、完了は必ず RunWorkerCompleted に集約され、UI側の状態遷移(ボタンの有効/無効、進捗表示の停止、次処理の開始)が整理しやすくなります。

「finallyの実行順が意図通りにならない」を解決する:順序は同期で固定する

キャンセル方式にすると「ProtocolのfinallyよりPCRのfinallyを先に走らせたい」など、後片付け順を保証したくなることがあります。しかし、並行実行している以上、finallyの実行順は“結果”であって“制御”ではありません。順序を保証したいなら、順序そのものを明示的に同期して設計します。

方法:ManualResetEventSlimで「先に片付ける側」が合図し「後に片付ける側」が待つ

最も単純で効果が高いのは、後片付けの境界に同期点を作る方法です。例えば「PCRの後片付け完了→Protocolの後片付け開始」という依存関係を固定できます。

private readonly ManualResetEventSlim _pcrCleanupDone = new ManualResetEventSlim(false);

private void Pcr_DoWork(object sender, DoWorkEventArgs e)
{
    try
    {
        RunPcrMacro();
    }
    finally
    {
        // PCRの後片付け(先にやりたい)
        CleanupPcr();
        _pcrCleanupDone.Set(); // 合図
    }
}

private void Protocol_DoWork(object sender, DoWorkEventArgs e)
{
    try
    {
        RunProtocolMacro();
    }
    finally
    {
        // PCRの後片付けが終わるまで待つ(無限待ちは避けてタイムアウト推奨)
        _pcrCleanupDone.Wait(TimeSpan.FromSeconds(10));

        // Protocolの後片付け(後にやりたい)
        CleanupProtocol();
    }
}

ポイントは、無限待ちは避けることです。片方が想定外に止まらないと、もう片方がfinallyで永久待ちになり、アプリの終了処理にまで影響します。タイムアウト後のフォールバック(ログ出力・安全側の解放・ユーザー通知)も用意すると運用が安定します。

方法:Task化して「続き」の関係を作る(後片付けを直列化する)

BackgroundWorkerを維持しつつも、後片付けだけは「Taskの続き」に寄せると順序が作りやすくなります。考え方としては、後片付けは並行でやらない、です。

private readonly TaskCompletionSource<object> _pcrFinished = new TaskCompletionSource<object>();

private void Pcr_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
    try
    {
        // PCRの後片付け
        CleanupPcr();
    }
    finally
    {
        _pcrFinished.TrySetResult(null);
    }
}

private async void Protocol_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
    // PCRの後片付け完了を待ってから、Protocolの後片付けへ
    await _pcrFinished.Task;

    CleanupProtocol();
}

UIスレッド上で後片付けを順序制御したい場合、こうした「完了を待ってから次へ」の形が分かりやすく、ログや例外処理も集約しやすいです。もちろん、後片付けが重い場合はUIを止めないように、後片付け自体を別スレッドで直列実行する設計(次の方法)も検討します。

方法:後片付け専用のオーケストレーターを用意して“必ずこの順で”実行する

規模が大きい場合や、後片付けが複数段階(例:通信停止→装置解放→ファイルクローズ→ログフラッシュ)に分かれる場合は、後片付けを一箇所に集約するのが最も事故が少ないです。

方法順序の強さ実装コスト向くケース
ManualResetEventSlim 等で待ち合わせ強い(依存関係を直に固定)「Aのcleanup後にB」など関係が少ない
TaskのContinue/awaitで直列化強い(続きとして表現)UIで状態遷移と後片付けをまとめたい
後片付けキュー(オーケストレーター)最強(全てを一列で実行)中〜高後片付けが複雑/順序の種類が多い

どの方式でも共通するのは、finallyの順番を“観測”するのではなく、順番を“設計”するという姿勢です。これができると、x86/x64やRelease/Debugの違いに振り回されにくくなります。

どうしても強制終了を残したい場合の現実的な選択肢

ビジネス要件として「固まった処理を必ず止めたい」「装置制御で緊急停止が必要」など、協調的キャンセルだけでは足りないこともあります。ただし、同一プロセス内のThread.Abortで“安全に止める”のは難しいのが現実です。強制停止が要るなら、強制停止に耐えられる境界を作るのが有効です。

手段止め方の強さメリットデメリット
協調的キャンセル(推奨)弱〜中後処理・状態が整合しやすい。完了通知も安定処理側が“止まれる設計”でないと効かない
別プロセス化してプロセスを終了固まった処理でも止められる。親プロセスの健全性を守りやすいIPC設計が必要。状態受け渡しが増える
Thread.Abort(最終手段)強そうに見えるコード変更が少なく見える通知/順序/整合性が壊れやすい。再現性が低い

特に「マクロ処理が外部装置やサードパーティライブラリに依存し、止まり方を自分で制御できない」タイプでは、別プロセス化の効果が大きいです。親(WPF UI)と子(マクロ実行)を分離し、子が固まったら子だけを終了できるようにしておくと、UIが生き残りやすく、ユーザー体験も改善します。

「完了イベントが来ない」を見分けるためのチェックリスト

最後に、今回のような事象を切り分けるときに役立つ確認項目をまとめます。原因が複合すると「Abortのせい」だけでは説明できない見え方になるため、チェックリストとして持っておくと便利です。

  • UIスレッドがブロックされていないか(重い処理をUIでしていないか、Dispatcherが回っているか)
  • RunWorkerCompletedで例外が出ていないか(例外で途中終了すると“呼ばれたのに見えない”)
  • 完了を待たずにアプリ/テストが終了していないか(ユニットテストなら完了待ちを入れる)
  • キャンセル要求の経路が二重化していないか(CancelAsyncとTokenが別々に動いて状態がズレる)
  • 後片付けが並行に走って衝突していないか(ファイル/ポート/装置を取り合っていないか)

例えばテストで「完了イベントが来ない」を避けるなら、完了を待つ同期を明示します。

var tcs = new TaskCompletionSource<RunWorkerCompletedEventArgs>();

worker.RunWorkerCompleted += (s, e) => tcs.TrySetResult(e);

worker.RunWorkerAsync();

// テストはイベントを待ってからアサートする
var completed = await tcs.Task;

まとめ:x64をThread.Abortで“直す”のではなく、止め方と順序を設計する

x86とx64の差を「System.dllが違うから」と捉えるより、Thread.Abortという強制終了が非決定である、そしてBackgroundWorkerの完了通知がUIスレッドにポストされるという性質を押さえる方が、再現性のある対策につながります。

  • 完了イベントや後処理順を期待して Thread.Abort を使い続けるのは危険
  • キャンセル方式に寄せるなら、CancelAsyncCancellationPending を含めてBackgroundWorkerの流儀に合わせる
  • 後片付け順を保証したいなら、finallyに期待せず同期で順序を固定する(イベント待ち、Taskの続き、オーケストレーター)
  • どうしても強制停止が必要なら、同一プロセス内のAbortより別プロセス化が現実的

この方針で設計を組み直すと、x64 Releaseでも「完了通知が来ない」「順番が崩れる」といった不安定さが大きく減り、長期運用でも事故が起きにくくなります。

この記事を書いた人

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

コメント

コメントする

目次