發生非預期例外時的 checklist - 要讓應用結束還是繼續,先看的判斷表
· 小村 豪 · Windows 開發, 例外處理, 設計, C# / .NET, 可靠性
只要談到非預期例外,很容易就直接進入「是要讓它掛,還是 catch 繼續」的二選一。
但實務上,這種二選一的擺法其實有點粗糙。
真正要看的,是 能不能把可能已經毀損的範圍封住。
- 只讓那一次操作失敗就好了嗎
- 重新初始化那個畫面/連線/worker 就好了嗎
- 是不是整個行程的一致性已經不可信
按這個順序看,會整理得多。
本文以 C# / .NET 的 Windows 應用、常駐應用、Windows 服務、設備整合工具等為前提,把 發生非預期例外時可以繼續的條件,與應該結束的條件 整理成一張判斷表。
1. 先下結論
catch (Exception)吃掉繼續跑,大多是危險的。- 可以繼續的前提是 3 項同時成立:能丟掉失敗的單位、能還原共用狀態、能說明外部副作用。
- UI 的 1 次操作、1 筆輸入、1 個 job,只要處理邊界明確,就可能可以繼續。
- 反之,若牽涉到共用可寫狀態、常駐迴圈、主執行緒、啟動流程、原生邊界、有記憶體毀損氣味,就偏向結束。
StackOverflowException、AccessViolationException、OutOfMemoryException這類懷疑「整個行程健康度」的例外,不要以繼續為前提。- WPF 或 Windows Forms 雖然有捕捉未處理例外後還能繼續運作的路,但 能繼續 與 繼續也安全 是兩件事。
- 長時間執行的服務或監控應用,與其帶著半毀的狀態活著,不如倒下後被重啟;這樣反而更安全、也更好診斷。
簡單說,判斷的主軸不是「能不能 catch」,而是 能不能恢復不變條件。
2. 本文說的「非預期例外」
2.1 預期與非預期要分
首先,罕見的例外和非預期的例外並不一樣。
例如下面這些,就算頻率低,也可以當成 預期之內:
- 使用者選到不存在的檔
- 連線暫時 timeout
- 匯入的 CSV 某行壞掉
- 取消時丟出
OperationCanceledException - 業務規則違反,只讓這次處理失敗
這些的共通點是 可以在設計時決定要怎麼處理這個失敗。
而本文主要要談的 非預期例外,例如:
- 自家程式碼的前提崩掉,丟出
NullReferenceException、InvalidOperationException - 在更新共用狀態的途中丟例外,不知道改到哪
- 監控迴圈或訊息處理的父迴圈掛了
- COM / P/Invoke / 廠商 SDK 邊界出現異常
- 出現
AccessViolationException、StackOverflowException這類「整個行程的健康檢查已經紅燈」的狀況
也就是說,「這個例外之後,還能不能信任應用程式的狀態」已經不明。
2.2 看似二選一,其實是三選一
讓這個議題變亂的元兇,是把「繼續」當成一種來想。
實務上大致是 3 段:
| 選擇 | 意義 |
|---|---|
| 只讓這次操作失敗然後繼續 | 保留畫面,只把這次存檔或匯入當失敗 |
| 只停掉子系統再繼續 | 重新初始化連線、畫面、worker、子行程 |
| 結束整個行程 | 狀態毀損範圍讀不出來,改用重啟策略 |
即使都叫「繼續」,裝沒事直接跑下去 與 把壞的部分切出去再繼續 完全不是一件事。
3. 先看的判斷表
3.1 全貌
先看這張表,大致方針就定下了。
| 狀況 | 首選 | 理由 |
|---|---|---|
| 只有 1 筆輸入、1 個畫面操作、1 個 job 失敗,狀態可丟 | 偏繼續 | 能把失敗單位封住 |
| 例外後可以把物件或連線丟掉重建 | 偏重新初始化子系統 | 能將毀損範圍局部化 |
| 共用狀態改到一半,但不知改到哪 | 偏結束 | 不變條件可能被打破 |
| DB/檔案/裝置指令等外部副作用半途,無法說明重複或未反映 | 偏結束 | 與外部世界的一致性不明 |
| 監控迴圈、重連迴圈、訊息處理的父迴圈因非預期例外掛了 | 偏結束 | 靜悄悄只死一部分功能,會變 zombie |
| 啟動流程、設定載入、DI 組裝、必要相依初始化失敗 | 以啟動失敗結束 | 半吊子啟動更危險 |
AccessViolationException、StackOverflowException、嚴重的 OutOfMemoryException、原生側有毀損氣味 |
立即結束 | 整個行程健康度存疑 |
| 危險處理被隔離在另一個行程,主行程沒事 | 主行程繼續,重啟子行程 | 故障領域已分離 |
flowchart TD
A["非預期例外"] --> B{"記憶體毀損 / 堆疊枯竭 / 致命資源枯竭的氣味?"}
B -- "是" --> Z["結束 / FailFast / 重啟"]
B -- "否" --> C{"失敗的單位能丟棄嗎?"}
C -- "否" --> Y["偏結束"]
C -- "是" --> D{"共用狀態能 rollback / 重新初始化嗎?"}
D -- "否" --> X["停子系統 or 結束"]
D -- "是" --> E{"外部副作用可被說明嗎?"}
E -- "否" --> X
E -- "是" --> W["只把這次操作當作失敗,繼續"]
3.2 比例外型別更要先看的事
不要只靠例外型別立刻下定論。
先看的是下列這些:
| 觀察點 | 要確認什麼 |
|---|---|
| 在哪裡發生 | UI 事件、單筆 job、父迴圈、啟動流程、原生邊界 |
| 處理走到哪 | 中途有沒有改到記憶體狀態、DB、檔案、裝置狀態 |
| 可能毀損的範圍 | 只有該物件、整個畫面、還是整個行程 |
| 是否可 rollback | 能否丟掉重建、能否用 transaction 復原 |
| 外部副作用 | 已送出/未送出、重複執行是否安全、能否補償 |
| 監控/重啟 | 掛掉後有無自動重啟或恢復路徑 |
3.3 高風險例外
沒必要把所有例外型別都講一遍,但下列幾種不適合以「繼續」為前提:
| 例外/徵兆 | 首選 | 觀察理由 |
|---|---|---|
StackOverflowException |
立即結束 | 呼叫堆疊已經壞掉,很難假定能正常恢復 |
AccessViolationException |
立即結束 | 被保護記憶體被非法存取,疑似原生邊界或記憶體毀損 |
OutOfMemoryException |
偏結束 | 恢復流程本身仍要額外分配,反而容易再炸 |
非預期的 NullReferenceException / InvalidOperationException |
視上下文,但偏結束 | 自家前提崩掉,中途變更可能仍殘留 |
| 從父迴圈漏出的非預期例外 | 偏結束 | 核心功能死掉,行程卻還留著,很危險 |
| 從 COM / P/Invoke / 廠商 SDK callback 起的異常 | 立即結束至偏結束 | 只看 managed 端無法判斷安全性 |
4. 依「發生地點」判斷
4.1 UI 事件
按鈕點擊、畫面切換、搜尋、開檔等 UI 事件,可以繼續的空間比較大。
但有條件。
比較容易繼續的情境:
- 在讀入之前就失敗,還沒動到業務狀態
- 只有對話框內的暫存狀態壞了,關了就沒了
- 例外後可以重建 ViewModel 或連線
- 可以誠實告訴使用者「這次操作失敗了」
反之,偏結束的情境:
- 畫面與 domain 狀態都改到一半
- 動到 static/singleton/快取等其他畫面也在看的共用狀態
- 例外後按鈕啟用或選取狀態殘留,整體一致性不明
- UI thread 上發生非預期例外,畫面或通知處理到哪不清楚
4.2 逐筆處理的 job / request
這種邊界很容易繼續。
- 1 則訊息
- 1 個檔案
- 1 個 HTTP 請求
- 1 個匯入 job
- 1 個批次目標
只要單位明確,就能 只讓那 1 筆失敗,繼續下一筆。
但需要具備:
- 從外部看得出失敗單位
- 中途變更可用 transaction 或補償復原
- 同一處理再執行一次結果不會壞
- 能把失敗送到隔離佇列或錯誤紀錄
4.3 常駐迴圈/監控/佇列處理
這是隨便繼續最容易出事的地方。
例如:
- 重連迴圈
- 監控迴圈
- 佇列消費迴圈
- 定期輪詢
- 設備狀態監控
- 工作列常駐處理
這類處理最可怕的是 父迴圈因一次非預期例外死掉,但行程還活著。
這裡策略要分:
- 每筆處理的邊界 捕捉預期內例外
- 父迴圈 漏出非預期例外時,偏向結束行程
4.4 啟動流程
把啟動失敗擺在「先啟動了再說」,幾乎一定會後悔。
- 讀不到必要設定
- 版本升級/migration 失敗
- 缺必要資料夾或憑證
- 核心服務初始化失敗
- 相依組裝壞掉
這種情境以 啟動失敗結束 最乾淨。
4.5 原生邊界/COM/P/Invoke/unsafe
這塊要另行嚴格一點:
- COM
- P/Invoke
- C++/CLI 外側
- 廠商 SDK
- 由 callback 回到 native 那側的程式碼
- 含
unsafe的處理
下列情境偏結束:
AccessViolationException- 疑似 heap 毀損、double free
- handle 異常、use-after-free 氣味
- callback 邊界突然死掉
5. 可以繼續的條件
下列條件大致齊備時,可以繼續:
| 條件 | 意義 |
|---|---|
| 失敗單位明確 | 1 操作、1 畫面、1 job、1 連線等,清楚哪一塊要丟 |
| 能丟棄狀態 | 能重建或視為未反映 |
| 共用狀態受保護 | 汙染不會蔓延到其他功能 |
| 能說明外部副作用 | 送了/沒送/可重送,能交代清楚 |
| 能誠實告知使用者 | 可以顯示「這次處理失敗」 |
| 可觀測 | 日誌、指標、dump 後續可追 |
6. 應該結束的條件
反之,符合下列情況就偏結束:
- 不知道中途改了什麼
- 動到了共用可寫狀態,一致性讀不出來
- 鎖、佇列、執行緒、監控迴圈的生命週期壞了
- 外部副作用的重複/遺漏/半途無法交代
- 啟動流程或核心基礎設施的初始化失敗
- 懷疑原生邊界或記憶體毀損
到這個等級,與其鑽研 如何漂亮地繼續,不如改為 倒下後方便恢復 的設計。
7. 依典型情境的建議
| 情境 | 建議 | 理由 |
|---|---|---|
| 開檔按鈕指定不存在的路徑 | 只讓那次操作失敗,繼續 | 狀態毀損局部 |
| CSV 匯入只有 1 行壞了 | 1 行或 1 檔失敗,繼續 | 失敗單位可封住 |
存檔中途丟出非預期 NullReferenceException |
重建畫面至偏結束 | 不確定 ViewModel/業務狀態改到哪 |
| 佇列的某筆訊息違反業務規則 | 只讓那筆失敗,繼續 | 可以丟到隔離佇列 |
| 佇列消費的父迴圈被非預期例外打掛 | 偏結束行程 | worker 整體生命週期已壞 |
| 啟動時必要設定讀不到 | 以啟動失敗結束 | 半吊子啟動風險更大 |
廠商 SDK callback 附近丟 AccessViolationException |
立即結束 | 不能無視記憶體毀損可能性 |
| 非核心的 telemetry 發送失敗 | 只停用該功能,繼續 | 主功能與故障領域可分 |
8. 常見的 NG
8.1 catch (Exception) 只印 log 就繼續
這相當危險。
既掩蓋原因,又讓毀損狀態被延命。
8.2 想在最後的未處理例外 handler 裡做恢復
AppDomain.UnhandledException、Application.ThreadException、DispatcherUnhandledException 等等,用來 最後記錄 很有用,但 不是神奇的復活點。
8.3 有外部副作用還輕率 retry
裝置指令、寄信、計費、搬檔、更新 DB 等,沒有先確保同一處理可以安全重複執行就 retry,會把重複執行事故變成主角。
8.4 監控迴圈死了,UI 還留著
只剩外觀活著、實際沒在做事的應用程式,會給周圍帶來很大麻煩。
8.5 沒設計「倒下」卻堅持「不想倒下」
不想倒下,就得先做這些:
- 自動重啟
- session 還原
- 中途成果的儲存
- 重新執行的安全性
- 故障領域的分離
9. 實作上的整理重點
9.1 把 catch 擺在邊界
與其在深層到處 catch,不如在下列 可定義失敗單位的地方 接例外:
- UI 操作邊界
- 1 個 request 邊界
- 1 個 job 邊界
- 1 個連線邊界
- 行程邊界
9.2 分清預期與非預期
- 預期:validation、not found、timeout、cancel、業務規則違反
- 非預期:前提崩掉、父迴圈漏出、原生邊界異常、記憶體毀損氣味
9.3 把共用狀態縮小
共用可寫狀態越大,繼續判斷越難。
反之,能縮在 1 畫面 1 session 1 worker 內,失敗也越容易局部化。
9.4 危險處理外送到別的行程
COM/ActiveX/廠商 SDK/unsafe/重量級影像處理/外部裝置控制等,不希望崩潰牽連到主體的部分,分到獨立行程會很有效。
9.5 未處理例外 handler 的重點是「記錄」而不是「恢復」
- 例外資訊
- 操作上下文
- 崩潰前的重要 log
- 設定/版本/連線目標
- dump 採集路徑
先把這些備齊,把「倒下後能追查」做好,反而能換來整體穩定。
9.6 不要過度信任 WPF / WinForms 的未處理例外事件
WPF 在 DispatcherUnhandledException 裡設 Handled = true,未處理例外後要繼續運作確實做得到。
Windows Forms 的主 UI thread 上,也能透過 Application.ThreadException 或 SetUnhandledExceptionMode 的設定去選擇要怎麼停。
但重點不在於 能不能繼續,而是 恢復條件是否齊備。
10. 總結
發生非預期例外時該看的,不是「這個例外能不能 catch」,而是 之後還能不能信任應用的狀態。
判斷順序大致這樣就夠:
- 失敗的單位能不能丟
- 共用狀態能不能復原或重建
- 外部副作用能不能交代
- 記憶體/執行緒/原生邊界的健康度還值得信嗎
這 4 題都有把握,就可以繼續。
沒把握,就偏結束。
尤其是長時間執行的應用、監控應用、服務、設備整合,帶毀損活下去 往往比 乾脆倒下 更危險。
例外處理不是「不讓它掛的技術」,而是 讓它壞得小、壞了就誠實停住、並且好恢復 的設計。
11. 參考資料
- .NET: Best practices for exceptions
- .NET: System.Exception
- .NET: StackOverflowException
- .NET: System.AccessViolationException
- .NET: Environment.FailFast
- .NET: AppDomain.UnhandledException
- WPF: Application.DispatcherUnhandledException
- Windows Forms: Application.SetUnhandledExceptionMode
- .NET: Exceptions in Managed Threads
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
應該在哪裡 `catch` 例外並輸出日誌、進行錯誤處理 - 以實務向整理呼叫階層的邊界與職責
本文整理在呼叫階層中應該於哪一層 catch 例外、輸出主日誌與進行錯誤處理的實務判斷標準。深層 helper 不寬泛接捕,外部 I/O 邊界負責翻譯例外,UseCase 把預期內失敗結果化,UI 與請求邊界輸出 1 次主日誌並決定回應,未處理例外處理器只承擔最終記錄與終止...
Windows 應用程式開發中遵守最低限度安全性的檢核表
用檢核表形式整理 WPF / WinForms / WinUI / C++ / C# 等 Windows 應用程式發佈前最低限不想漏的安全性要點。涵蓋避免不必要的管理員權限、EXE 與更新物簽章加時間戳、改用 DPAPI 與 Credential Locker、保留 HTT...
無法避免自行實作 logger 時,真正必要的最小要件是什麼:實務要件與整合測試觀點
本文整理當無法使用既成 logging framework、必須自行實作應用程式日誌時,第一版該守住的最小要件,包含 UTF-8 JSON Lines、必要欄位、一個行程一個檔案、flush 條件、輪轉與保留,並列出以真實檔案、執行緒、行程驗證的整合測試清單,協助讀者打造在...
哪些應該用單元測試驗證,哪些該留給整合測試 - 切界線的方法與實務判斷表
本文以「想消弭哪種不確定性」為主軸,整理單元測試與整合測試該各自承擔什麼。從純邏輯、格式、接線、環境、時間五個切面歸納成判斷表,並列出 Repository 全 mock、Controller 連框架一起驗等常見誤區,幫讀者在實務上不再為界線猶豫。
Windows 應用程式因程式錯誤的例外掉下也要確實留下日誌 - 不賭 in-process 的設計與 WER / 最終日誌 / 監視程序的最佳實踐
整理 Windows 應用程式因預期外例外或程式錯誤掉下時,如何同時保留通常時序日誌、最終當機標記與 WER LocalDumps 等多層證跡的最佳實踐。並說明在 .NET、WinForms、WPF、native C++ 等各框架上不該過度信任 in-process 處理器...
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
支援包含常駐處理、設備連動、運作日誌與可維護結構的 Windows 桌面應用程式。
故障調查 & 根本原因分析
調查難以重現的故障、長時間執行後的問題、記憶體洩漏、通訊停滯等棘手的正式環境問題。
作者檔案
本文作者的個人檔案頁面。
Go Komura
小村軟體有限公司 代表
以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。