更新紀錄(2 筆,最後更新 2026年09月04日)
本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。
引用本文(DOI: 10.5281/zenodo.21616330)
本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。
Go Komura(2026)。〈Windows 應用程式當機時留下日誌與傾印的設計〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616330 https://comcomponent.com/zh-TW/blog/2026/03/19/000-windows-app-crash-logging-best-practices/
- DOI(最新版本)
- 10.5281/zenodo.21616330
- DOI(此版本)
- 10.5281/zenodo.22297168
在 Windows 應用程式的缺陷調查裡,最難受的就是 只知道它當掉了,卻沒留下任何說明為什麼當掉的資料 這種狀態。
在下面這幾種專案裡,這個問題會特別嚴重。
- 只在客戶環境才會當掉
- 只有長時間運轉之後才會當掉
- WPF / WinForms / Windows 服務 / 常駐應用程式,而且重現率很低
- 牽涉到 COM、P/Invoke、native DLL、vendor SDK
- 只拿得到「例外訊息」,前後的脈絡完全沒有
不過話說在前面,光靠當掉那一側的處理程序,沒辦法「一定」留下日誌。 把堆疊毀損、記憶體毀損、fast fail、強制終止、斷電都算進來之後,in-process 的最後一筆日誌本質上就是 best effort。
flowchart TB
accTitle: in-process 的最後一筆日誌是 best effort
accDescr: 說明把堆疊毀損、記憶體毀損、fast fail、強制終止、斷電都算進來之後,光靠當掉那一側的處理程序無法一定留下日誌,in-process 的最後一筆日誌本質上就是 best effort 的圖。
a1["堆疊毀損、記憶體毀損"] --> a4["最後一筆 in-process 日誌"]
a2["fast fail、強制終止"] --> a4
a3["斷電"] --> a4
a4 --> a5["本質上是 best effort"]
a5 -.-> a6["做不到「一定留下」"]
圖 1: 只靠當掉那一側的處理程序,無法「一定」留下最後一筆日誌。
實務上該追求的,是 不把希望全押在當掉的處理程序內部 的架構。 也就是說,
- 平時的時序日誌
- 當掉瞬間的最終當機標記
- 由 OS 或另一個處理程序留下的當機佐證資料
用這三層來思考。
本文以 Windows 桌面應用程式、常駐應用程式、Windows 服務、設備介接工具為前提,梳理 即使因為程式錯誤引發的例外而當掉,也不會失去調查能力的最佳實踐。
1. 先講結論
先把結論列出來。
- 不要把「最後一筆日誌」全押在單一個 in-process 處理常式上,這一點最重要。
- 實務上最穩當的,是 一般日誌 + 最終當機標記 + WER LocalDumps 的組合。
- 長時間運轉、設備介接、外掛程式、混用 native SDK 的情況,再加上一個監控處理程序(watchdog / launcher / service) 會強上不少。
- 在當機處理常式裡 不做耗時的處理 是鐵則。壓縮、HTTP 傳送、DI 解析、UI 對話方塊、產生複雜的 JSON 都要移出去。
- 當機時只在本機短短留下一筆,壓縮、上傳、通知則挪到 下次啟動後或另一個處理程序。
- 用 WinForms 的
ThreadException或 WPF 的DispatcherUnhandledException在表面上硬撐下去的設計,面對程式錯誤時很危險。 - 不論 .NET 還是 native,懷疑狀態已經毀損的例外,都以「記錄後結束」而不是「復原」為原則 比較安全。
- 要取得傾印的話,必須同時保管 PDB 與散發出去的二進位檔,否則之後讀不出來。
簡單說,最佳實踐就是 「不要想在當掉的瞬間做完全部。把當掉前、當掉瞬間、當掉後的職責分開」。
flowchart TB
accTitle: 當掉前、當掉瞬間、當掉後的職責分工
accDescr: 說明不要想在當掉的瞬間做完全部,而是把當掉前的一般日誌、當掉瞬間只在本機短短寫入、當掉後的壓縮與上傳與通知分開負責的最佳實踐的圖。
b1["當掉前:留下一般日誌"] --> b2["當掉瞬間:只在本機短短寫入"]
b2 --> b3["當掉後:壓縮、上傳、通知"]
b3 -.-> b4["在下次啟動後或另一個處理程序進行"]
圖 2: 不要在當掉的瞬間做完全部,而是分成前、瞬間、後三個時點分擔職責。
1.1 本文使用的術語
先把後面會直接使用、不再另外說明的詞列出來。
| 術語 | 全稱、讀法 | 意義 |
|---|---|---|
| WER | Windows Error Reporting,Windows 錯誤報告 | 由 OS 端攔下應用程式的異常終止並加以記錄的 Windows 機制。把傾印留在本機的設定就是 LocalDumps |
| 傾印 / minidump | crash dump | 把當掉那一瞬間處理程序的記憶體內容存下來的檔案。事後可以查看執行緒、堆疊與模組 |
| PDB | Program Database | 建置時產生的符號檔。少了它,就算打開傾印也不會出現函式名稱與行號 |
| in-process | 處理程序內 | 在當掉的那個處理程序自己內部處理。相對的說法是另一個處理程序 |
| best effort | 盡力而為 | 「順利的話會留下來,但不保證」的性質。當掉瞬間的 in-process 日誌就是這樣 |
fast fail / __fastfail |
高速失敗終止 | 判斷狀態已經壞掉時,不做善後、用最少步驟立刻結束的機制。native 是 __fastfail,.NET 則對應 Environment.FailFast |
| watchdog | 監控處理程序 | 從外部看著本體處理程序的啟動、結束與存活的另一個處理程序。也可以做成 launcher 或父服務 |
| heartbeat | 存活訊號 | 定期告訴 watchdog「我還活著」的訊號 |
| UNC 路徑 | Universal Naming Convention | \\server\share\... 這種形式的網路共用路徑。當機時使用會因為瞬斷或認證資訊而被卡住 |
| ACL | Access Control List,存取控制清單 | 設定誰可以讀寫那個資料夾。是傾印與日誌「撲空」的常見原因 |
| SEH | Structured Exception Handling,結構化例外處理 | Windows 的原生例外機制。SetUnhandledExceptionFilter 就掛在這上面 |
| CRT | C Runtime,C 執行階段 | C / C++ 標準函式庫的實作。它和 SEH 分開,另外有自己的終止路徑 |
| session | session ID(工作階段識別碼) | 用來辨識「講的是哪一次啟動的執行個體」的值。是把日誌、傾印、watchdog 記錄兜在一起的鑰匙 |
圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 24 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle
2. 為什麼只靠 in-process 做不到「確實」
這一點含糊帶過,整個設計就會搖擺。
2.1 當掉的那條執行緒,執行內容本身就可能已經壞了
未處理例外的掛勾(hook)與頂層例外篩選器,有可能 在已經壞掉那一側的執行緒內容上執行。 到了這個時間點,
- 堆疊已經不安全
- 堆積毀損讓後續的記憶體配置也不安全
- 例外發生時持有的鎖,會讓後續的等待直接卡死
- logger 本身相依的物件已經壞掉
這些狀況都很常見。
所以把 最後那個處理常式看成「能做的事情相當少的地方」,而不是「什麼都能做的地方」 會比較安全。
flowchart TB
accTitle: 最後那個處理常式能做的事情很少
accDescr: 說明未處理例外的掛勾有可能在已經壞掉那一側的執行緒內容上執行,堆疊與堆積都不安全,等待鎖會卡住,logger 的相依物件也可能壞掉,因此應視為能做的事情相當少的地方的圖。
c1["在壞掉的執行緒內容上執行"] --> c2["堆疊與堆積都不安全"]
c1 --> c3["等待鎖可能卡死"]
c1 --> c4["logger 的相依物件也可能壞掉"]
c2 --> c5["能做的事情相當少的地方"]
c3 --> c5
c4 --> c5
圖 3: 最後那個處理常式不是「什麼都能做的地方」,而是限制重重的地方。
2.2 fast fail 與毀損狀態例外,前提就是「in-process 只做最低限度的動作」
在記憶體毀損或致命狀態下,最好不要指望一般的例外處理。
特別是 native 端的 __fastfail 這一類,以及懷疑狀態已毀損的異常情境,設計方向本來就是 「用盡可能少的額外負擔立刻結束」。
也就是說,最後那筆 in-process 日誌寫得出來算賺到,主要佐證資料交給 OS 或另一個處理程序,才是自然的想法。
2.3 .NET 的未處理例外事件同樣不是做「耗時復原處理」的地方
.NET 的 AppDomain.UnhandledException 很方便,
但最好把這裡能做的事情限縮到 簡短的記錄 為止。
- 可能受到例外發生時仍持有的鎖影響
- 並不是連毀損狀態例外都能安全攔下
- 硬要在這裡訂出繼續執行的方針,很容易變成半壞的狀態還硬撐著
把它理解成 「未處理例外事件 = 最後的通知」,而 不是「安全的復原點」,才符合實際。
flowchart TB
accTitle: 未處理例外事件是最後的通知
accDescr: 說明 AppDomain.UnhandledException 裡能做的只到簡短的記錄,並不是連毀損狀態例外都能安全攔下,未處理例外事件是最後的通知而不是安全的復原點的圖。
d1["未處理例外事件"] --> d2["能做的只到簡短的記錄"]
d2 --> d3["當成最後的通知使用"]
d1 -.-> d4["不是安全的復原點"]
d4 -.-> d5["硬訂繼續方針就會半壞著硬撐"]
圖 4: 未處理例外事件是記錄的入口,不是復原的地方。
3. 建議的架構 - 把 crash-time 與 after-restart 分開
最容易梳理清楚的做法,是把 當機時要做的事 和 重新啟動後要做的事 分開。
先用一張圖把這三層各自 由哪個處理程序負責、資料最後落在哪裡 畫出來。
flowchart TD
subgraph APP["應用程式處理程序"]
L1["一般日誌<br/>append-only 的時序記錄"]
L2["最終當機標記<br/>只寫一行就結束"]
end
subgraph WIN["Windows 端"]
WER["WER LocalDumps<br/>在處理程序外儲存傾印"]
end
subgraph WD["watchdog 處理程序"]
EX["記錄 exit code 與結束時間<br/>判斷是否重新啟動"]
end
subgraph NEXT["下次啟動的健全處理程序"]
POST["壓縮 / 上傳 / 通知<br/>偵測上次的異常終止"]
end
DISK[("本機的固定資料夾")]
L1 --> DISK
L2 --> DISK
WER --> DISK
EX --> DISK
DISK --> POST
L1 -. 例外發生 .-> L2
L2 -. 處理程序結束 .-> WER
L2 -. 處理程序結束 .-> EX
圖 5: 三層佐證資料的全貌。當掉的處理程序自己寫的只有兩份,主要佐證資料在外面。
重點有三個。
- 當掉的處理程序自己寫的,只有「應用程式處理程序」框裡的那兩份。而且最終當機標記只做到「寫一行就結束」。
- 主要佐證資料在處理程序之外。WER 的傾印與 watchdog 的記錄,就算應用程式壞掉也會留下來。
- 比對的鑰匙是共用的 session ID 與 PID。這裡沒有對齊,三份佐證資料看起來就會像三件不相干的事。
| 階段 | 目的 | 在哪裡執行 | 做什麼 |
|---|---|---|---|
| 平時 | 留下時序記錄 | 應用程式內 | 結構化日誌、heartbeat、界線事件 |
| 當機時 | 落下最低限度的佐證資料 | 應用程式內 + OS | 最終當機標記、WER 傾印 |
| 結束的當下 | 偵測 unexpected exit | 另一個處理程序 | 記錄 exit code、判斷是否重新啟動、通知 |
| 下次啟動後 | 做耗時的後續處理 | 新的健全處理程序 | 壓縮、上傳、通知使用者、清理舊日誌 |
照這樣切開之後,設計會穩定很多。
3.1 最小構成
如果是規模較小的業務工具,或公司內部用的 WPF / WinForms,一開始做到這個程度通常就夠了。
- 一般日誌:本機的 append-only 檔案
- 最終當機標記:專用的短檔案
- 傾印:WER LocalDumps
- 下次啟動時:顯示「上次異常終止,有診斷資訊」
3.2 加強版的構成
遇到下面這些要求時,最好再往上加一級。
- 24/7 運轉
- 裝置控制、監控、常駐
- 大量使用 COM / P/Invoke / native SDK
- 有子處理程序、外掛程式、指令碼執行
- 在客戶環境不允許「停著不動」
這種情況下,分成
- worker 處理程序:本體處理
- launcher / watchdog / service:監控啟動、記錄 exit、重新啟動
- WER LocalDumps:設在 worker 端
- 下次啟動或 watchdog:回收診斷資訊
這樣切開,會相當貼近實務。
flowchart TB
accTitle: 加強版構成的分工
accDescr: 說明在 24 小時運轉或裝置控制的情境下,把本體處理交給 worker 處理程序,把監控啟動、記錄 exit、重新啟動交給 launcher 或 watchdog,WER LocalDumps 設在 worker 端,再由下次啟動或 watchdog 回收診斷資訊的構成的圖。
e0["較嚴格的要求(24/7、裝置控制等)"] --> e1["worker:本體處理"]
e0 --> e2["watchdog:監控啟動、記錄 exit、重新啟動"]
e1 -.-> e3["WER LocalDumps 設在 worker 端"]
e2 -.-> e4["下次啟動或 watchdog 回收診斷資訊"]
圖 6: 加強版構成把本體與監控拆成不同的處理程序,職責固定下來。
4. 一般日誌的最佳實踐
只想靠當機時的最後一行來打這場仗,大多會輸。 真正管用的是 當掉前那一段的一般日誌。
4.1 日誌要的是「之後能互相比對的資訊」,而不是「給人讀的文章」
列出一般日誌裡至少要放進去的欄位。
- UTC 時間戳記
- 從處理程序啟動起算的經過時間
- PID / TID
- 應用程式名稱、版本、建置編號、commit 識別碼
- session ID
- 操作 ID / 工作 ID / 關聯 ID
- 模組名稱 / 畫面名稱 / worker 名稱
- 前一個對外的動作
- 寫入檔案
- 更新資料庫
- 送出裝置命令
- 發出通訊要求
- 例外型別、HRESULT / Win32 錯誤 / 例外碼
- 主要輸入參數的摘要
- 在不含機密的範圍內記下對象 ID
建議採用 一行一個事件的 JSON Lines 或 key=value 格式。
用 JSON Lines 的話,一個事件大概是這種細緻度。
{"ts":"2026-03-18T01:15:33.412Z","up_ms":184213,"pid":1234,"tid":9,"app":"MyApp","ver":"3.2.1.884","commit":"9f1c2ab","session":"4f1c","level":"Warning","module":"DeviceWorker","op":"JOB-20260318-0042","event":"ExternalCommandSent","target":"COM3","cmd":"SEQ_START","timeout_ms":3000}
雖然長,但 只看這一行就知道「什麼時候、哪一次啟動的執行個體、哪個版本、在哪個操作進行到一半、做了什麼」。和傾印那邊靠 pid 與 session 相連,和建置那邊靠 ver 與 commit 相連。
改成 key=value 格式的話,同樣的內容長這樣。
ts=2026-03-18T01:15:33.412Z up_ms=184213 pid=1234 tid=9 app=MyApp ver=3.2.1.884 commit=9f1c2ab session=4f1c level=Warning module=DeviceWorker op=JOB-20260318-0042 event=ExternalCommandSent target=COM3 cmd=SEQ_START timeout_ms=3000
比起留下給人讀的長篇文字, 「之後能把三個檔案兜在一起比對」 這件事更重要。
flowchart TB
accTitle: 從一行日誌串起三份佐證資料
accDescr: 說明一般日誌的一行靠 pid 與 session 連到傾印那邊,靠 ver 與 commit 連到建置那邊,之後能把三個檔案兜起來比對,比留下給人讀的長篇文字更重要的圖。
f1["一般日誌的一行"] --> f2["靠 pid / session 連到傾印那邊"]
f1 --> f3["靠 ver / commit 連到建置那邊"]
f2 --> f4["三個檔案能兜起來比對"]
f3 --> f4
圖 7: 日誌的一行,靠 pid、session、ver、commit 連上其他佐證資料。
4.2 關鍵事件要同步寫下來
把一般日誌全部改成同步寫入會變慢。 但全部交給非同步緩衝區的話,當掉的瞬間會整批消失。
所以實務上依照層級改變處理方式才實際。
Information這種細節事件:可以先進緩衝區Warning以上:儘早 flush- 重要的界線事件:同步寫下來
- ProcessStart
- ConfigLoaded
- WorkerStarted
- ExternalCommandSent
- TransactionCommitted
- RecoveryStarted
- FatalPathEntered
重點是,至少業務上的界線一定要確實落到地上。
flowchart TB
accTitle: 依層級區分寫入方式
accDescr: 說明 Information 這種細節事件可以先進緩衝區,Warning 以上要儘早 flush,業務上的界線事件則同步寫下來,依層級區分處理方式的圖。
g0["日誌的寫入"] --> g1["Information:可以緩衝"]
g0 --> g2["Warning 以上:儘早 flush"]
g0 --> g3["界線事件:同步寫下"]
g3 -.-> g4["至少業務上的界線要落到地上"]
圖 8: 不是全部同步也不是全部緩衝,而是依層級區分寫入方式。
4.3 把「正在寫的一般日誌」和「最後的當機標記」分開
這一點相當重要。
想把全部東西塞進單一個 rolling log 的話,
- 剛好正在輪替
- 還留在非同步佇列裡
- 例外發生之後 logger 自己就死了
- 日誌寫到一半就斷了
這些事情都會發生。
所以建議至少拆成兩份。
app-<session>.jsonl平時的時序日誌fatal-last.log或fatal-<session>.log最終當機標記專用
光是 「最後那一行要留在哪裡」很明確,實務上就幫上很大的忙。
flowchart TB
accTitle: 把一般日誌與 fatal 標記分開
accDescr: 說明全部塞進單一個 rolling log 時,輪替中、非同步佇列殘留、logger 自己死掉都會讓最後一行消失,因此要拆成平時的時序日誌與最終當機標記專用檔案兩份的圖。
h1["全部塞進單一個 rolling log"] --> h2["輪替中、佇列殘留、logger 死掉都會消失"]
h2 -.->|"改為"| h3["拆成一般日誌與 fatal 標記兩份"]
h3 --> h4["最後一行的存放位置變明確"]
圖 9: 最後一行的存放位置,固定在與一般日誌不同的檔案。
4.4 日誌儲存位置固定在本機,不要用網路上的位置
當機時去依賴 UNC 路徑、NAS、HTTP、雲端 API 很危險。
- 網路瞬斷
- DNS 延遲
- 認證資訊失效
- 在 UI 執行緒上等待
- 服務帳戶權限不足
這些全都會牽扯進來。
當機時 先落到本機的固定路徑。 要傳出去的動作,留給 下次啟動後或另一個處理程序。
4.5 檔名裡要放進 session
只有日期不夠。 因為同一天會重新啟動很多次。
例如像這樣。
Logs\
MyApp_20260318_101530_pid1234_session-4f1c.jsonl
MyApp_fatal_20260318_101533_pid1234_session-4f1c.log
MyApp_watchdog_20260318.jsonl
光是 「講的是哪一次啟動的執行個體」 很明確,分析速度就會差很多。
5. 最終當機標記的最佳實踐
這裡不是打造 完整功能 logger 的地方。 而是 只寫一次、寫得短、盡量寫得住 的地方。
5.1 目的是「固定住入口」,不是「原因的細節」
最終當機標記裡該放的資訊,收得越窄越強。
- 發生時的 UTC 時間
- PID / TID
- session ID
- 版本 / 建置編號
- 來自哪一個掛勾
AppDomain.UnhandledExceptionApplication.ThreadExceptionDispatcherUnhandledExceptionSetUnhandledExceptionFilter_set_invalid_parameter_handlerset_terminate
- 例外型別或例外碼
- 可能的話加上簡短訊息
- 前一個操作 ID
- 一般日誌的檔名
- 預定的 dump 資料夾
這樣就夠了。
flowchart TB
accTitle: 標記的目的是固定住入口
accDescr: 說明最終當機標記不是要寫原因的細節,而是把來自哪一個掛勾、哪一種例外,以及一般日誌的檔名與預定的 dump 資料夾這些調查入口固定下來,因此資訊要收窄的圖。
i1["最終當機標記"] --> i2["哪一個掛勾、例外型別、session"]
i1 --> i3["一般日誌檔名與 dump 資料夾"]
i2 --> i4["調查的入口被固定下來"]
i3 --> i4
i4 -.-> i5["原因的細節交給傾印與一般日誌"]
圖 10: 標記的職責不是原因的細節,而是固定住調查的入口。
5.2 當機處理常式裡不能做的事
下面每一項出事的機率都相當高。
- 從 DI 容器解析 logger
- 使用 async / await
- 丟出 Task
- 等待鎖
- 組出複雜的 JSON
- 碰 COM 物件
- 彈出 UI 對話方塊
- 壓縮
- HTTP / SMTP / Slack / Teams 傳送
- 解析 dump 並做出摘要
- 把例外吞掉繼續執行
當機處理常式 不是一般處理流程的延續。 要把它收攏成「只做最低限度的本機寫入然後結束」。
5.3 當機處理常式裡要做的事
反過來說,要做的事相當單純。
- 防止重複進入
- 只寫一行
- flush
- 結束處理程序
順序就是這樣。
可以的話,用的是
- 事先建好的專用資料夾
- 事先確認過存在的路徑
- 已經查看過 ACL 的儲存位置
這幾種位置。
一般日誌 flush 過頭會變慢,但 fatal 標記的筆數極少,只有這裡可以 flush 得重一點。
.NET 用 FileStream.Flush(true),native 用 FlushFileBuffers,把它當成 「只有這一行現在就要落到地上」 來處理,設計起來比較順。
flowchart TB
accTitle: 當機處理常式的四個步驟
accDescr: 說明當機處理常式要做的只有防止重複進入、只寫一行、flush、結束這四件事,而且 fatal 標記筆數極少,只有這裡可以用較重的 flush 直接寫到磁碟的圖。
j1["1. 防止重複進入"] --> j2["2. 只寫一行"]
j2 --> j3["3. flush"]
j3 --> j4["4. 結束處理程序"]
j3 -.-> j5["只有這一行現在就要落到地上"]
圖 11: 處理常式只做這四個步驟,順序不要打亂。
5.4 不要想著讓它繼續跑
如果是程式錯誤引發的 unexpected 例外,把最後那個處理常式看成 記錄裝置而不是復原裝置 比較安全。
特別想把「不繼續」訂成原則的,是下面這幾種。
- 就算只是
NullReferenceException或InvalidOperationException,也可能剛好停在共用狀態更新到一半 - UI 執行緒上的 unexpected 例外
- 從監控迴圈或父迴圈漏出來的 unexpected 例外
AccessViolationExceptionStackOverflowException- native 界線上的異常
- CRT 的 invalid parameter / purecall / terminate
「不想讓它當掉」的心情可以理解,但 半壞著活下去,對診斷和維運通常更難受。
要結束時,.NET 可以考慮 Environment.FailFast,native 可以考慮 RaiseFailFastException 或 __fastfail 這類 立即終止的 API,設計上不要指望 finally 或一般的善後處理,比較安全。
flowchart TB
accTitle: 是記錄裝置而不是復原裝置
accDescr: 說明面對程式錯誤引發的非預期例外時,應把最後的處理常式看成記錄裝置而不是復原裝置,比起半壞著活下去,記錄後用立即終止的 API 結束更安全的圖。
k1["程式錯誤引發的例外"] --> k2["半壞著活下去"]
k1 --> k3["記錄後結束"]
k2 -.-> k4["診斷與維運都難受"]
k3 --> k5["考慮 FailFast 這類立即終止的 API"]
圖 12: 最後的處理常式不是復原裝置,而是記錄後結束用的裝置。
5.5 C# 的最小實作
把前面的方針直接落到 .NET 6 以後的 C#,大概就是這個分量。不使用 DI,也不使用 logger。
using System;
using System.IO;
using System.Text;
using System.Threading;
internal static class FatalMarker
{
// 使用事先建好、而且已經做過寫入測試的本機固定路徑
private static readonly string LogDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MyApp", "Logs");
// 用來和一般日誌、傾印、watchdog 記錄互相比對的鑰匙
private static readonly string SessionId = Guid.NewGuid().ToString("N").Substring(0, 8);
// 防止重複進入的旗標。0 表示尚未寫入
private static int _written;
/// <summary>應用程式啟動後只呼叫一次。</summary>
public static void Install()
{
// 不想在當機時呼叫 CreateDirectory,所以在這裡先建好
Directory.CreateDirectory(LogDirectory);
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
}
/// <summary>在判斷「不能再繼續下去」的地方呼叫。</summary>
public static void FailNow(string reason)
{
Write("FailNow", null, reason);
Environment.FailFast(reason);
}
private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
{
Write("AppDomain.UnhandledException", e.ExceptionObject as Exception, null);
// 這裡不結束處理程序。因為是未處理的例外,接下來 CLR 會走預設的終止流程。
// 在這裡呼叫 FailFast,WER 留下的傾印的原因,
// 就會從原本的例外被換成 FailFast。
}
private static void Write(string hook, Exception ex, string note)
{
// 第二次以後什麼都不做
if (Interlocked.Exchange(ref _written, 1) != 0)
{
return;
}
try
{
string fileName = string.Format(
"MyApp_fatal_{0:yyyyMMdd_HHmmss}_pid{1}_session-{2}.log",
DateTime.UtcNow, Environment.ProcessId, SessionId);
string path = Path.Combine(LogDirectory, fileName);
// 不呼叫序列化器,只用字串串接組出一行
string line = string.Join("\t",
"ts=" + DateTime.UtcNow.ToString("O"),
"pid=" + Environment.ProcessId,
"tid=" + Environment.CurrentManagedThreadId,
"session=" + SessionId,
"hook=" + hook,
"type=" + (ex != null ? ex.GetType().FullName : "none"),
"message=" + Flatten(ex != null ? ex.Message : note),
"log=" + LogDirectory);
using (var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
{
byte[] bytes = new UTF8Encoding(false).GetBytes(line + Environment.NewLine);
stream.Write(bytes, 0, bytes.Length);
// 傳 true 會寫到磁碟,而不是只寫進 OS 的快取
stream.Flush(true);
}
}
catch
{
// 這裡再失敗就沒有別的辦法了。攔下來就結束
}
}
private static string Flatten(string s)
{
return string.IsNullOrEmpty(s) ? string.Empty : s.Replace("\r", " ").Replace("\n", " ");
}
}
呼叫端 只要在啟動後呼叫 Install() 就好。忘了這一步,上面的程式碼一個位元組都不會發揮作用。
internal static class Program
{
private static void Main(string[] args)
{
FatalMarker.Install();
// 之後就是一般的應用程式啟動處理
RunApplication(args);
}
private static void RunApplication(string[] args)
{
// 例:判斷共用狀態壞掉時,不要繼續,直接讓它結束
// FatalMarker.FailNow("device state inconsistent");
}
}
這 60 行守住的,只有 5.3 列出的那四件事。
- 用
Interlocked.Exchange防止重複進入 - 只用字串串接 寫一行
- 用
Flush(true)寫到磁碟 - 從
UnhandledException不讓它繼續執行
Environment.FailFast 會先把訊息寫進 Windows 的應用程式記錄檔,再立刻結束處理程序,並把內容一併放進錯誤報告。也就是說 FailFast 本身也多留下一份佐證資料。不過如同前面所說,在未處理例外的路徑上呼叫它,傾印看起來的樣子會變,所以呼叫的位置要挑過。
flowchart TB
accTitle: 挑選呼叫 FailFast 的位置
accDescr: 說明 Environment.FailFast 會先寫進應用程式記錄檔再立即結束而多留下一份佐證資料,但在未處理例外的路徑上呼叫,WER 留下的傾印的原因會從原本的例外被換成 FailFast,因此要挑選呼叫位置的圖。
l1["Environment.FailFast"] --> l2["寫進事件記錄檔後立即結束"]
l2 --> l3["佐證資料多一份"]
l1 -.-> l4["在未處理例外的路徑上呼叫"]
l4 -.-> l5["傾印的原因被換成 FailFast"]
圖 13: FailFast 會多留一份佐證資料,但在未處理例外裡呼叫會讓原因被換掉。
6. 各框架的注意事項
6.1 .NET 共通:AppDomain.CurrentDomain.UnhandledException
它拿來當 最後的通知 很有用。 但要避免在這裡做耗時的復原處理。
用法的基本原則很簡單。
- 寫下最終當機標記
- 需要的話在 Windows 事件記錄檔留下最小限度的訊息
- 不繼續執行
- 不要在這裡等待或重試
UnhandledException 很方便,但 不要把「能在這裡把應用程式救回健康狀態」當成前提,比較安全。
6.2 WinForms:Application.ThreadException
它麻煩的地方在於,能攔下 UI 執行緒的未處理例外,讓程式表面上繼續跑下去。
拿來把業務輸入這種預期之內的錯誤變成對話方塊還說得過去, 但 不適合用在程式錯誤引發的 unexpected 例外還要繼續執行的場合。
如果真的要優先做原因調查,
- 在
ThreadException裡只做最小限度的記錄 - 或是改用
UnhandledExceptionMode.ThrowException - 然後結束處理程序,留下傾印與日誌
這樣比較安全。
6.3 WPF:Application.DispatcherUnhandledException
WPF 的情況很像。
- 主要對象只有 UI 執行緒上的例外
- 設成
Handled = true就能表面上繼續跑 - 但面對程式錯誤這樣做,畫面狀態和內部狀態很容易對不上
所以在 WPF 也一樣,不要拿它當成硬撐下去的手段,而是當成記錄的入口 比較穩當。
flowchart TB
accTitle: 不要用 UI 事件硬撐下去
accDescr: 說明 WinForms 的 ThreadException 與 WPF 的 DispatcherUnhandledException 雖然能讓程式表面上繼續跑,但面對程式錯誤時畫面狀態與內部狀態會對不上,應當成記錄的入口,結束後留下傾印與日誌比較安全的圖。
m1["UI 執行緒的未處理例外"] --> m2["表面上還能繼續跑"]
m2 -.-> m3["畫面與內部狀態對不上"]
m2 --> m4["當成記錄的入口"]
m4 --> m5["結束後留下傾印與日誌"]
圖 14: WinForms / WPF 的 UI 事件,要當成記錄的入口而不是硬撐的手段。
6.4 不要把 TaskScheduler.UnobservedTaskException 當成主要路徑
它並不是 「當掉前的最後一道防線」。
拿來輔助偵測 Task 漏接的例外可以,
但 當成當機時可靠的記錄路徑就太弱了。
所以,
- 及早發現漏看的例外
- 在開發階段把
Task設計上的疏漏揪出來
這些用途可以用,但 不要讓它當最終當機處理常式的主角。
6.5 native Win32 / C++:不要過度信任 SetUnhandledExceptionFilter
在 native 這一側,很容易就想把希望寄託在 SetUnhandledExceptionFilter 上。
不過它 在 faulting thread 的內容上執行,所以會受到
- 無效的堆疊
- 很深的遞迴
- 已經壞掉的堆積
- 例外發生時仍持有的鎖
這些狀況影響。
因此把 SetUnhandledExceptionFilter 想成
接收最後通知的 best effort 入口,剛剛好。
6.6 native C++ 也要接住 CRT 的終止路徑
在 native C++ 只盯著未處理的 SEH 會漏掉東西。
具體來說,要一併看下面這幾個。
_set_invalid_parameter_handler_set_purecall_handlerset_terminate
這一系列是用來接住 由 C 執行階段或 C++ 執行階段發起的「終止路徑」。
實務上,
- 在這些處理常式裡也寫下最終當機標記
- 但不做耗時的復原處理
- 確實結束處理程序
- 主要佐證資料交給 WER / dump
這樣比較穩當。
flowchart TB
accTitle: 只有 SEH 會漏掉
accDescr: 說明在 native C++ 只盯著 SetUnhandledExceptionFilter,會漏掉由 CRT 或 C++ 執行階段發起的終止路徑,因此也要在 invalid parameter、purecall、terminate 的處理常式留下最小限度的記錄,主要佐證資料交給 WER 與 dump 的圖。
n1["只有 SetUnhandledExceptionFilter"] --> n2["漏掉 CRT 的終止路徑"]
n2 --> n3["連 invalid parameter / purecall / terminate 也接住"]
n3 --> n4["只寫最小限度的記錄並確實結束"]
n4 -.-> n5["主要佐證資料交給 WER / dump"]
圖 15: native C++ 要在 SEH 之外再接住 CRT 端的終止路徑,才不會有漏。
7. 以 WER LocalDumps 為基礎
這一塊在實務上相當強。
7.1 首選是 WER LocalDumps
以 「當掉之後盡量確實地留下最低限度的佐證資料」 這個目的來說, WER LocalDumps 是最好上手的。
理由很單純。
- 可以由 OS 端留下傾印
- 不用額外工具就能導入
- 可以用應用程式為單位設定
- 能把當機時的主要佐證資料移到 in-process 之外
只看日誌看不出來的
- 是哪一條執行緒當掉
- 在哪個堆疊上當掉
- 是哪個模組界線
- managed / native / COM / SDK 哪一邊可疑
這些事後都能查看,這一點很強。
flowchart TB
accTitle: WER LocalDumps 強在哪裡
accDescr: 說明 WER LocalDumps 可以由 OS 端留下傾印、以應用程式為單位設定,把主要佐證資料移到 in-process 之外,而且事後還能查看是哪一條執行緒在哪個堆疊當掉、是哪個模組界線的圖。
p1["WER LocalDumps"] --> p2["由 OS 端留下傾印"]
p1 --> p3["可以用應用程式為單位設定"]
p2 --> p4["把主要佐證資料移到 in-process 之外"]
p3 --> p4
p4 -.-> p5["看得到執行緒、堆疊、模組界線"]
圖 16: WER LocalDumps 是把主要佐證資料移出當掉的處理程序的基礎。
7.2 典型的設定
例如針對 MyApp.exe,要把傾印留在 C:\CrashDumps\MyApp,可以這樣做。
reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\MyApp.exe" /f
reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\MyApp.exe" /v DumpFolder /t REG_EXPAND_SZ /d "C:\CrashDumps\MyApp" /f
reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\MyApp.exe" /v DumpCount /t REG_DWORD /d 10 /f
reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\MyApp.exe" /v DumpType /t REG_DWORD /d 2 /f
一開始想清楚取捨到這個程度就可以了。
| 值 | 首選的設定 |
|---|---|
DumpFolder |
專用資料夾 |
DumpCount |
5〜10 |
DumpType |
開發用的電腦設 2,現場依容量與機密要求設 1 或 2 |
7.3 一定要查看 dump 儲存位置的 ACL
日誌和傾印都一樣,設到寫不進去的資料夾就沒有意義。
特別是在
- Windows 服務
- 做了權限分離的子處理程序
- 現場電腦上的受限帳戶
- 牽涉到 UAC
這些場合,儲存位置的 ACL 是撲空的主因。
儲存位置要確認到
- 事先建立
- 寫入測試
- 限制保留數量
- 維運人員是否進得去查看
這幾點。
flowchart TB
accTitle: 避免儲存位置的 ACL 讓傾印撲空
accDescr: 說明 Windows 服務或受限帳戶被指到寫不進去的資料夾而撲空是典型的失敗,因此儲存位置要事先建立,並確認寫入測試、保留數量限制,以及維運人員是否進得去查看的圖。
q1["服務、受限帳戶"] --> q2["設到寫不進去的資料夾"]
q2 --> q3["傾印撲空"]
q3 -.->|"要避免的話"| q4["事先建立並做寫入測試"]
q4 --> q5["也要確認保留數量與是否進得去查看"]
圖 17: 傾印撲空的主因是儲存位置的 ACL,用事前的寫入測試來避免。
7.4 想把目前的日誌附加到 WER 報告時
如果要用送往 Microsoft 的 WER 報告,或自行維運的 WER 流程,也可以用 WerRegisterFile 做 把目前的日誌檔納入錯誤報告的註冊。
不過把這裡看成 本機儲存之外額外多出來的一條路徑,而不是替代品,比較安全。 因為當機時真正想要的,首先是 在手邊那台電腦上盡量確實地留下來。
順序上,
- 本機的一般日誌
- 本機的 fatal 標記
- 本機的 dump
- 需要的話再用 WER 傳送路徑註冊相關檔案
這樣比較貼近實務。
7.5 不只留傾印,也要留下版本管理的資訊
就算取到傾印,事後如果
- 沒有當時的 EXE / DLL
- 沒有 PDB
- 不知道是哪個 commit 建置的
那就會弱掉很多。
至少下面這些要留著。
- 散發出去的二進位檔
- 對應的 PDB
- 版本
- 建置的日期時間
- commit 識別碼
- 安裝程式的版本
傾印的收集與 PDB 的保管是 一組的。
flowchart TB
accTitle: 傾印收集與 PDB 保管是一組的
accDescr: 說明就算取到傾印,若沒有留下當時的 EXE 與 DLL、PDB,以及是哪個 commit 建置的,事後就會讀不出來,因此散發的二進位檔、PDB、版本、commit 識別碼的保管要和傾印收集一起做的圖。
r1["只留下傾印"] --> r2["沒有 PDB 與當時的二進位檔"]
r2 --> r3["事後讀不出來"]
r3 -.->|"所以"| r4["保管散發的二進位檔與 PDB"]
r4 --> r5["版本與 commit 識別碼也要留"]
圖 18: 傾印要有同一次建置的 PDB 與二進位檔留著,才讀得出來。
7.6 從取得傾印到第一次打開它
「傾印堆了一堆,卻沒有人打開過」真的很常見。深入的部分留給專門的文章,這裡只放 最初的十分鐘份。
準備工作有兩項。
- 把和那個 dump 同一次建置的 PDB 與散發二進位檔 收到同一個資料夾
- 準備好 Windows SDK 的 Debugging Tools for Windows 裡附的 WinDbg
接著在 WinDbg 打開 .dmp,依序輸入下面的指令。
.sympath srv*C:\symbols*https://msdl.microsoft.com/download/symbols;C:\myapp\pdb
.reload /f
.ecxr
!analyze -v
~*k
lm v
各自的職責如下。
| 指令 | 做什麼 |
|---|---|
.sympath |
設定符號的搜尋位置。Microsoft 的符號伺服器與自己放 PDB 的位置都要指定 |
.reload /f |
強制重新讀取符號 |
.ecxr |
移動到例外發生時的暫存器內容。忘了這一步,看到的會是 WER 端的等待堆疊,而不是當掉的位置 |
!analyze -v |
自動分析目前的例外並詳細顯示。先讀這裡 |
~*k |
列出所有執行緒的呼叫堆疊。看得到當掉那條以外的執行緒在做什麼 |
lm v |
列出已載入的模組與版本。用來和散發出去的檔案、建置編號互相比對 |
做到這裡還是「不出現函式名稱」「不出現行號」的話,原因多半是 PDB 來自不同次的建置。請回到 7.5 的版本管理。
flowchart TB
accTitle: 打開傾印的最初十分鐘
accDescr: 說明先收集同一次建置的 PDB 與散發二進位檔,用 WinDbg 打開傾印,移動到例外發生時的內容再讀分析結果,若不出現函式名稱就表示 PDB 來自不同次的建置,要回到版本管理的最初流程的圖。
s1["收集同一次建置的 PDB 與二進位檔"] --> s2["用 WinDbg 打開傾印"]
s2 --> s3["移動到例外發生時的內容再分析"]
s3 -.-> s4["忘了 .ecxr 就會看到 WER 端的等待堆疊"]
s3 --> s5{"是否出現函式名稱與行號"}
s5 -->|"沒出現"| s6["PDB 來自不同次建置,回到版本管理"]
圖 19: 傾印分析從準備、開啟、移動到例外內容這個順序開始。
更深入的收集與分析,整理在文末的相關文章裡。
8. 使用 MiniDumpWriteDump 或自製當機回報程式時的思路
有些場合確實需要自己實作。
- 想在 UI 上放一個「儲存診斷資訊」按鈕
- 想把日誌與設定檔一起打包
- 想把一群子處理程序一起處理
- 想在自動上傳前加上自訂的遮蔽處理
不過這裡最重要的是,不要讓取得 dump 的處理也全背在當掉那一側。
8.1 比起 self-dump,交給另一個處理程序更好
MiniDumpWriteDump 很強大,
但 與其從當掉的那個處理程序內部呼叫,從另一個處理程序呼叫更安全。
典型的構成長這樣。
- worker 本體偵測到異常
- 可以的話用事件或具名管道通知 helper
- helper 取得 worker 的 dump
- helper 把
tail日誌與設定檔打包起來 - helper 在結束後放進上傳佇列
這樣一來,就算 worker 壞掉,helper 那邊還是健全的。
sequenceDiagram
accTitle: 由另一個處理程序 helper 取得傾印
accDescr: 說明 worker 本體偵測到異常後用事件或具名管道通知 helper,健全的 helper 取得 worker 的傾印,把日誌與設定檔打包,並在結束後放進上傳佇列的流程的圖。
participant W as worker 本體
participant H as helper
W->>H: 用事件或管道通知異常
H->>W: 取得 worker 的 dump
H->>H: 打包 tail 日誌與設定檔
H->>H: 結束後放進上傳佇列
Note over H: worker 壞掉時 helper 仍然健全
圖 20: dump 的取得交給健全的 helper 處理程序,而不是當掉的那一側。
8.2 真的只能 in-process 的話,就收攏到專用執行緒
就算沒辦法拆成另一個處理程序, 留一條專用執行緒只做 dump 也會好一些。
但即使如此,本質上仍然是 best effort。 不會因為「加了自製的 dump 實作」就 100% 安心。
8.3 耗時的事情挪到下次啟動後
自製的回報程式上,很容易就想做這些事。
- zip 壓縮
- 和 symbol 資訊互相比對
- 上傳到伺服器
- 螢幕擷取
- 從資料庫取得額外資訊
這些要挪到 重新啟動之後或 helper 那一側,而不是當機的當下。
9. 加入監控處理程序之後會有什麼不同
長時間運轉的系統,監控處理程序相當管用。
9.1 監控處理程序會留下什麼
在 watchdog / launcher / 父服務那一側,可以留下這些資訊。
- 子處理程序的啟動時間
- 啟動引數
- PID
- 監控對象的版本
- 最後一次收到 heartbeat 的時間
- 結束時間
- exit code
- restart 次數
- 有沒有 dump
- 有沒有重新啟動
光是有這些,
- 是不是真的當掉了
- 是不是 OS 關機
- 是不是使用者自己關掉
- 是不是停止回應之後被 kill
- 重新啟動迴圈跑了幾次
就都看得相當清楚。
flowchart TB
accTitle: watchdog 的記錄能分辨出什麼
accDescr: 說明監控處理程序留下 exit code 與結束時間、最後一次收到 heartbeat 的時間、restart 次數之後,就能從外部分辨是真的當掉、OS 關機、使用者關掉、停止回應後被 kill,還是重新啟動迴圈的圖。
t1["exit code 與結束時間"] --> t4["能從外部分辨"]
t2["最後一次收到 heartbeat"] --> t4
t3["restart 次數"] --> t4
t4 -.-> t5["是當掉、關機,還是使用者關掉"]
圖 21: 有 watchdog 的記錄,就能從外面分辨結束的種類。
9.2 特別適合的情況
可以積極考慮做拆分的,是下面這些情況。
- 內含 vendor SDK 的 worker
- 影像處理 / 影片處理 / device I/O
- 監控或輪詢的父迴圈
- 執行指令碼或外掛程式
- 承載既有的 COM / ActiveX 資產
- 64 位元 / 32 位元橋接或互通
把危險的處理關進單一個 worker,日誌設計和復原設計都會輕鬆很多。
10. 常見的錯誤做法
這一節收集的是 設計層級的陷阱。「當機處理常式裡不能做什麼」這種操作步驟層級的說明整理在 5.2,正在實作的人請看那邊。看起來重複的項目(例如 10.3 的 HTTP 傳送),5.2 寫的是「為什麼在處理常式裡做會出事」,這裡寫的是「結果在維運上會發生什麼」。
10.1 用 catch (Exception) 只記個日誌就繼續跑
最常見,也最危險。
- 中途改到一半的變更留了下來
- 共用狀態壞掉
- 後續衍生的故障變多
- 真正的原因位置變模糊
換來多一筆日誌,卻常常讓 出事的時間拉得更長。
10.2 只相信 async logger 的佇列
非同步日誌本身沒有不好。 問題在於 連 fatal path 也排進同一個佇列就結束了。
當掉的瞬間 worker 停下來,那個佇列會整個消失。
只有 fatal path 直接寫檔 這條退路,留著比較安全。
10.3 在當機處理常式裡送 HTTP
很容易想實作,但相當危險。
- DNS
- TLS
- proxy
- 驗證
- 逾時
- 等待重送
全部都會壓在已經當掉的內容上。
要傳出去就放到 重新啟動之後。
10.4 有 dump,卻和一般日誌連不起來
這種情況很多。
- 傾印檔名裡沒有 session
- 日誌那邊沒有 PID / session
- watchdog 那邊沒有 PID
- build 編號對不上
結果就是 三份佐證資料看起來像三件不相干的事。
flowchart TB
accTitle: 佐證資料連不起來的失敗
accDescr: 說明傾印檔名裡沒有 session、日誌那邊沒有 PID 或 session、build 編號對不上時,傾印、日誌、watchdog 記錄這三份佐證資料就會看起來像三件不相干的事的圖。
u1["傾印檔名裡沒有 session"] --> u4["三份佐證資料看起來像三件事"]
u2["日誌裡沒有 PID / session"] --> u4
u3["build 編號對不上"] --> u4
圖 22: 缺了當鑰匙的 ID,好不容易留下的佐證資料就串不起來。
10.5 用 WinForms / WPF 的未處理例外事件硬撐下去
表面上「不會當掉了」,一開始會很受歡迎。 但實際上
- 只剩畫面還在
- worker 已經死了
- 按鈕還是可以按
- 不知道到底存檔成功沒有
很容易做出這種殭屍狀態。
10.6 沒有看 native 端的終止路徑
只靠 SetUnhandledExceptionFilter 就放心的話,會漏掉
- invalid parameter
- purecall
- terminate
- fast fail
這幾邊。
在 native C++,最好 除了 SEH 之外,也留意 CRT / C++ 執行階段那一側的終止路徑。
11. 最低限度的導入檢查清單
滿足下面這些,就相當實戰了。
- 一般日誌以一行一個事件留下
- 所有日誌都有 UTC、PID、TID、version、session
- 留下
ProcessStart與ProcessExit - 重要的界線事件會同步 flush
- 有最終當機標記的專用檔案
- fatal path 不經過 async logger
- WER LocalDumps 已經以應用程式為單位設定好
- dump 儲存位置的 ACL 已經驗證過
- 有保管 PDB 與散發出去的二進位檔
- 下次啟動時能偵測到上次的異常終止
- 壓縮 / 上傳 / 通知在重新啟動後或另一個處理程序進行
- native C++ 也把 invalid parameter / purecall / terminate 梳理過
- 在驗證用的電腦上刻意讓它當掉,確認 是不是真的留得下來
最後一行特別重要。 光是設計出來沒有意義,一定要做「真的全部取得到」的測試。
12. 要測試到什麼程度
把想確認的項目整理成表。
| 測試 | 確認什麼 |
|---|---|
| managed 的未處理例外 | 一般日誌、fatal 標記、dump 是不是全部到齊 |
| UI 執行緒的例外 | WinForms / WPF 的事件路徑是不是如預期 |
| worker 執行緒的例外 | 會不會走到 AppDomain.UnhandledException,watchdog 能不能偵測到 |
| native 例外 | WER dump 是不是真的取得到 |
| invalid parameter / terminate | 走 CRT / C++ 執行階段路徑時,最小限度的記錄是不是也留得下來 |
| 強制 kill | in-process 做不到時,watchdog 那邊能不能記下 unexpected exit |
| 重新啟動 | 下次啟動後的通知、回收、上傳會不會動 |
重要的是 不要停在「有例外飛出來應該就會有日誌」,而要確認「在這個條件下會留下這個檔案」。
13. 總結
想讓 Windows 應用程式因程式錯誤引發的例外而當掉之後,仍留下調查所需的資訊,思考的主軸其實相當單純。
- 不要只指望當掉那一側的處理程序
- 拆成一般日誌、最終當機標記,以及 OS 或另一個處理程序留下的佐證資料
- 當機時只在本機短短留下一筆
- 耗時的處理挪到重新啟動後或另一個處理程序
- 以 WER LocalDumps 為基礎
- 比起繼續執行,以記錄後結束為原則
說到底, 比起「拚最後那一行」,「打造一套就算沒有最後那一行也追得下去的架構」 更強。
flowchart TB
accTitle: 不依賴最後一行的架構
accDescr: 說明比起拚最後那一行,打造一套沒有最後一行也追得下去的架構更強,而即使如此仍要把最終當機標記短短留在另一個檔案,主要佐證資料交給 WER 的傾印與當掉前的一般日誌的圖。
v0["要追求的形狀"] --> v2["沒有最後一行也追得下去的架構"]
v2 --> v3["標記短短留在另一個檔案"]
v2 --> v4["主要佐證資料是傾印與一般日誌"]
圖 23: 比起拚最後那一行,不如打造一套沒有它也追得下去的架構。
話雖如此,最後那一行還是想要,所以 最終當機標記就短短地留在另一個檔案。 然後真正的主要佐證資料,交給 WER 的 dump 與當掉前的一般日誌。 這在 Windows 應用程式的實務上,是相當穩定的做法。
相關文章
參考資料
- Microsoft Learn: Collecting User-Mode Dumps https://learn.microsoft.com/en-us/windows/win32/wer/collecting-user-mode-dumps
- Microsoft Learn: Using WER https://learn.microsoft.com/en-us/windows/win32/wer/using-wer
- Microsoft Learn: MiniDumpWriteDump function https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/nf-minidumpapiset-minidumpwritedump
- Microsoft Learn: SetUnhandledExceptionFilter function https://learn.microsoft.com/en-us/windows/win32/api/errhandlingapi/nf-errhandlingapi-setunhandledexceptionfilter
- Microsoft Learn: System.AppDomain.UnhandledException event https://learn.microsoft.com/en-us/dotnet/fundamentals/runtime-libraries/system-appdomain-unhandledexception
- Microsoft Learn: Application.ThreadException Event https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.application.threadexception
- Microsoft Learn: Application.DispatcherUnhandledException Event https://learn.microsoft.com/en-us/dotnet/api/system.windows.application.dispatcherunhandledexception
- Microsoft Learn: TaskScheduler.UnobservedTaskException Event https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskscheduler.unobservedtaskexception
- Microsoft Learn: Environment.FailFast https://learn.microsoft.com/en-us/dotnet/api/system.environment.failfast
- Microsoft Learn: Registering for Application Recovery https://learn.microsoft.com/en-us/windows/win32/recovery/registering-for-application-recovery
- Microsoft Learn: RegisterApplicationRecoveryCallback https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-registerapplicationrecoverycallback
- Microsoft Learn: WerRegisterFile https://learn.microsoft.com/en-us/windows/win32/api/werapi/nf-werapi-werregisterfile
- Microsoft Learn: _set_invalid_parameter_handler https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/set-invalid-parameter-handler-set-thread-local-invalid-parameter-handler
- Microsoft Learn: _set_purecall_handler https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/get-purecall-handler-set-purecall-handler
- Microsoft Learn: set_terminate https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/set-terminate-crt
- Microsoft Learn: __fastfail https://learn.microsoft.com/en-us/cpp/intrinsics/fastfail
- Microsoft Learn: FileStream.Flush(Boolean) https://learn.microsoft.com/en-us/dotnet/api/system.io.filestream.flush
- Microsoft Learn: !analyze (WinDbg) https://learn.microsoft.com/en-us/windows-hardware/drivers/debuggercmds/-analyze
- Microsoft Learn: .ecxr (Display Exception Context Record) https://learn.microsoft.com/en-us/windows-hardware/drivers/debuggercmds/-ecxr–display-exception-context-record-
- Microsoft Learn: Symbol path for Windows debuggers https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/symbol-path
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
Windows 當機傾印收集入門 - WER/ProcDump/WinDbg
為了追查難以重現的 Windows 應用程式當機,本文梳理 WER LocalDumps、ProcDump、MiniDumpWriteDump 與 WinDbg 的取捨,以及維運上要注意的地方。
睡眠・休眠・Modern Standby 與長時間執行的應用程式 ── 用設計預防「半夜停止運轉」
本文將從 S3 睡眠、休眠、Modern Standby 的差異出發,整理長時間執行的 Windows 應用程式「早上一看才發現已經停止」的原因,並解說睡眠期間計時器與 TCP 連線的行為,以及透過 SetThreadExecutionState 進行抑止的做法。
網路磁碟機與 UNC 路徑的陷阱 ── 業務應用程式處理檔案伺服器(共用資料夾)的實務
整理業務應用程式對共用資料夾進行輸出・監控時常見的麻煩。說明磁碟機代號(Z:)為何無法從服務中看到、各執行帳戶所需的權限、錯誤 1219,以及 FileSystemWatcher 的注意事項。
Process Monitor(ProcMon)實戰指南 ── 在10分鐘內找出「設定未被讀取」「ACCESS DENIED」的原因
「明明已經修改了設定檔,卻沒有生效」「昨天還能正常運作,今天卻無法啟動」──在動手修改原始碼之前,Process Monitor(ProcMon)能從檔案、登錄檔存取的實際情況中找出原因。本文從不具調查現場的角度,解說 Filter 的實務用法、NAME NOT FOUND...
用 PerfView 與 dotnet-trace 找出「變慢」的原因 ── .NET 效能調查實務入門
當商用應用程式「變慢」「CPU 卡住不動」「偶爾當掉」時,該用哪個工具看什麼?本文整理 PerfView 與 dotnet-trace 的角色分工、CPU 取樣的讀法(inclusive/exclusive)、用 ThreadTime 調查阻塞時間,以及與 EventSou...
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
故障調查 & 根本原因分析
只在客戶環境出現的當機、重現率低的異常終止,以及用傾印與日誌互相比對來分析原因,都和缺陷調查、原因分析這個主題非常契合。
Windows 應用程式開發
在 WPF、WinForms、常駐應用程式、Windows 服務上,一般日誌、WER、watchdog 該怎麼設計,直接關係到 Windows 應用程式開發本身。
常見問題
整理諮詢這個主題時常見的問題。
- 當掉的應用程式靠自己能確實留下日誌嗎?
- 不能。把堆疊毀損、記憶體毀損、fast fail、強制終止、斷電都算進來之後,in-process 的最後一筆日誌本質上就是 best effort。實務上會分成三層:平時的時序日誌、當掉瞬間的最終當機標記,以及由 OS 或另一個處理程序留下的當機佐證資料,讓整套設計不只指望當掉的處理程序內部。最穩當的組合是一般日誌 + 最終當機標記 + WER LocalDumps。
- 當機處理常式裡不能做哪些事?
- 所有耗時的處理。從 DI 容器解析 logger、async/await、等待鎖、組出複雜的 JSON、操作 COM 物件、彈出 UI 對話方塊、壓縮、HTTP/SMTP/Slack 傳送,都有很高的機率會出事。要做的只收攏成四件:防止重複進入、只寫一行、flush、結束處理程序。壓縮、上傳、通知這類耗時的後續處理,一律挪到下次啟動後或另一個處理程序。
- WER LocalDumps 該怎麼設定?
- 在登錄檔的 HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\應用程式名稱.exe 底下設定 DumpFolder(專用資料夾)、DumpCount(5〜10 左右)、DumpType(開發用的電腦設 2,現場依容量與機密要求設 1 或 2)。最重要的是查看儲存位置的 ACL,Windows 服務或受限帳戶被指到寫不進去的資料夾而整個撲空,是最典型的失敗。另外傾印的收集和 PDB、散發二進位檔的保管是一組,少了任何一邊,之後都會讀不出來。
- WPF 的 DispatcherUnhandledException 可以攔下例外後繼續執行嗎?
- 面對程式錯誤引發的非預期例外時很危險。Handled=true 確實能讓程式表面上繼續跑,但很容易做出殭屍狀態:畫面還在而 worker 已經死了,按鈕還能按卻不知道到底存檔成功沒有。未處理例外事件應該當成記錄的入口而不是復原裝置,寫完最終當機標記就不要繼續,直接結束處理程序,主要的佐證資料交給 WER 的傾印與當掉前的一般日誌,這樣比較安全。結束時可以考慮 Environment.FailFast 這類立即終止的 API。