非預期例外發生時該結束還是繼續執行的判斷表

· 更新日期: · · Windows 開發, 例外處理, 設計, C# / .NET, 可靠性

更新紀錄(2 筆,最後更新 2026年09月04日)

本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。

已將繁體中文版改寫為日文原文的完整翻譯。先前的繁體中文版只譯出日文原文的一部分,遺漏了章節、表格、Mermaid 圖、圖說與 FAQ。本次依日文原文將這些內容全部補回,並新增本文的知識地圖章節。技術主張與日文版一致。 查看更新前的版本 (DOI: 10.5281/zenodo.22279416)
補上了日文原文中已有的諮詢引導(consultation_services)。內文沒有改動。 查看更新前的版本 (DOI: 10.5281/zenodo.21616300)
初次發布
引用本文(DOI: 10.5281/zenodo.21616299)

本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。

Go Komura(2026)。〈非預期例外發生時該結束還是繼續執行的判斷表〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616299 https://comcomponent.com/zh-TW/blog/2026/03/16/005-unexpected-exception-exit-or-continue-decision-table/

DOI(最新版本)
10.5281/zenodo.21616299
DOI(此版本)
10.5281/zenodo.22297136

下載附日英雙語工作表的 Excel 檢核表

這個檔案由 Checklist-ja 與 Checklist-en 兩張工作表組成,把本文的判斷軸拆成 繼續條件 / 結束條件 / 實作方針 / 判斷順序 4 個類別共 27 個項目。Status 與 Notes 欄留空,可以直接當成故障處理的紀錄,或設計審查的檢核表使用。

一談到非預期的例外,很容易就落進「要讓它當掉,還是 catch 之後繼續」的二選一。 不過在實務上,這種二選一的擺法有點粗糙。

真正想看的,是 能不能把可能已經毀損的範圍封起來。

  • 是不是只讓那一次操作以失敗收場就好
  • 是不是只要重新初始化那個畫面 / 連線 / worker 就好
  • 還是整個處理程序的一致性都已經可疑了

照這個順序看下來,會好整理很多。

確認毀損範圍的順序說明發生非預期的例外時,依照能不能只讓那個操作以失敗收場、是不是只要重新初始化子系統就好、整個處理程序的一致性是不是可疑的順序,確認可能毀損範圍的流程的圖。能不能只讓這個操作失敗是不是只要重新初始化畫面或連線整個處理程序是不是都可疑了

圖 1: 依照操作、子系統、整個處理程序的順序,確認可能毀損的範圍。

本文以 C# / .NET 的 Windows 應用程式、常駐應用程式、Windows 服務、設備介接工具等為前提,把 發生非預期的例外時可以繼續的條件,以及應該結束的條件 整理成一張判斷表。

1. 先講結論

  • 用 catch (Exception) 吞掉之後繼續跑,大多是危險的。
  • 可以繼續的,是 能丟掉失敗的單位、能把共用狀態還原、能說明外部副作用 這 3 項同時成立的時候。
  • UI 的 1 次操作、1 筆輸入、1 個工作等,只要處理邊界明確,就有可能繼續。
  • 反過來說,一旦牽涉到共用的可寫入狀態、常駐迴圈、主執行緒、啟動流程、原生邊界,或有記憶體毀損的氣味,就偏向結束。
  • StackOverflowException、AccessViolationException、OutOfMemoryException 這種會讓人懷疑「整個處理程序健全性」的例外,不要以繼續為前提來思考比較安全。
  • WPF 和 Windows Forms 雖然也有接住未處理例外、表面上繼續下去的路,但 能繼續 和 繼續也安全 是兩回事。
  • 長時間運轉的服務或監控應用程式,與其帶著半毀的狀態活下去,不如當掉之後被重新啟動,往往更好診斷也更安全。

簡單說,判斷的主軸是 能不能回復不變條件。

可以繼續的 3 個條件說明只有在能丟掉失敗的單位、能把共用狀態還原、能說明外部副作用這 3 項同時成立時才可以繼續,而判斷的主軸是能不能回復不變條件的圖。能丟掉失敗的單位3 項齊備就可以繼續能把共用狀態還原能說明外部副作用主軸是能不能回復不變條件

圖 2: 只有在失敗單位、共用狀態、外部副作用這 3 個條件齊備時才可以繼續。

1.1 本文使用的用語

在讀判斷表之前,先把反覆出現的 5 個詞定下來。

用語 本文中的意思
不變條件 處理前後必須始終成立的狀態約定。「明細的合計與彙總值一致」「快取與 DB 的內容一致」「開啟的連線一定登記在管理清單上」等都屬於這一類。這裡崩掉之後還繼續運轉的話,後續的處理就全部都可疑
失敗單位 失敗時可以整個丟掉的範圍。1 次操作、1 個畫面、1 個工作、1 條連線等
外部副作用 已經跑到處理程序外面的變更。DB 更新、檔案寫入、寄送郵件、對設備送出指令等,catch 已經還原不了的東西
子系統 可以整組停止並重新初始化的單位。連線、畫面、worker、子處理程序等
FailFast 指的是 Environment.FailFast。這個 API 不執行 try / finally,也不執行 finalizer,直接讓處理程序立刻結束,細節在 9.7 說明

圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 21 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle

2. 本文所說的「非預期的例外」

2.1 區分預期之內與非預期

首先,罕見的例外和非預期的例外並不是同一回事。

舉例來說,下面這些就算頻率很低,也可以歸進 預期之內。

  • 使用者選了不存在的檔案
  • 通訊對象暫時逾時
  • 匯入的 CSV 有 1 列壞掉
  • 取消操作時拋出 OperationCanceledException
  • 因為違反業務規則,想只讓這次處理失敗

這些的共通點是,可以在設計時就先決定這個失敗要怎麼處理。

另一方面,本文主要要談的 非預期的例外,是下面這些。

  • 自己寫的程式碼前提崩掉,拋出 NullReferenceException 或 InvalidOperationException
  • 在更新共用狀態的途中拋出例外,不知道改到哪裡
  • 監控迴圈或訊息處理的父迴圈掛掉
  • 在 COM / P/Invoke / vendor SDK 邊界出現異常
  • 出現 AccessViolationException 或 StackOverflowException,處理程序的健康檢查本身就已經亮紅燈

也就是說,這些都是 「這個例外發生之後,還能不能信任應用程式的狀態,已經不知道了」 的情況。

預期之內與非預期的分界說明失敗的處理方式可以在設計時先決定的例外,就算頻率很低也算預期之內,而發生之後不知道還能不能信任應用程式狀態的例外就是本文所說的非預期的圖。能決定不能決定發生了例外處理方式能不能在設計時先決定預期之內〔頻率低也沒關係〕非預期〔不確定狀態能否信任〕

圖 3: 罕見的例外和非預期的例外是兩回事,分界在於處理方式能不能在設計時先決定。

2.2 看似二選一,其實有三選一

把這件事弄複雜的元凶,是把繼續只當成一種來想。

在實務上,大致分成 3 個層級。

選擇 意思
只讓這次操作失敗然後繼續 保留畫面,只把這次的儲存或匯入當成失敗
只停掉子系統然後繼續 只重新初始化連線、畫面、worker、子處理程序
結束處理程序 因為讀不出狀態毀損的範圍,改以重新啟動為前提

同樣說「讓應用程式繼續」,裝作什麼都沒發生就這樣跑下去 和 把壞掉的部分切開再繼續,分量完全不同。

同樣是繼續,分量卻不同說明雖然都叫讓應用程式繼續,但裝作什麼都沒發生就跑下去,和把壞掉的部分切開再繼續,兩者的分量並不相同的圖。讓應用程式繼續裝作什麼都沒發生就繼續把壞掉的部分切開再繼續同樣是繼續,分量卻不同

圖 4: 不要把繼續只當成一種,要和把壞掉的部分切開的繼續區分開來。

3. 先看的判斷表

3.1 全貌

先從這張表看起,大致的方針就定下來了。

狀況 首選 理由
只有 1 筆輸入、1 個畫面操作、1 個工作失敗,而且狀態可以丟掉 偏繼續 因為可以把失敗單位封起來
例外之後可以把目標物件或連線處置掉再重建 偏重新初始化子系統 因為可以把毀損範圍局部化
共用狀態更新到一半,但不知道改到哪裡 偏結束 因為不變條件可能已經崩掉
DB / 檔案 / 設備指令等外部副作用做了一半,說不清楚是重複還是沒寫進去 偏結束 因為讀不出和外部世界的一致性
監控迴圈、重新連線迴圈、訊息處理的父迴圈因非預期的例外掛掉 偏結束 因為靜悄悄只死掉一部分功能,很容易殭屍化
啟動流程、設定載入、DI 組態、必要相依性的初始化失敗 以啟動失敗結束 因為半吊子地啟動更危險
AccessViolationException、StackOverflowException、嚴重的 OutOfMemoryException、原生側有毀損的氣味 偏立即結束 因為整個處理程序的健全性可疑
危險的處理已經隔離在另一個處理程序,父處理程序毫髮無傷 父處理程序繼續,重新啟動子處理程序 因為故障領域已經分離
是否否是否是否是非預期的例外記憶體毀損 / 堆疊耗盡 / 致命的資源耗盡的氣味?結束 / FailFast / 重新啟動能不能丟掉失敗的單位?偏結束共用狀態能不能 rollback / 重新初始化?停掉子系統 or 結束能不能說明外部副作用?只把這次操作當成失敗然後繼續

圖 5: 依照毀損的氣味、失敗單位、共用狀態、外部副作用的順序確認並決定方針的流程。

圖中第一個分支的「記憶體毀損的氣味」會在 3.4 說明,右上的 FailFast 會在 9.7 具體說明。

3.2 比例外型別更該先看的事

不要只靠例外型別就立刻下判斷。 想先查看的是這幾點。

觀察點 要檢查什麼
在哪裡發生 UI 事件、單筆工作、父迴圈、啟動流程、原生邊界,是哪一個
進行到哪裡 中途有沒有改到記憶體狀態、DB、檔案、設備狀態
可能毀損的範圍 只有那個物件、整個畫面,還是整個處理程序
能不能 rollback 能不能處置掉重建、能不能用交易還原
外部副作用 送出了還是沒送出、重複執行安不安全、能不能補償
監控與重新啟動 讓它當掉之後,有沒有自動重新啟動或復原的路徑

3.3 高風險的例外

沒必要把細部的例外型別全部講過一遍,不過確實有些不適合以繼續為前提來看。

例外 / 徵兆 首選 觀察的理由
StackOverflowException 偏立即結束 呼叫堆疊已經崩壞,很難以正常回復為前提
AccessViolationException 偏立即結束 對受保護記憶體的非法存取,會讓人懷疑原生邊界或記憶體毀損
OutOfMemoryException 偏結束 以追加配置為前提的回復處理本身就容易不穩定
非預期的 NullReferenceException / InvalidOperationException 視前後脈絡而定,但偏結束 是自己的前提崩掉,中途的變更可能還留著
從父迴圈漏出來的非預期例外 偏結束 功能的核心已經死掉,卻只剩處理程序活著,很危險
以 COM / P/Invoke / vendor SDK callback 為起點的異常 立即結束到強烈偏結束 只看受管理端很難判斷是否安全

3.4 「記憶體毀損的氣味」與「殭屍化」到底是在看什麼

這兩個詞看起來很憑感覺,但實際上要看的東西是固定的。

先看懷疑記憶體毀損時的症狀。

看得到的現象 在哪裡看
處理程序以例外碼 0xc0000005(存取違規)或 0xc0000374(堆積毀損)當掉 事件檢視器 > Windows 記錄檔 > 應用程式的「應用程式錯誤」(事件識別碼 1000)
每次當掉的位置都不一樣。在剛剛根本沒碰過的程式碼裡當掉 日誌、傾印的呼叫堆疊
只有在碰到照理說已經釋放的物件或控制代碼時才當掉 重現步驟、傾印
在原生側的釋放處理(free / delete / COM 的釋放)當掉 傾印的呼叫堆疊(例如在 ntdll.dll 裡面當掉)
沒碰過的值被改寫了。同樣的輸入結果卻不一樣 輸入輸出日誌、重新執行結果的比對

0xc0000005 是 STATUS_ACCESS_VIOLATION,0xc0000374 是 STATUS_HEAP_CORRUPTION,兩者都是 Microsoft 的 NTSTATUS 清單裡定義的值。尤其是堆積毀損,當掉的不是弄壞的那一瞬間,而是 下一個碰到那塊壞掉堆積的一方,所以當掉的位置未必就是犯人。如果出現這種形式的症狀,比較安全的想法是:在受管理端 catch 之後繼續跑也沒有意義。

堆積毀損時當掉位置的錯開說明堆積毀損不會在弄壞的當下就當掉,而是在下一個碰到那塊壞掉堆積的一方當掉,因此當掉的位置未必就是犯人的圖。某處把堆積弄壞了當下不會當掉下一個碰到的一方當掉當掉的位置未必是犯人

圖 6: 堆積毀損會在下一個碰到的一方當掉,所以當掉的位置和弄壞的位置會錯開。

接著是懷疑殭屍化時的症狀。

看得到的現象 在哪裡看
處理程序還活著,但最近一次的處理時刻沒有更新 最後處理時刻的日誌、heartbeat
只有佇列或接收資料夾的滯留件數一直增加 佇列長度、未處理檔案數
從某個時刻開始,日誌突然完全停住 應用程式日誌
畫面可以操作,但背後的更新已經停了 畫面顯示與實際資料的核對
worker 執行緒數比預期少 診斷日誌、Process Explorer 的執行緒清單

殭屍化(zombie)指的不是「沒有當掉」,而是 沒在做事卻活著。像最後處理時刻、滯留件數、heartbeat 這種「顯示還在動作的指標」先準備好,不論是繼續還是結束的判斷,還是事後追查,都會輕鬆很多。

撈出殭屍化的指標說明殭屍化是沒在做事卻活著的狀態,先準備好最後處理時刻、滯留件數、heartbeat 這類顯示還在動作的指標,判斷與事後追查都會變輕鬆的圖。最後處理時刻先準備好顯示還在動作的指標滯留件數heartbeat繼續或結束的判斷變輕鬆事後追查變輕鬆

圖 7: 不是看有沒有當掉,而是用顯示還在做事的指標來撈出殭屍化。

4. 依發生地點判斷

4.1 UI 事件

按鈕點擊、畫面切換、搜尋、選擇檔案這類 UI 事件,可以繼續的空間相對比較大。 不過有條件。

比較容易繼續的,是這些情況。

  • 在載入之前就失敗,還沒碰到業務狀態
  • 只有對話方塊內的暫時狀態壞掉,關掉畫面就可以丟掉
  • 例外之後可以重建 ViewModel 或連線
  • 可以誠實告訴使用者「這次的操作失敗了」

反過來說,變成這樣就偏向結束。

  • 畫面和領域狀態兩邊都更新到一半
  • 碰到了 static / singleton / 快取這類其他畫面也會看的共用狀態
  • 例外發生之後,只剩按鈕啟用狀態或選取狀態留著,一致性不明
  • 在 UI 執行緒上發生非預期的例外,繪製和通知進行到哪裡很可疑
UI 操作例外的分界說明 UI 事件的例外如果只碰到關掉就能丟的暫時狀態就容易繼續,一旦碰到其他畫面也會看的共用狀態或更新到一半的狀態就偏向結束的圖。只有可丟棄的暫時狀態共用狀態或更新到一半UI 操作發生非預期的例外碰到了哪一種狀態只讓這個操作失敗然後繼續偏結束

圖 8: UI 事件可以繼續的空間很大,但一旦碰到共用狀態,情況就不一樣了。

4.2 逐筆處理的工作 / 請求

這裡是容易繼續的邊界。

  • 1 則訊息
  • 1 個檔案
  • 1 個 HTTP 請求
  • 1 個匯入工作
  • 1 個批次對象

只要這種單位明確,就可以 只讓那 1 筆失敗,然後前進到下一筆。

不過有前提。

  • 失敗單位從外面看得出來
  • 中途的變更可以用交易或補償收拾好
  • 同樣的處理再跑一次,結果也不會壞掉
  • 可以把失敗移到隔離佇列或錯誤紀錄

4.3 常駐迴圈 / 監控 / 佇列處理

這裡是隨手繼續下去最會出事的地方。

例如:

  • 重新連線迴圈
  • 監控迴圈
  • 佇列消費迴圈
  • 定期輪詢
  • 設備狀態監控
  • 常駐在系統匣的處理

這類處理最可怕的,是 父迴圈因為一次非預期的例外死掉,卻只有處理程序活了下來。

這裡的方針最好分開。

  • 在 每筆項目處理的邊界 接住預期之內的例外
  • 一旦非預期的例外從 父迴圈 漏出來,就偏向結束處理程序
常駐迴圈的方針分工說明在每筆項目處理的邊界接住預期之內的例外,一旦非預期的例外從父迴圈漏出來就偏向結束處理程序,這種常駐處理的方針分工的圖。常駐迴圈每筆項目處理的邊界父迴圈接住預期之內的例外非預期漏出來就偏結束避免只剩處理程序活著

圖 9: 在項目邊界和父迴圈分開訂方針,把父迴圈的非預期例外接到結束。

4.4 啟動流程

把啟動時的失敗處理成「先啟動起來再說」,結果就是應用程式在少了一部分功能的狀態下繼續運轉,之後很難釐清原因。

  • 讀不到必要的設定
  • 版本遷移 / migration 失敗
  • 缺少必要的資料夾或憑證
  • 核心服務的初始化失敗
  • 相依性的組態壞掉

這種情況,當成啟動失敗直接結束 比較清楚。

4.5 原生邊界 / COM / P/Invoke / unsafe

這一塊最好另外拉出來,看得嚴一點。

  • COM
  • P/Invoke
  • C++/CLI 的另一側
  • vendor SDK
  • 透過 callback 回來的原生側程式碼
  • 含有 unsafe 的處理

尤其看到下面這些,就偏向結束。

  • AccessViolationException
  • 讓人懷疑堆積毀損或 double free 的症狀
  • 控制代碼異常、釋放後又存取的氣味
  • 在 callback 邊界突然死掉
原生邊界異常的處理方式說明在 COM 或 P/Invoke 這類原生邊界,一旦看到 AccessViolationException、讓人懷疑堆積毀損的症狀或在 callback 邊界突然死掉就偏向結束,並且把這個邊界本身另外拉出來看得更嚴的圖。AccessViolationException偏結束堆積毀損或 double free 的症狀在 callback 邊界突然死掉原生邊界另外拉出來看得更嚴

圖 10: 在原生邊界看到這些症狀,就放棄繼續的前提,偏向結束。

5. 可以繼續的條件

把可以繼續的條件整理起來,就是下面這些。前提是這些條件大致都齊備。

條件 意思
失敗單位明確 1 次操作、1 個畫面、1 個工作、1 條連線等,知道要丟掉的單位是什麼
狀態可以丟掉 可以處置掉重建,或是可以當成還沒寫進去
共用狀態受到保護 汙染不會擴散到其他功能
能說明外部副作用 送了 / 沒送 / 可以重送,這些都說得清楚
能誠實告知使用者 可以顯示「這次的處理失敗了」
可以監控 可以用日誌、指標、傾印做事後追查

6. 應該結束的條件

反過來說,符合下面這些就偏向結束。

  • 不知道中途改了什麼
  • 碰到了共用的可寫入狀態,讀不出一致性
  • 鎖、佇列、執行緒、監控迴圈的生命週期壞掉了
  • 說不清楚外部副作用是重複、遺漏還是做了一半
  • 在啟動流程或核心基礎設施的初始化失敗
  • 懷疑原生邊界或記憶體毀損

到了這個層級,比起 漂亮地繼續下去的巧思,讓它當掉並且更好復原的巧思 才管用。

偏結束時管用的巧思說明在符合偏結束條件的層級上,比起漂亮地繼續下去的巧思,讓它當掉並且更好復原的巧思才管用的圖。符合偏結束的條件漂亮地繼續下去的巧思讓它當掉並且更好復原的巧思在這個層級不太管用這一邊比較管用

圖 11: 偏結束的狀況下,與其投資在繼續的巧思,不如投資在好復原上。

7. 依典型情境的建議

3.1 的判斷表是用條件的形式寫的,所以套用到實際場面時可能會猶豫。這張表把那些條件重新套進常見的場面裡。猶豫的時候,先在 3.1 檢查條件,再到這張表找接近的那一列,照這個順序比較快。

猶豫時的查表順序說明套用到場面時如果猶豫,先用 3.1 的判斷表檢查條件,再到本章的表找接近的場面那一列,這樣的順序的圖。套用到場面時猶豫用 3.1 的判斷表檢查條件到本章的表找接近的那一列

圖 12: 從條件的表走到場面的表,照這個順序查就不容易猶豫。

情境 建議 理由
在開啟檔案的按鈕指定了不存在的路徑 只讓這次操作失敗然後繼續 因為狀態毀損是局部的
CSV 匯入時只有 1 列壞掉 以 1 列失敗或 1 個檔案失敗的方式繼續 因為容易把失敗單位封起來
畫面儲存到一半拋出非預期的 NullReferenceException 重建畫面到偏結束 因為不確定 ViewModel / 業務狀態改到哪裡
佇列裡有 1 則訊息違反業務規則 只讓那則訊息失敗然後繼續 因為可以移到隔離佇列
佇列消費的父迴圈因非預期的例外掛掉 偏向結束處理程序 因為 worker 整體的生命週期已經壞掉
啟動時讀不到必要的設定 以啟動失敗結束 因為半吊子地啟動更危險
vendor SDK callback 附近出現 AccessViolationException 偏立即結束 因為不能無視記憶體毀損的可能性
只有非本質的遙測傳送失敗 只停用那個功能然後繼續 因為主要功能和故障領域可以分開

8. 常見的 NG

8.1 用 catch (Exception) 只印日誌就繼續

這相當危險。 因為這既掩蓋了原因,又容易讓毀損的狀態被延續使用下去。

8.2 想在最後的未處理例外處理常式裡回復

AppDomain.UnhandledException、Application.ThreadException、DispatcherUnhandledException 這些,當成 最後記錄的地方 很有用,但並不是 有魔法的回復點。

8.3 明明有外部副作用卻輕率地 retry

設備指令、寄送郵件、計費、搬移檔案、更新 DB 這些,在同一個處理還沒有重複執行安全性的情況下就 retry,接下來出包的主角就換成重複執行了。

輕率的 retry 招來的重複執行說明在有外部副作用的處理上,同一個處理還沒有重複執行安全性就 retry,會讓重複執行的事故變成主角的圖。有外部副作用的處理失敗還沒有重複執行安全性就 retry重複執行的事故變成主角設備指令、計費、傳送等

圖 13: 還沒有重複執行安全性就 retry,會招來重複執行的事故。

8.4 監控迴圈死掉了,卻只留著 UI

只有外觀活著、實際沒在做事的應用程式,在使用者眼中看起來正常,反而更晚被發現。最好事先做到能在監控端撈出 3.4 所列的殭屍化症狀。

8.5 沒有做讓它當掉的設計,卻說「不想讓它當掉」

如果不想讓它當掉,那在此之前有些東西得先放進去。

  • 自動重新啟動
  • session 還原
  • 中途成果的儲存
  • 重複執行安全性
  • 故障領域的分離

9. 實作時的整理重點

9.1 把 catch 靠到邊界上

與其在很深的層裡什麼都 catch,不如像下面這樣,

  • UI 操作邊界
  • 1 個請求的邊界
  • 1 個工作的邊界
  • 1 條連線的邊界
  • 處理程序邊界

在 可以定義失敗單位的地方 接住,會比較好整理。

舉例來說,如果是逐筆處理的匯入,catch 就放在迴圈裡面。這裡就是失敗單位。

// C# / .NET 8。逐筆處理的迴圈中,把失敗單位封在迴圈內側的範例。
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)
        {
            // 父迴圈壞掉 = 整個 worker 的生命週期壞掉了。
            // 不要吞掉例外做出「只剩處理程序活著」的狀態,而是停下來交給重新啟動。
            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 不會反映到服務的結束狀態,因此 在服務內容裡設定的修復操作(「重新啟動服務」)不會執行。官方的 worker service 教學也明確寫著:預設的 BackgroundServiceExceptionBehavior.StopHost 會「乾淨地停下來」,所以 Windows 的服務管理端不會重新啟動,要讓修復操作生效,必須用 0 以外的結束碼呼叫 Environment.Exit(第 11 章的參考資料)。

Environment.Exit 會結束目前的處理程序,所以 想在當掉之前留下的日誌,要在這一行之前全部寫完。如果用的是會緩衝的 logger,就在中間插入排清(flush)。反過來說,如果對象不是 Windows 服務,而只有 systemd 或容器,那麼先設定 Environment.ExitCode 再用 StopApplication() 收攤,監控端一樣可以當成失敗處理。請依照要在哪一種環境執行來選擇。

另外,也要先掌握 catch 在迴圈內側和外側的角色不同這一點。內側是記錄失敗單位,外側是結束生命週期。這裡搞混的話,會變成 1 筆失敗就讓應用程式當掉,或反過來 worker 死掉了處理程序卻還活著。

內側與外側 catch 的角色說明迴圈內側的 catch 負責記錄失敗單位,外側的 catch 負責結束生命週期,搞混的話會造成 1 筆失敗就當掉,或 worker 死掉了處理程序還活著的圖。迴圈內側的 catch記錄失敗單位迴圈外側的 catch結束生命週期搞混會變成 1 筆失敗就當掉搞混會變成只剩處理程序活著

圖 14: catch 在迴圈內側和外側角色不同,搞混會往兩個方向都出事。

9.2 區分預期之內與非預期的例外

  • 預期之內:validation、not found、timeout、cancel、違反業務規則
  • 非預期:前提崩掉、父迴圈漏出、原生邊界異常、記憶體毀損的氣味

9.3 把共用狀態縮小

共用的可寫入狀態越大,繼續與否的判斷就越難。 反過來說,越能把它封在 1 個畫面、1 個 session、1 個 worker 裡面,失敗也越容易封住。

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 事件,所以訂閱端一旦拋出例外,
        // 寫在後面的處理常式還沒註冊上去,就會變成
        // 「啟動時當掉了,日誌卻一行都沒留下」這種最麻煩的形態。
        // 最想記錄的正是啟動流程本身的失敗,所以先掛上去。

        // 在 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
        {
            // 就算記錄失敗,也不要擋住結束流程。
        }
    }
}

重點是不要在處理常式裡面試圖修好狀態。在這裡要做的,只有 留下下次能追同一個現象的材料。

記錄專用處理常式的流程說明 WPF 要先在 base.OnStartup 之前註冊完處理常式再進入啟動流程,而處理常式裡不試圖修好狀態,只留下下次能追同一個現象的材料的流程的圖。先註冊處理常式進入 base.OnStartup記錄未處理的例外不試圖修好狀態

圖 15: 在啟動流程之前先註冊完,處理常式裡只做記錄。

9.6 不要過度信任 WPF / WinForms 的未處理例外事件

在 WPF 裡,只要在 DispatcherUnhandledException 中設成 Handled = true,未處理例外之後要繼續下去本身是做得到的。 Windows Forms 在主 UI 執行緒上,也可以依 Application.ThreadException 或 SetUnhandledExceptionMode 的設定來選擇怎麼停。

不過,能不能就這樣繼續下去,和 回復的條件是否齊備,是兩個不同的問題。

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 反而會破壞資源的情況下,要用 FailFast 而不是 Environment.Exit。

取捨方式如下。

狀況 選擇
不變條件已經壞掉,執行善後反而更危險 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 的失敗單位的話題。

FailFast 不執行善後的理由說明在狀態毀損到無法修復時執行善後的程式碼有可能把壞掉的內容寫出去,因此 Environment.FailFast 不執行 try 或 finally 也不執行 finalizer 就結束,並在 Windows 上留下事件記錄檔與傾印的圖。不變條件壞掉了用 FailFast 立刻結束finally 和 finalizer 都不執行留下傾印與事件記錄檔〔Windows〕避免把壞掉的內容寫出去

圖 16: FailFast 藉由不執行善後,避免把毀損的狀態寫出去。

10. 總結

發生非預期的例外時該看的,不是「這個例外能不能 catch」,而是 之後還能不能信任應用程式的狀態。

判斷的順序,大致這樣就夠了。

  1. 能不能丟掉失敗的單位
  2. 能不能把共用狀態還原或重建
  3. 能不能說明外部副作用
  4. 記憶體 / 執行緒 / 原生邊界的健全性還能不能信任

這 4 點都有把握的話就可以繼續。 沒把握的話,就偏向結束。

總結的判斷順序說明依序確認能不能丟掉失敗單位、能不能還原共用狀態、能不能說明外部副作用、包含原生邊界在內的健全性能不能信任,4 點都有把握就繼續,否則偏向結束的判斷流程的圖。4 點都有把握沒把握能不能丟掉失敗單位能不能還原共用狀態能不能說明外部副作用能不能信任健全性可以繼續偏結束

圖 17: 依序回答 4 個問題,只有全部都有把握時才選擇繼續。

尤其在長時間運轉的應用程式、監控應用程式、服務、設備介接上,帶著毀損活下去 比 直接了當地當掉 更危險的場面相當多。

例外處理不是「不讓它當掉的技術」。 而是 讓壞掉的程度變小、壞了就誠實停下來、並且好復原的設計。

11. 參考資料

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

非預期的例外可以用 catch (Exception) 吞掉之後繼續跑嗎?
只印日誌就繼續跑,大多是危險的。因為這既掩蓋了原因,又容易讓毀損的狀態被延續使用下去。可以繼續的,是能丟掉失敗的單位、能把共用狀態還原、能說明外部副作用這 3 項同時成立的時候。判斷的主軸不是「這個例外能不能 catch」,而是「這之後還能不能信任應用程式的狀態」。
出現哪些例外時應該立即結束?
像 StackOverflowException、AccessViolationException、嚴重的 OutOfMemoryException 這種會讓人懷疑整個處理程序健全性的例外,不要以繼續為前提來思考比較安全。StackOverflowException 代表呼叫堆疊已經崩壞,AccessViolationException 則是對受保護記憶體的非法存取,會讓人懷疑記憶體毀損。以 COM、P/Invoke、vendor SDK 的 callback 為起點的異常,只看受管理端也很難判斷是否安全,因此同樣強烈偏向結束。
在什麼條件下,發生例外也可以讓應用程式繼續?
前提是下列條件大致齊備:失敗單位明確(1 次操作、1 個畫面、1 個工作、1 條連線等,知道要丟掉的單位是什麼)、狀態可以處置掉重建、汙染不會擴散到共用狀態、能說明外部副作用、能誠實告訴使用者「這次的處理失敗了」、能用日誌或指標做事後追查。像 UI 的 1 次操作或 1 個匯入工作這樣處理邊界明確時,就有可能繼續。反過來說,共用狀態更新到一半、父迴圈、啟動流程、原生邊界的異常都偏向結束。
在 WPF 的 DispatcherUnhandledException 裡設成 Handled=true 就可以繼續嗎?
未處理例外之後要繼續下去本身是做得到的,但能繼續和繼續也安全是兩個不同的問題。AppDomain.UnhandledException 或 DispatcherUnhandledException 這類處理常式,當成最後記錄的地方很有用,但並不是有魔法的回復點。把例外資訊、操作的前後脈絡、採集傾印的動線備齊,優先做出當掉之後能調查的形態,結果反而更穩定。尤其是長時間運轉的服務或監控應用程式,與其帶著半毀的狀態撐著,不如當掉之後被重新啟動,往往更好診斷也更安全。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽