想定外例外で終了すべきか継続すべきかの判断表

· 更新日: · · Windows開発, 例外処理, 設計, C# / .NET, 信頼性

更新履歴(6件・最終更新 2026年08月02日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
2点直しました。1つは`BackgroundService`の親ループで、`Environment.ExitCode`を立てて`StopApplication`する形にしていましたが、Windowsサービスとして動かす場合はこれでは足りません。ホストが正常停止するとSCMへは`SERVICE_STOPPED`が報告され、`ExitCode`はサービスの終了状態に反映されないため、設定した回復操作が走りません。公式のワーカーサービスのチュートリアルどおり`Environment.Exit(1)`で終了させ、systemdやコンテナだけが相手なら前の形でもよいことを併記しました。もう1つは1件ずつのループで、`catch (Exception)`が`NullReferenceException`や`OutOfMemoryException`まで「ただの不良データ」として飲み込み、同じ記事で「ホストごと止める」と決めた想定外例外が親ループへ届かない状態でした。1件分の失敗を表す例外型だけを捕まえる形にしています。
`BackgroundService`の親ループが壊れたときの例に、終了コードの設定を足しました。`StopApplication`は正常に畳む要求なのでプロセスは0で終わり、失敗時だけ再起動する監視側(サービスの回復操作、`Restart=on-failure`、コンテナの再起動ポリシー)からは正常終了と区別が付かず、ワーカーが起き上がらないままになります。
WPFのハンドラー登録を`base.OnStartup(e)`の後に書いていたのを前に移しました。`base.OnStartup`はStartupイベントを発火させるので、その購読側で例外が出ると、後ろに書いたハンドラーはまだ登録されておらず、起動時に落ちたのにログが1行も残りません。
コード例を追加しました。1件ずつ処理するループでcatchを失敗単位に閉じ込める例、親ループでは握りつぶさず`StopApplication()`で止める`BackgroundService`の例、WPFの記録専用ハンドラーの例です。`FailFast`の性質(`finally`とファイナライザーを走らせない、イベントログとダンプ、デバッガー下の挙動)の節と、メモリ破壊やゾンビ化を疑うときの症状と確認場所の表、用語の定義を追加しました。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589662)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「想定外例外で終了すべきか継続すべきかの判断表」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589662 https://staging.comcomponent.com/blog/2026/03/16/005-unexpected-exception-exit-or-continue-decision-table/

DOI(最新版)
10.5281/zenodo.21589662
DOI(この版)
10.5281/zenodo.21732689

日英シート付きの Excel チェックリストをダウンロード

このファイルは Checklist-jaChecklist-en の 2 シート構成で、この記事の判断軸を 継続条件 / 終了条件 / 実装方針 / 判断順 の 4 カテゴリ 27 項目に分解したものです。StatusNotes 列は空のままなので、障害対応の記録や設計レビューのチェック表としてそのまま使えます。

想定していない例外の話になると、つい「落とすか、catch して続けるか」の二択で考えがちです。 ただ、実務ではこの二択の置き方が少し乱暴です。

本当に見たいのは、壊れた可能性がある範囲を閉じ込められるか です。

  • その操作だけ失敗で終えられるのか
  • その画面 / 接続 / ワーカーだけ再初期化すればよいのか
  • もうプロセス全体の整合性が怪しいのか

この順で見ると、かなり整理しやすくなります。

この記事では、C# / .NET の Windows アプリ、常駐アプリ、Windows サービス、装置連携ツールなどを前提に、想定していない例外が起きたときに継続してよい条件と、終了したほうがよい条件を判断表としてまとめます。

1. まず結論

  • catch (Exception) で握りつぶして続行、はだいたい危険です。
  • 継続してよいのは、失敗した単位を捨てられる共有状態を元に戻せる外部副作用を説明できる、の 3 つが揃うときです。
  • UI の 1 操作、1 件の入力、1 件のジョブなど、処理境界が明確なら継続できることがあります。
  • 逆に、共有の書き換え可能な状態、常駐ループ、メインスレッド、起動処理、ネイティブ境界、メモリ破壊臭が絡むなら終了寄りです。
  • StackOverflowExceptionAccessViolationExceptionOutOfMemoryException のような「プロセス全体の健全性」を疑う例外は、継続前提で考えないほうが安全です。
  • WPF や Windows Forms には未処理例外を拾って見かけ上続ける道もありますが、続けられること続けて安全なことは別です。
  • 長時間動くサービスや監視アプリは、半分壊れたまま生き延びるより、落ちて再起動されたほうが診断もしやすく安全なことが多いです。

要するに、判断の軸は 不変条件を回復できるか です。

1.1 この記事で使う言葉

判断表を読む前に、繰り返し出てくる 5 語だけ先に決めておきます。

用語 この記事での意味
不変条件 処理の前後で必ず成り立っていなければならない、状態の約束です。「明細の合計と集計値が一致する」「キャッシュと DB の内容がそろっている」「開いた接続は必ず管理一覧に載っている」などが該当します。ここが崩れたまま動き続けると、後続の処理はすべて疑わしくなります
失敗単位 失敗したときに丸ごと捨てられる範囲です。1 操作、1 画面、1 ジョブ、1 接続など
外部副作用 プロセスの外へ出てしまった変更です。DB 更新、ファイル書き込み、メール送信、装置へのコマンド送信など、catch では戻せないもの
サブシステム 停止と再初期化をまとめて行える単位です。接続、画面、ワーカー、子プロセスなど
FailFast Environment.FailFast のことです。try / finally も finalizer も走らせずにプロセスを即終了させる API で、詳細は 9.7 で扱います

この記事の知識マップ

想定外の例外への対応は、catch (Exception)で握りつぶして処理を継続することではなく、共有状態の不変条件と失敗単位、外部副作用を説明できるかどうかで継続か終了かを判断すべきだと整理する記事です。StackOverflowExceptionやAccessViolationException、深刻なOutOfMemoryExceptionのようにメモリ破壊の兆候を伴う例外や、COM・P/Invokeなどネイティブ境界の異常は終了寄りで、Environment.FailFastやEnvironment.Exitが終了の実装手段になります。BackgroundServiceの親ループが想定外例外で落ちた場合は、Windowsサービスとして動かす限りEnvironment.Exitで終了コードを返さないと回復操作が働かず、AppDomain.UnhandledExceptionなどの未処理例外ハンドラーは記録に使うべきで状態の回復手段ではないことも扱います。

想定外の例外を継続すべきか終了すべきかの知識マップ想定外の例外に対する継続と終了の判断が不変条件・失敗単位・外部副作用の確認を前提とすること、メモリ破壊が疑われる例外やネイティブ境界の異常が終了寄りであること、BackgroundServiceの想定外例外時にはEnvironment.Exitでの終了コードがWindowsサービスの回復操作に必要なこと、未処理例外ハンドラーは回復ではなく記録に使うべきであることを示す図前提とする前提とする前提とする原因になり得る用いるのは非推奨推奨される対応推奨される対応推奨される対応原因になり得る推奨される対応実装を担う実装を担う原因になり得る推奨される対応前提とする用いるのは非推奨実装を担う用いるのは非推奨両立しない防止するで確認できる想定外の例外不変条件処理の継続失敗単位外部副作用catch (Exception)での握りつぶしゾンビ化プロセスの終了StackOverflowExceptionAccessViolationExceptionEnvironment.FailFastOutOfMemoryExceptionメモリ破壊の兆候Environment.Exitネイティブ境界BackgroundServiceIHostApplicationLifetime.StopApplicationWindowsサービス未処理例外ハンドラ例外後の状態回復の試み

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全21件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

2. この記事でいう「想定していない例外」

2.1 想定内と想定外を分ける

まず、珍しい例外と想定外の例外は同じではありません。

たとえば、こういうものは頻度が低くても 想定内にできます。

  • ユーザーが存在しないファイルを選んだ
  • 通信先が一時的にタイムアウトした
  • 取り込み CSV の 1 行が壊れていた
  • キャンセル操作で OperationCanceledException が出た
  • 業務ルール違反でその処理だけ失敗にしたい

これらは、その失敗をどう扱うかを設計で先に決められる種類です。

一方で、この記事で主に扱う 想定していない例外は、こういうものです。

  • 自分のコードの前提が崩れて NullReferenceExceptionInvalidOperationException が出た
  • 共有状態の更新途中で例外が飛び、どこまで反映されたか怪しい
  • 監視ループやメッセージ処理の親ループが落ちた
  • COM / P/Invoke / vendor SDK 境界で異常が出た
  • AccessViolationExceptionStackOverflowException のように、そもそもプロセスの健康診断が赤い

つまり、「この例外が起きたあとに、アプリの状態をまだ信用してよいか分からない」ものです。

2.2 二択に見えて本当は三択ある

この話をややこしくする犯人は、継続を 1 種類で考えてしまうことです。

実務では、だいたい 3 段階に分かれます。

選択 意味
その操作だけ失敗させて継続 画面は残すが、今回の保存や取り込みだけ失敗扱いにする
サブシステムだけ止めて継続 接続、画面、ワーカー、子プロセスだけ再初期化する
プロセスを終了する 状態破壊の範囲が読めないので、再起動前提にする

「アプリを継続する」と言っても、何もなかった顔でそのまま続けるのと、壊れた部分を切り離して継続するのでは重みが違います。

3. まず見る判断表

3.1 全体像

まずはこの表から見ると、だいたいの方針が決まります。

状況 まずの選択 理由
1 つの入力、1 つの画面操作、1 つのジョブだけが失敗し、状態を捨てられる 継続寄り 失敗単位を閉じ込められるから
例外後に対象オブジェクトや接続を破棄し、作り直せる サブシステム再初期化寄り 壊れた範囲を局所化できるから
共有状態を途中まで更新したが、どこまで反映されたか分からない 終了寄り 不変条件が崩れている可能性があるから
DB / ファイル / 装置コマンドなど外部副作用が半端で、重複や未反映を説明できない 終了寄り 外の世界との整合が読めないから
監視ループ、再接続ループ、メッセージ処理の親ループが想定外例外で落ちた 終了寄り 黙って一部機能だけ死ぬとゾンビ化しやすいから
起動処理、設定読み込み、DI 構成、必須依存の初期化で失敗した 起動失敗で終了寄り 半端に起動するほうが危険だから
AccessViolationExceptionStackOverflowException、深刻な OutOfMemoryException、native 側の破壊臭がある 即終了寄り プロセス全体の健全性が怪しいから
危険な処理が別プロセスに隔離されていて、親プロセスは無傷 親は継続、子を再起動 障害領域を分離できているから
はいいいえいいえはいいいえはいいいえはい想定していない例外メモリ破壊 / スタック枯渇 / 致命的な資源枯渇の臭い?終了 / FailFast / 再起動失敗した単位を捨てられる?終了寄り共有状態をロールバック / 再初期化できる?サブシステム停止 or 終了外部副作用を説明できる?その操作だけ失敗として継続

図の最初の分岐にある「メモリ破壊の臭い」は 3.4 で、右上の FailFast は 9.7 で具体的に扱います。

3.2 例外型より先に見ること

例外型だけで即決しないほうがよいです。 先に確認したいのは、このあたりです。

見る点 何を確認するか
どこで起きたか UI イベント、1 件ジョブ、親ループ、起動処理、ネイティブ境界のどこか
どこまで進んだか 途中でメモリ状態、DB、ファイル、装置状態が変わっていないか
壊れうる範囲 そのオブジェクトだけか、画面全体か、プロセス全体か
ロールバック可能か 破棄して作り直せるか、トランザクションで戻せるか
外部副作用 送信済みか未送信か、二重実行が安全か、補償処理できるか
監視・再起動 落としたあと自動再起動や復旧導線があるか

3.3 危険度の高い例外

細かい例外型の話を全部する必要はありませんが、継続前提で見ないほうがよいものはあります。

例外 / 兆候 まずの選択 見る理由
StackOverflowException 即終了寄り 呼び出しスタックが破綻しており、通常の回復を前提にしにくい
AccessViolationException 即終了寄り 保護メモリへの不正アクセスで、ネイティブ境界やメモリ破壊が疑われる
OutOfMemoryException 終了寄り 追加割り当て前提の回復処理自体が不安定になりやすい
unexpected な NullReferenceException / InvalidOperationException 文脈次第だが終了寄り 自分の前提崩れであり、途中変更が残っている可能性がある
親ループから漏れた想定外例外 終了寄り 機能の中核が死んでいるのにプロセスだけ残る危険がある
COM / P/Invoke / vendor SDK callback 起点の異常 即終了〜強めの終了寄り managed だけ見ても安全性を判断しづらい

3.4 「メモリ破壊の臭い」「ゾンビ化」は何を見て言うのか

この 2 つは感覚的な言葉に見えますが、実際に見ているものは決まっています。

まず、メモリ破壊を疑う症状です。

見えるもの どこで見るか
例外コード 0xc0000005(アクセス違反)や 0xc0000374(ヒープ破損)でプロセスが落ちる イベント ビューアー > Windows ログ > Application の「アプリケーション エラー」(イベント ID 1000)
落ちる場所が毎回違う。直前に触っていないコードで落ちる ログ、ダンプの呼び出しスタック
解放したはずのオブジェクトやハンドルを触ったときだけ落ちる 再現手順、ダンプ
ネイティブ側の解放処理(free / delete / COM の解放)で落ちる ダンプの呼び出しスタック(ntdll.dll の中で落ちるなど)
触っていない値が書き換わっている。同じ入力なのに結果が変わる 入出力ログ、再実行結果の比較

0xc0000005 は STATUS_ACCESS_VIOLATION、0xc0000374 は STATUS_HEAP_CORRUPTION で、いずれも Microsoft の NTSTATUS 一覧に定義されている値です。特にヒープ破損は、壊した瞬間ではなく 壊れたヒープを次に触った側 で落ちるため、落ちた場所が犯人とは限りません。この形の症状が出ているなら、managed 側で catch して続けても意味がない、と考えたほうが安全です。

次に、ゾンビ化を疑う症状です。

見えるもの どこで見るか
プロセスは生きているのに、直近の処理時刻が更新されない 最終処理時刻のログ、heartbeat
キューや受信フォルダの滞留件数だけが増え続ける キュー長、未処理ファイル数
ある時刻からログがぱたりと止まっている アプリログ
画面は操作できるのに、裏の更新だけ止まっている 画面表示と実データの突き合わせ
ワーカースレッド数が想定より減っている 診断ログ、Process Explorer のスレッド一覧

ゾンビ化とは、落ちていないことではなく 仕事をしていないのに生きている ことです。最終処理時刻、滞留件数、heartbeat のように「動いていることを示す指標」を先に用意しておくと、継続か終了かの判断も、後追いの調査もかなり楽になります。

4. どこで起きたかで判断する

4.1 UI イベント

ボタンクリック、画面遷移、検索、ファイル選択のような UI イベントは、継続できる余地が比較的大きいです。 ただし、条件があります。

継続しやすいのは、こんな場合です。

  • 読み込み前に失敗し、業務状態をまだ触っていない
  • ダイアログ内の一時状態だけ壊れており、画面を閉じれば捨てられる
  • 例外後に ViewModel や接続を作り直せる
  • 利用者へ「今回の操作は失敗した」と正直に伝えられる

逆に、こうなってくると終了寄りです。

  • 画面とドメイン状態の両方を途中まで更新した
  • static / singleton / キャッシュなど、他画面も見る共有状態を触った
  • 例外が起きたあと、ボタン活性や選択状態だけ残って整合が分からない
  • UI スレッド上で unexpected な例外が起き、どこまで描画や通知が進んだか怪しい

4.2 1 件ずつ処理するジョブ / リクエスト

ここは、継続しやすい境界です。

  • 1 メッセージ
  • 1 ファイル
  • 1 HTTP リクエスト
  • 1 取り込みジョブ
  • 1 バッチ対象

こうした単位が明確なら、その 1 件だけ失敗にして次へ進めます。

ただし、前提があります。

  • 失敗単位が外から見て明確
  • 途中変更がトランザクションや補償で整う
  • 同じ処理をもう一度流しても結果が壊れない性質がある
  • 失敗を隔離キューやエラー記録へ逃がせる

4.3 常駐ループ / 監視 / キュー処理

ここは、雑に継続すると一番まずい場所です。

たとえば:

  • 再接続ループ
  • 監視ループ
  • キュー消費ループ
  • 定期ポーリング
  • 装置状態監視
  • トレイアプリの常駐処理

この手の処理で怖いのは、親ループが 1 回の想定外例外で死に、プロセスだけ生き残ることです。

ここでは方針を分けたほうがよいです。

  • 各アイテム処理の境界で想定内例外を捕まえる
  • 親ループから想定外例外が漏れたら、プロセス終了寄りにする

4.4 起動処理

起動時の失敗を「とりあえず起動してから考える」にすると、機能の一部だけが欠けた状態で動き続けることになり、後から原因を切り分けにくくなります。

  • 必須設定が読めない
  • バージョン移行 / マイグレーションに失敗した
  • 必須フォルダや証明書がない
  • 中核サービスの初期化に失敗した
  • 依存関係の構成が壊れている

こういう場合は、起動失敗として終了のほうが分かりやすいです。

4.5 ネイティブ境界 / COM / P/Invoke / unsafe

ここは別枠で少し厳しめに見たほうがよいです。

  • COM
  • P/Invoke
  • C++/CLI の先
  • vendor SDK
  • callback で戻ってくる native 側コード
  • unsafe を含む処理

特にこのあたりが見えたら終了寄りです。

  • AccessViolationException
  • ヒープ破壊やダブル free を疑う症状
  • ハンドル異常、解放後アクセスの臭い
  • callback 境界で突然死ぬ

5. 継続してよい条件

継続してよい条件をまとめると、こうなります。これらがだいたい揃っていることが前提です。

条件 意味
失敗単位が明確 1 操作、1 画面、1 ジョブ、1 接続など、捨てる単位が分かる
状態を捨てられる 破棄して作り直せる、または未反映として扱える
共有状態が守られる 他の機能へ汚染が広がらない
外部副作用を説明できる 送った / 送っていない / 再送してよい、が分かる
利用者へ正直に伝えられる 「今回の処理は失敗した」と表示できる
監視できる ログ、メトリクス、ダンプで後追い調査できる

6. 終了したほうがよい条件

逆に、ここに当てはまるなら終了寄りです。

  • 途中まで何を変更したか分からない
  • 共有の書き換え可能な状態を触っていて、整合が読めない
  • ロック、キュー、スレッド、監視ループの寿命管理が壊れた
  • 外部副作用の重複 / 欠落 / 半端が説明できない
  • 起動処理や中核インフラの初期化で失敗した
  • ネイティブ境界やメモリ破壊を疑う

このレベルなら、きれいに継続する工夫より、落として復旧しやすくする工夫のほうが効きます。

7. 典型パターン別のおすすめ

3.1 の判断表は条件の形で書いてあるので、実際の場面に当てはめるときに迷うことがあります。この表は、その条件をよく見かける場面へ当てはめ直したものです。迷ったら、まず 3.1 で条件を確認し、それからこの表で近い行を探す順番が早いです。

パターン おすすめ 理由
ファイルを開くボタンで存在しないパスを指定した その操作だけ失敗で継続 状態破壊が局所的だから
CSV 取り込みの 1 行だけ壊れていた 1 行失敗 or 1 ファイル失敗で継続 失敗単位を閉じ込めやすいから
画面保存の途中で unexpected な NullReferenceException が出た 画面再作成〜終了寄り どこまで ViewModel / 業務状態が変わったか怪しいから
キューの 1 メッセージが業務ルール違反だった そのメッセージだけ失敗で継続 隔離キューへ逃がせるから
キュー消費の親ループが想定外例外で落ちた プロセス終了寄り ワーカー全体の寿命が壊れているから
起動時に必須設定が読めない 起動失敗で終了 半端起動のほうが危険だから
vendor SDK callback まわりで AccessViolationException 即終了寄り メモリ破壊の可能性を無視できないから
非本質なテレメトリ送信だけ失敗した その機能だけ無効化して継続 主機能と障害領域を分けられるから

8. よくある NG

8.1 catch (Exception) でログだけ出して続ける

これはかなり危険です。 原因を隠すうえに、壊れた状態を延命しやすいからです。

8.2 最後の未処理例外ハンドラで回復しようとする

AppDomain.UnhandledExceptionApplication.ThreadExceptionDispatcherUnhandledException などは、最後に記録する場所としては有用ですが、魔法の回復ポイントではありません。

8.3 外部副作用があるのに安易に retry する

装置コマンド、メール送信、課金、ファイル移動、DB 更新などで同じ処理の再実行安全性がないまま retry すると、今度は二重実行事故が主役になります。

8.4 監視ループが死んだのに UI だけ残す

見た目だけ生きていて、仕事をしていないアプリは、利用者からは正常に見えるぶん発見が遅れます。3.4 のゾンビ化の症状が出ていないかを、監視側で拾えるようにしておきたいところです。

8.5 落とす設計をしていないのに「落としたくない」と言う

落としたくないなら、その前に入れておくものがあります。

  • 自動再起動
  • セッション復元
  • 途中成果の保存
  • 再実行安全性
  • 障害領域の分離

9. 実装時の整理ポイント

9.1 catch する場所を境界に寄せる

深い層で何でも catch するより、

  • UI 操作境界
  • 1 リクエスト境界
  • 1 ジョブ境界
  • 1 接続境界
  • プロセス境界

のように、失敗単位が定義できる場所で受けるほうが整理しやすいです。

たとえば、1 件ずつ処理する取り込みなら、catch はループの中に置きます。ここが失敗単位です。

// C# / .NET 8。1 件ずつ処理するループで、失敗単位をループの内側に閉じ込める例。
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;

public sealed record ImportItem(string Id, string Payload);
public sealed record ImportFailure(string Id, string ExceptionType, string Message);
public sealed record ImportSummary(int Succeeded, IReadOnlyList<ImportFailure> Failed);

public interface IImportStore
{
    /// <summary>
    /// 1 件分の失敗(検証エラー、重複、書式不正など)は ImportItemException で投げる。
    /// それ以外は「この 1 件の問題」ではないので、そのまま外へ抜けさせる。
    /// </summary>
    Task SaveAsync(ImportItem item, CancellationToken cancellationToken);
}

/// <summary>捨てて次へ進んでよい、1 件分の失敗。</summary>
public sealed class ImportItemException(string message, Exception? inner = null)
    : Exception(message, inner);

public sealed class ImportRunner(IImportStore store, ILogger<ImportRunner> logger)
{
    public async Task<ImportSummary> RunAsync(
        IReadOnlyList<ImportItem> items,
        CancellationToken cancellationToken)
    {
        int succeeded = 0;
        List<ImportFailure> failed = [];

        foreach (ImportItem item in items)
        {
            cancellationToken.ThrowIfCancellationRequested();

            try
            {
                await store.SaveAsync(item, cancellationToken);
                succeeded++;
            }
            catch (ImportItemException ex)
            {
                // 1 件分の状態は捨てられるので、記録して次の 1 件へ進む。
                //
                // ここを catch (Exception) にしないこと。NullReferenceException や
                // OutOfMemoryException まで「ただの不良データ」として飲み込むと、
                // 4.3 で「ホストごと止める」と決めた想定外例外が親ループへ届きません。
                // 状態が信用できなくなった後も、残りの件を書き続けることになります
                logger.LogError(ex, "Import failed. ItemId={ItemId}", item.Id);
                failed.Add(new ImportFailure(item.Id, ex.GetType().Name, ex.Message));
            }
        }

        return new ImportSummary(succeeded, failed);
    }
}

逆に、この処理を回す 親ループ側では握りつぶさない のが対になる判断です。4.3 で書いたとおり、親ループが死んだままプロセスだけ残るのが一番まずい形なので、想定外例外はホストの停止につなげます。

// 常駐ループ側。停止要求は正常系として抜け、それ以外の想定外例外はホストごと止める。
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

public interface IImportQueue
{
    Task<IReadOnlyList<ImportItem>> DequeueBatchAsync(CancellationToken cancellationToken);
}

public sealed class ImportWorker(
    IImportQueue queue,
    ImportRunner runner,
    IHostApplicationLifetime lifetime,
    ILogger<ImportWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            using PeriodicTimer timer = new(TimeSpan.FromSeconds(10));

            while (await timer.WaitForNextTickAsync(stoppingToken))
            {
                IReadOnlyList<ImportItem> batch = await queue.DequeueBatchAsync(stoppingToken);
                ImportSummary summary = await runner.RunAsync(batch, stoppingToken);

                logger.LogInformation(
                    "Batch finished. Succeeded={Succeeded} Failed={Failed}",
                    summary.Succeeded,
                    summary.Failed.Count);
            }
        }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
        {
            // 停止要求は正常系。ここで静かに終わる。
        }
        catch (Exception ex)
        {
            // 親ループが壊れた = ワーカー全体の寿命が壊れている。
            // 握りつぶして「プロセスだけ生きている」状態を作らず、止めて再起動へ渡す。
            logger.LogCritical(ex, "Worker loop failed unexpectedly. Stopping the host.");

            // StopApplication は「正常に畳む」要求です。これだけだと、失敗時だけ
            // 再起動する監視側(サービスの回復操作、systemd の Restart=on-failure、
            // コンテナの restart ポリシー)からは「仕事を終えて正常終了した」と
            // 区別が付かず、二度と起き上がりません。
            //
            // Environment.ExitCode を立てるだけでも足りません。Windows サービスとして
            // 動かす場合、ホストが正常停止すると SCM へは SERVICE_STOPPED が
            // 報告され、ExitCode はサービスの終了状態に反映されないため、
            // 回復操作は走りません。0 以外の終了コードでプロセスを終了させます
            Environment.Exit(1);
        }
    }
}

Environment.Exit(1) を使っているところが要点です。IHostApplicationLifetime.StopApplication() は正常停止の要求なので、Windows サービスとして動かしている場合、SCM へは SERVICE_STOPPED が報告されます。プロセスの Environment.ExitCode はサービスの終了状態には反映されないため、サービスのプロパティで設定した回復操作(「サービスを再起動する」)は走りません。公式のワーカーサービスのチュートリアルも、既定の BackgroundServiceExceptionBehavior.StopHost は「きれいに止める」ので Windows のサービス管理側は再起動しない、回復操作を効かせるには 0 以外の終了コードで Environment.Exit を呼ぶ必要がある、と明記しています(11章の参考資料)。

Environment.Exit は今のプロセスを終わらせるので、落ちる前に出しておきたいログは、この行より前に書き切ってください。バッファリングするロガーを使っているなら、フラッシュを挟みます。逆に、Windows サービスではなく systemd やコンテナだけが相手なら、Environment.ExitCode を立ててから StopApplication() で畳む形でも監視側は失敗として扱えます。どちらの環境で動かすかで選んでください。

なお、ループの内側と外側で catch の役割が変わる点も押さえておきます。内側は失敗単位の記録、外側は寿命の終了です。ここを取り違えると、1 件の失敗でアプリが落ちたり、逆にワーカーが死んでもプロセスが生き残ったりします。

9.2 想定内例外と想定外例外を分ける

  • 想定内: validation、not found、timeout、cancel、業務ルール違反
  • 想定外: 前提崩れ、親ループ漏れ、ネイティブ境界異常、メモリ破壊臭

9.3 共有状態を小さくする

共有の書き換え可能な状態が大きいほど、継続判断は難しくなります。 逆に、1 画面 1 セッション 1 ワーカーの中へ閉じ込められるほど、失敗も閉じ込めやすくなります。

9.4 危険な処理は別プロセスへ逃がす

COM / ActiveX / vendor SDK / unsafe / 重い画像処理 / 外部機器制御など、落ちたときの被害を広げたくないものは、別プロセス化がかなり効きます。

9.5 未処理例外ハンドラは「回復」より「記録」

  • 例外情報
  • 操作文脈
  • 直前の重要ログ
  • 設定 / バージョン / 接続先
  • dump 採取導線

このあたりを揃えて、落ちたあとに詰められる形を優先したほうが、結果として安定します。

WPF なら、記録用のハンドラーはこのくらいの形で足ります。

// WPF (.NET 8) の App.xaml.cs。ハンドラーは「回復」ではなく「記録」に使う。
using System;
using System.IO;
using System.Reflection;
using System.Threading.Tasks;
using System.Windows;

namespace SampleApp;

public partial class App : Application
{
    private static readonly string CrashLogPath = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
        "SampleApp",
        "crash.log");

    protected override void OnStartup(StartupEventArgs e)
    {
        // ハンドラーの登録は base.OnStartup(e) より前に置く。
        // base.OnStartup は Startup イベントを発火させるので、その購読側で例外が
        // 出ると、後ろに書いたハンドラーはまだ登録されておらず、
        // 「起動時に落ちたのにログが1行も残らない」という一番困る形になる。
        // 起動処理そのものの失敗こそ記録したいので、先に張っておく。

        // UI スレッドで未処理のまま上がってきた例外
        DispatcherUnhandledException += (_, args) =>
        {
            Record("DispatcherUnhandledException", args.Exception);

            // args.Handled = true にすれば続行できるが、続けてよいかは 5 章の条件で判断する。
            // 判断がつかないなら、記録して既定動作(終了)に任せる。
            args.Handled = false;
        };

        // UI スレッド以外も含む最後の通知。ここでは止められないので記録専用。
        AppDomain.CurrentDomain.UnhandledException += (_, args) =>
            Record("AppDomain.UnhandledException", args.ExceptionObject as Exception);

        // await されないまま回収された Task の例外
        TaskScheduler.UnobservedTaskException += (_, args) =>
        {
            Record("UnobservedTaskException", args.Exception);
            args.SetObserved();
        };

        // ここまで張り終えてから、既定の起動処理(Startup イベントの発火)へ進む
        base.OnStartup(e);
    }

    private static void Record(string source, Exception? exception)
    {
        try
        {
            Directory.CreateDirectory(Path.GetDirectoryName(CrashLogPath)!);

            string version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
            string text = string.Join(
                Environment.NewLine,
                $"[{DateTimeOffset.Now:O}] {source}",
                $"version={version} os={Environment.OSVersion} user={Environment.UserName}",
                exception?.ToString() ?? "(no exception object)",
                string.Empty);

            File.AppendAllText(CrashLogPath, text);
        }
        catch
        {
            // 記録に失敗しても、終了処理を止めない。
        }
    }
}

ポイントは、ハンドラーの中で状態を直そうとしないことです。ここでやるのは、次に同じ現象を追える材料を残すことだけです。

9.6 WPF / WinForms の未処理例外イベントを過信しない

WPF では DispatcherUnhandledExceptionHandled = true にすると、未処理例外後も続けること自体はできます。 Windows Forms でもメイン UI スレッドでは Application.ThreadExceptionSetUnhandledExceptionMode の設定次第で止め方を選べます。

ただ、それで続けられるかどうかと、回復条件が揃っているか は別の問題です。

9.7 Environment.FailFast は「片づけるほうが危険」なときだけ

3.1 のフローチャートに出てくる FailFast は、Environment.FailFast のことです。公式ドキュメントに書かれている挙動は次のとおりです。

  • 実行中の try / finally も finalizer も 走らせずに プロセスを終了する
  • Windows では、渡したメッセージを Windows のアプリケーション イベント ログ へ書き、アプリケーションのダンプを作成 してから終了する
  • メッセージと例外情報は、Windows エラー報告経由で Microsoft へのエラー報告にも含まれる
  • Visual Studio のデバッガー下で呼ぶと ExecutionEngineException になり、fatalExecutionEngineError の managed debugging assistant が発生する

finally を走らせないのは欠点ではなく、この API の目的です。状態が壊れているときに後片づけコードを走らせると、壊れた内容をそのままファイルや DB へ書いてしまうことがあるからです。ドキュメントでも、アプリの状態が修復不能に壊れており、try / finally や finalizer を実行するとリソースを壊してしまう場合Environment.Exit ではなく FailFast を使う、と説明されています。

使い分けは、こうなります。

状況 選ぶもの
不変条件が壊れており、後片づけを走らせるほうが危ない Environment.FailFast
状態は健全で、片づけてから終わりたい 正常な終了処理(IHostApplicationLifetime.StopApplication など)
単に終了コードを返して終わりたい Environment.Exit または Main からの return

コードにすると、次のような場所で使います。

// C# / .NET 8。共有状態の不変条件が壊れたことを検出した箇所。
// ここから先は、どの後片づけ処理も信用できない。
if (cache.Count != store.Count)
{
    Environment.FailFast(
        $"Invariant broken: cache={cache.Count} store={store.Count}",
        new InvalidOperationException("Cache and store are out of sync."));
}

ダンプが自動で採取されるので、FailFast に渡すメッセージには どの不変条件が、どの値で壊れていたか を入れておくと、後の調査がかなり楽になります。逆に、入力ミスや通信エラーのような想定内の失敗で FailFast を呼ぶのは過剰です。そこは 9.1 の失敗単位の話に戻ります。

10. まとめ

想定していない例外が起きたときに見るべきなのは、「この例外は catch できるか」ではなく、このあともアプリの状態を信用できるかです。

判断の順番としては、だいたいこれで十分です。

  1. 失敗した単位を捨てられるか
  2. 共有状態を戻せるか、作り直せるか
  3. 外部副作用を説明できるか
  4. メモリ / スレッド / ネイティブ境界の健全性は信用できるか

この 4 つに自信があるなら継続できます。 自信がないなら、終了寄りです。

特に、長時間動くアプリ、監視アプリ、サービス、装置連携では、壊れたまま生きることのほうが、素直に落ちることより危険な場面がかなりあります。

例外処理は「落とさない技術」ではありません。 壊れ方を小さくし、壊れたら正直に止まり、復旧しやすくする設計です。

11. 参考資料

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

想定外の例外をcatch (Exception)で握りつぶして続行してはいけないのですか?
ログだけ出して続けるのはだいたい危険です。原因を隠すうえに、壊れた状態を延命しやすいからです。継続してよいのは、失敗した単位を捨てられる、共有状態を元に戻せる、外部副作用を説明できる、の3つが揃うときです。判断の軸は「この例外は catch できるか」ではなく「このあともアプリの状態を信用できるか」です。
どんな例外が出たら即終了すべきですか?
StackOverflowException、AccessViolationException、深刻なOutOfMemoryExceptionのように、プロセス全体の健全性を疑う例外は継続前提で考えないほうが安全です。StackOverflowExceptionは呼び出しスタックが破綻しており、AccessViolationExceptionは保護メモリへの不正アクセスでメモリ破壊が疑われます。COM・P/Invoke・vendor SDKのcallback起点の異常も、managed側だけ見ても安全性を判断しづらいため強めの終了寄りです。
例外が起きてもアプリを継続してよいのはどんな条件ですか?
失敗単位が明確(1操作・1画面・1ジョブ・1接続など捨てる単位が分かる)、状態を破棄して作り直せる、共有状態へ汚染が広がらない、外部副作用を説明できる、利用者へ「今回の処理は失敗した」と正直に伝えられる、ログやメトリクスで後追い調査できる、という条件がだいたい揃っていることが前提です。UIの1操作や1件の取り込みジョブのように処理境界が明確なら継続できることがあります。逆に共有状態の更新途中、親ループ、起動処理、ネイティブ境界の異常は終了寄りです。
WPFのDispatcherUnhandledExceptionでHandled=trueにすれば継続できますか?
未処理例外後も続けること自体はできますが、続けられることと続けて安全なことは別問題です。AppDomain.UnhandledExceptionやDispatcherUnhandledExceptionなどのハンドラは、最後に記録する場所としては有用ですが、魔法の回復ポイントではありません。例外情報・操作文脈・ダンプ採取導線を揃えて落ちたあとに調査できる形を優先するほうが、結果として安定します。特に長時間動くサービスや監視アプリは、半分壊れたまま生き延びるより落ちて再起動されるほうが診断もしやすく安全なことが多いです。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る