工業相機控制應用跑一個月後突然崩潰時(前篇) - handle leak 的找法與長時間運轉用的日誌設計
· 小村 豪 · Windows 開發, 故障調查, 工業相機, handle leak, 日誌設計
Windows 應用跑久之後突然崩潰時,我們很容易先懷疑記憶體流失。 但實際上,handle leak(控制代碼流失)才是主兇,在數週後才以二次故障的形式浮現的情況並不少見。
本次介紹的是一個工業相機控制用的 Windows 應用,連續運轉約 1 個月後突然崩潰的調查案例。切分到最後,原因其實是 相機重連路徑上,某次失敗路徑造成的 handle leak。
前篇整理 handle leak 是什麼、我們怎麼切分、為了避免再發生要留下什麼日誌。 後篇 工業相機控制應用跑一個月後突然崩潰時(後篇) - 什麼是 Application Verifier 與異常系測試基盤的做法 則談異常系測試基盤。
細節的名詞與部分日誌欄位有隱藏,但思路在 Windows 的裝置控制類應用中幾乎都通用。
1. 先下結論(一句話)
- 長時間運轉後才崩潰的控制應用,不要只看
Private Bytes,一定也要看Handle Count - handle leak 多半不在正常系路徑,而是藏在
timeout/reconnect/ 中途失敗 / early return 的路徑 - 實際崩潰的那一行,通常不是洩漏的那一行,而是之後無法再產生新 handle 的那個點
- 先要有的日誌:
operation/session情境、process 的handle count、resource 的open/close對應、Win32 / HRESULT / SDK 錯誤 - 與其等月級的再現,不如把 連線/切斷/重連/失敗路徑在短迴圈裡重複跑幾千次
- 後篇提到的 Application Verifier 很有用,但前提是 先有能追 lifetime 崩壞的日誌
簡單說,這類案件要先做的不是「盯著『跑了一個月後崩掉』這件事發呆」,而是 把資源增長與失敗路徑做成可觀測的形式。
handle leak 被發現時,多半已經帶著二次故障的面孔。所以只看崩掉那瞬間的例外,很容易走偏方向。
2. 什麼是 handle leak
2.1. 這裡說的「handle」
這裡說的 handle,是 Windows 行程用來參照 OS 資源的識別子。例如:
| 分類 | 例 |
|---|---|
| 核心物件 | event、mutex、semaphore、thread、process、waitable timer |
| I/O 類 | file、pipe、socket、對 device 的 open |
| 控制類應用常見的 | 相機 SDK 內部的 event、callback 註冊綁定的等待物件、擷取執行緒相關 handle |
在控制應用裡特別容易出事的,是 「為某次操作暫時開啟的資源,在失敗路徑忘了關」 這種模式。
像下列流程:
- 每次重連時做一個 event
- callback 登錄或擷取開始途中失敗
- success path 有關,failure path 沒關
- 短測試只走成功路徑,所以沒被抓到
這類 bug 在 code review 或實運行中都很容易殘存。
2.2. 為什麼偏偏長時間運轉才浮出
handle leak 通常不會一次性把程式炸掉。 反而麻煩的是 每次失敗只漏 1 個 這種緩坡。
flowchart LR
A[一般運轉] --> B[偶發 timeout / reconnect]
B --> C[失敗路徑建立 Event Handle]
C --> D[沒呼叫 CloseHandle]
D --> E[Handle Count 微幅成長]
E --> F[重複數百次]
F --> G[CreateEvent / SDK open 失敗]
G --> H[在別的位置崩潰 / 停止]
一次 reconnect 只漏 1 個,在幾分鐘內看不出來。
但 24/7 跑的裝置控制應用,timeout、再初始化、連線復原等邊界條件會重複很多次。結果就變成「幾週後才浮出來」。
這裡的重點是:handle leak 本身不一定就是崩潰的那一行。常見的崩法是:
- 新做 event / file / thread 的 API 失敗
- SDK 內部要的資源無法建立,回通用錯誤
- 失敗後錯誤處理不嚴謹,踩到
null/ invalid handle 就崩 - timeout 變多,結果被 watchdog 或上位控制端 kill
也就是說,崩潰點只是「最後的受害者」,不一定是「最初的犯人」。
2.3. 與記憶體流失的差異
長時間運轉後的 bug 會讓人想先懷疑記憶體流失。這很自然,但 handle leak 要用不同的軸來看,才比較快。
| 觀察點 | 記憶體流失 | handle leak |
|---|---|---|
| 先看什麼指標 | Private Bytes、Commit、Working Set |
Handle Count |
| 典型症狀 | 記憶體吃緊、paging、變慢、OOM | Create* / Open* / SDK 內部初始化失敗、二次故障 |
| 潛伏在哪 | 快取、參照殘留、忘記釋放 | create/open 與 close/dispose 不對稱 |
| 外觀上 | 記憶體緩緩成長 | handle count 緩緩成長且不回落 |
所以長時間運轉的切分時,只盯記憶體就像用一隻眼在開車。
至少把 Handle Count 與 Thread Count 一起看,會明顯好整理。
3. 案例:工業相機控制應用 1 個月後突然崩潰
3.1. 發生的症狀
症狀很單純:
- 某個控制工業相機的 Windows 應用 24/7 在跑
- 正常時可以跑
- 約 1 個月後某天突然崩潰
- 重啟後又能撐一陣子
第一個難題就是 崩到需要很久。每次重現要等 1 個月,調查上很難受。
更麻煩的是,每次崩潰的位置不完全一樣。有時在重連開始後、有時在擷取開始時、有時在 SDK 呼叫失敗後。
這種狀態,剛開始時下列項目都可疑:
- 相機 SDK 的不穩定
- 通訊或裝置斷線導致的暫時故障
- 記憶體流失
- 執行緒相關 race
- 未寫進日誌的初始化失敗
也就是「看起來可疑的項目太多」。
3.2. 最先觀察的指標
所以先做的事,是把整個 process 的資源成長趨勢看出來。 這次的觀察大致是:
| 指標 | 觀察到的趨勢 | 解讀 |
|---|---|---|
Handle Count |
reconnect、timeout 後微增,且不回落 | 懷疑 handle leak |
Private Bytes |
有起伏,但單調成長斜率不明顯 | 不一定是 heap 主導 |
Thread Count |
基本持平 | thread leak 機率低 |
| 崩潰位置 | 每次有點不同 | 二次故障的可能性高 |
到這一步,視線就收得差不多了。 把問題讀成 「1 個月後崩」,不如讀成 「途中一直在漏一點點東西,結果 1 個月後崩掉」 更自然。
3.3. 真正洩漏的位置
最後真正的原因,是 相機重連時,初始化失敗路徑裡建立的 event handle 忘了 close。
把流程簡化大約像:
sequenceDiagram
participant App as 控制應用
participant OS as Windows
participant SDK as 相機 SDK
App->>OS: CreateEvent
App->>SDK: 註冊 callback
SDK-->>App: 中途失敗 / timeout
Note over App: failure path return
Note over App: 沒呼叫 CloseHandle
loop 多次 reconnect
App->>OS: Handle Count 緩慢成長
end
App->>OS: 下一個 CreateEvent / Open
OS-->>App: 失敗
App-->>App: 以二次故障形式崩潰
程式碼示意大概是:
handle = CreateEvent(...)
if (!RegisterCallback(handle))
{
return Error; // 漏了 CloseHandle(handle)
}
if (!StartAcquisition())
{
return Error; // 這裡的 close 也漏
}
...
CloseHandle(handle)
為什麼短測試抓不到,也很容易理解:
- 正常啟動 → 正常結束時 close 會跑到
- 只有在 reconnect 途中才可能失敗
- 沒有針對這些 failure path 反覆壓測
- 正式環境花好幾週才慢慢累積
也就是 「只看正常系看不到,異常系就會自然漏」 的結構。
修正方向並不花俏:
- 把
create/open與close/dispose的職責放近 - 中途失敗也能釋放 → 靠
finally、destructor、session object - callback 註冊、擷取開始的前後,所有權寫清楚
- 「誰負責關」寫在 code 的職責裡,不是寫在註解
不是特別的技巧,只是 把資源壽命內化到程式碼結構裡。
4. 怎麼切分
4.1. 不等月級再現,縮短等待時間
每次等 1 個月效率太差。該做的是 讓可疑路徑在短時間內反覆被走到。
這次就是這樣壓縮:
flowchart LR
A[啟動] --> B[相機 open]
B --> C[擷取開始]
C --> D[模擬 timeout / 斷線]
D --> E[重連]
E --> F[擷取恢復]
F --> G{重複 N 次}
G -- 是 --> D
G -- 否 --> H[結束時看差異]
重點是 把時間花在邊界的生命週期操作,而不是在「可以拍的」正常狀態。
具體情境:
- 反覆
open -> start -> stop -> close - 刻意觸發 timeout,讓 reconnect 反覆跑
- callback 註冊後立刻失敗
- 斷線中斷、重連中斷、shutdown 競態
不用完美還原 1 個月的實際運行。 反覆踩懷疑的 lifetime edge 幾千次,反而離真正原因更近。
4.2. 以 Handle Count 的斜率去看
handle leak 看絕對值常常看不出來。 關鍵是 該回落的操作有沒有回落,以及 幾次操作會增加幾個 handle。
順序上大致:
- 預熱後記下 baseline
- 在 reconnect / start-stop / close 後記錄
Handle Count - 看每個週期的差異
- 看幾個週期下來的總體斜率
例如:
leakSlope =
(currentHandleCount - baselineHandleCount)
/ reconnectCount
2000 是多是少,跟應用性質有關,會浮動。 但如果 每 reconnect 一次就 +1 且不回落,那就相當可疑。
訣竅是:不要只看 Handle Count,至少一起記錄:
Handle CountPrivate BytesThread CountReconnectCount- 目前是哪個 phase
這樣就能快速判斷:「是記憶體在漲」、「是執行緒在漲」、還是「每次重連後資源沒回落」。
4.3. 看 create/open 與 close/dispose 的對應
整個 process 的 Handle Count 可疑只是第一步,要找到漏點,還需要 把資源生命週期成對記下來的日誌。
像這種 structured log:
CameraSession session=421 cameraId=CAM01 phase=ReconnectStart reason=FrameTimeout handleCount=1824 privateBytesMB=418
CameraResource session=421 resourceId=evt-884 kind=Event name=FrameReady action=Create osHandle=0x00000ABC handleCount=1825
CameraResource session=421 resourceId=evt-884 kind=Event name=FrameReady action=Close osHandle=0x00000ABC handleCount=1824
這裡不要只靠 osHandle。
Windows 的 handle 值會被回收再利用,所以日誌上至少要帶:
sessionIdresourceIdkindaction(Create/Open/Register/Close/Dispose/Unregister)osHandlephase
這樣就能找到 有 Create、卻沒有 Close 的不對稱流。
4.4. handle leak 要找「洩漏的地方」而不是「崩掉的地方」
這點很重要。
handle leak 很常看起來是:
- 崩掉那行:
CreateEvent失敗 - 真正漏的:前幾天開始,failure path 漏了
CloseHandle
崩掉的那個 API 是 災難的出口,未必是 原因的入口。
所以調查順序應該是:
- 先看哪種資源在一直增加
- 看在哪個操作邊界沒有回落
- 找
create/open與close/dispose不對稱的位置 - 最後才讀崩潰點
這樣比較不會迷路。
5. 為了防止再發生需要的日誌
5.1. 最基本要留的項目
這次真正起作用的,不是單純把日誌量加大。 而是 整理並增加「事後能回溯到原因的訊息」。
至少要留:
| 分類 | 至少的欄位 | 為何需要 |
|---|---|---|
| 操作情境 | cameraId、sessionId、operationId、reconnectCount、phase |
把事件綁到「哪個操作的第幾次」 |
| process 資源 | handleCount、privateBytes、workingSet、threadCount |
先分辨什麼在增加 |
| resource lifecycle | action、resourceId、kind、osHandle、owner |
追 create/open 與 close/dispose 的配對 |
| 外部呼叫結果 | win32Error、HRESULT、sdkError、timeoutMs |
事後比對失敗型態 |
| 狀態轉移 | OpenStart、OpenDone、ReconnectStart、ReconnectDone、ShutdownStart 等 |
判斷是在哪個 phase 中崩的 |
| 執行環境 | pid、tid、buildVersion、machineName |
dump / 符號 / 發布物可對應 |
這不能說「夠用了」。 但至少少了這些,就只剩下「崩掉過」這個事實的日誌。
5.2. 實際加強的日誌
本案例把日誌往這幾個方向加強:
- 定期 heartbeat
- 每 1〜5 分鐘輸出
Handle Count、Private Bytes、Thread Count、ReconnectCount
- 每 1〜5 分鐘輸出
- 相機 session 的邊界日誌
OpenStartCallbackRegisteredAcquisitionStartTimeoutDetectedReconnectStartReconnectDoneCloseStartCloseDone
- 資源生命週期日誌
- event / thread / file / timer / SDK registration token 的
Create/Open/Register與Close/Dispose/Unregister
- event / thread / file / timer / SDK registration token 的
- 錯誤正規化
- 不只存 exception message,
win32Error、HRESULT、sdkError、phase一起輸出
- 不只存 exception message,
重點是 成功與失敗時,日誌格式不要變。 不然失敗時變另外一種格式,事後很難彙整。
5.3. 粒度怎麼抓
常見的錯誤是「先全部 INFO 輸出」。 但這樣之後要讀會被日誌牆擋住,很痛苦。
實務上粒度大致這樣分:
- 定期監控
Handle Count、Private Bytes、Thread Count、ReconnectCount
- 操作邊界
- session 的 start / done / fail
- 資源邊界
create/open/register與close/dispose/unregister
- 異常細節
- error code、stack、dump 觸發條件
每一幀的詳細日誌通常用不到。 反而 「哪個職責開、哪個職責關」可讀的日誌,對長時間 bug 更有用。
6. 粗略的分流
- 幾天到幾週後才崩
- 先加
Handle Count/Private Bytes/Thread Count的 heartbeat
- 先加
- 有 retry / reconnect / shutdown
- 先做只針對這些邊界反覆跑的 harness
- 大量使用 native SDK / P/Invoke / Win32
- 後篇的 Application Verifier 很值得導入
- 也有 GUI
- 除了
Handle Count,也要看GDI Objects/USER Objects
- 除了
- 崩掉瞬間的 exception 單獨看不出東西
- 先把 operation / session / resource lifecycle 的 structured log 整好,比較快找到
最後一點特別重要。 故障調查的決勝點,常常不是分析技術本身,而是 是不是做成了可觀測的形式。
7. 總結
要留住的要點:
症狀讀法:
- 跑很久才崩,就不要只看記憶體,也要看
Handle Count - handle leak 容易藏在異常系的 failure path,而不是正常系
- 崩潰地點常常是二次故障的出口,不是真正漏的地方
防止再發生的設計:
create/open與close/dispose的職責擺在一起- 以 session / operation 為單位留下帶情境的日誌
- 同時記錄 process 資源與 resource lifecycle
測試推進方式:
- 不等月級再現,用短迴圈反覆跑 timeout / reconnect / shutdown
- 合格條件不只是「不崩」,而是「崩了能追」
- 後篇會用 Application Verifier 把記憶體不足、handle 異常這類不易浮現的壞法提前暴露
控制類應用能跑正常系固然重要, 但在長期運維下,壞了之後「能知道發生什麼事」 才是更關鍵的差距。
handle leak 正好是這種差距特別明顯的 bug。 不要只在崩掉當下看,從增加方式、邊界、職責的配對去看,就容易多了。
後篇:工業相機控制應用跑一個月後突然崩潰時(後篇) - 什麼是 Application Verifier 與異常系測試基盤的做法
8. 參考資料
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
工業相機控制應用跑一個月後突然崩潰時(後篇) - 什麼是 Application Verifier 與異常系測試基盤的做法
後篇整理 Application Verifier 是什麼以及怎麼把它編進 Windows 異常系測試基盤。用 Handles 抓 invalid handle、Low Resource Simulation 不弄掛機器就觸發資源不足,搭配 harness EXE 與自家日...
TCP 重送讓工業相機通訊卡幾秒時 - RFC1323 timestamp 與重送等待的切分
本文整理工業相機 TCP 通訊偶爾卡幾秒的切分思路:先在 wire 上用 Wireshark 確認是封包遺失後的 RTO 重送等待,再核對 SYN/SYN-ACK 是否協商 TCP timestamps option,並說明 RFC1323 系 timestamp 如何消除...
Windows 應用的 crash dump 收集入門 - 先搞清楚 WER / ProcDump / WinDbg 怎麼分工
本文整理在 Windows 應用追難以重現的 crash 時,要先以 WER LocalDumps 應用單位設定為起手式,再依現場狀況追加 ProcDump,最後才考慮 MiniDumpWriteDump 自製收集的決策順序。讀完能理解 mini 與 full dump 的...
開發 COM 元件、OCX/ActiveX 時常見的坑 - 整理 Visual Studio 的 32bit/64bit、註冊、管理員權限
整理開發 COM、OCX、ActiveX 元件時最容易卡關的四個面向:宿主行程的 32bit/64bit、Visual Studio 2022 變成 64bit 後的設計時整合、regsvr32 與 Regasm 的註冊位置、以及管理員權限與 HKCU/HKLM 的關係,協...
ClickOnce 是什麼 - 以實務視角整理機制、更新、適合場面・不適合場面
本文以實務視角整理 ClickOnce 是什麼,從 manifest、快取、更新、簽章的構造,到適合公司內部 .NET 桌面業務應用程式的案件與不適合 machine-wide 或 service、driver 等深度 OS 整合的案件,幫助讀者判斷是否採用並掌握 depl...
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
故障調查 & 長期運行故障
整理間歇性故障、通訊診斷、長期運行當機、失敗路徑測試基礎的主題頁面。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
支援包含常駐處理、設備連動、運作日誌與可維護結構的 Windows 桌面應用程式。
故障調查 & 根本原因分析
調查難以重現的故障、長時間執行後的問題、記憶體洩漏、通訊停滯等棘手的正式環境問題。
作者檔案
本文作者的個人檔案頁面。
Go Komura
小村軟體有限公司 代表
以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。