工業相機控制應用跑一個月後突然崩潰時(前篇) - 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 BytesCommitWorking Set Handle Count
典型症狀 記憶體吃緊、paging、變慢、OOM Create* / Open* / SDK 內部初始化失敗、二次故障
潛伏在哪 快取、參照殘留、忘記釋放 create/openclose/dispose 不對稱
外觀上 記憶體緩緩成長 handle count 緩緩成長且不回落

所以長時間運轉的切分時,只盯記憶體就像用一隻眼在開車。 至少把 Handle CountThread 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/openclose/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

順序上大致:

  1. 預熱後記下 baseline
  2. 在 reconnect / start-stop / close 後記錄 Handle Count
  3. 看每個週期的差異
  4. 看幾個週期下來的總體斜率

例如:

leakSlope =
    (currentHandleCount - baselineHandleCount)
    / reconnectCount

2000 是多是少,跟應用性質有關,會浮動。 但如果 每 reconnect 一次就 +1 且不回落,那就相當可疑。

訣竅是:不要只看 Handle Count,至少一起記錄:

  • Handle Count
  • Private Bytes
  • Thread Count
  • ReconnectCount
  • 目前是哪個 phase

這樣就能快速判斷:「是記憶體在漲」、「是執行緒在漲」、還是「每次重連後資源沒回落」。

4.3. 看 create/openclose/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 值會被回收再利用,所以日誌上至少要帶:

  • sessionId
  • resourceId
  • kind
  • action(Create/Open/Register/Close/Dispose/Unregister)
  • osHandle
  • phase

這樣就能找到 有 Create、卻沒有 Close 的不對稱流。

4.4. handle leak 要找「洩漏的地方」而不是「崩掉的地方」

這點很重要。

handle leak 很常看起來是:

  • 崩掉那行:CreateEvent 失敗
  • 真正漏的:前幾天開始,failure path 漏了 CloseHandle

崩掉的那個 API 是 災難的出口,未必是 原因的入口

所以調查順序應該是:

  1. 先看哪種資源在一直增加
  2. 看在哪個操作邊界沒有回落
  3. create/openclose/dispose 不對稱的位置
  4. 最後才讀崩潰點

這樣比較不會迷路。

5. 為了防止再發生需要的日誌

5.1. 最基本要留的項目

這次真正起作用的,不是單純把日誌量加大。 而是 整理並增加「事後能回溯到原因的訊息」

至少要留:

分類 至少的欄位 為何需要
操作情境 cameraIdsessionIdoperationIdreconnectCountphase 把事件綁到「哪個操作的第幾次」
process 資源 handleCountprivateBytesworkingSetthreadCount 先分辨什麼在增加
resource lifecycle actionresourceIdkindosHandleowner create/openclose/dispose 的配對
外部呼叫結果 win32ErrorHRESULTsdkErrortimeoutMs 事後比對失敗型態
狀態轉移 OpenStartOpenDoneReconnectStartReconnectDoneShutdownStart 判斷是在哪個 phase 中崩的
執行環境 pidtidbuildVersionmachineName dump / 符號 / 發布物可對應

這不能說「夠用了」。 但至少少了這些,就只剩下「崩掉過」這個事實的日誌。

5.2. 實際加強的日誌

本案例把日誌往這幾個方向加強:

  1. 定期 heartbeat
    • 每 1〜5 分鐘輸出 Handle CountPrivate BytesThread CountReconnectCount
  2. 相機 session 的邊界日誌
    • OpenStart
    • CallbackRegistered
    • AcquisitionStart
    • TimeoutDetected
    • ReconnectStart
    • ReconnectDone
    • CloseStart
    • CloseDone
  3. 資源生命週期日誌
    • event / thread / file / timer / SDK registration token 的 Create/Open/RegisterClose/Dispose/Unregister
  4. 錯誤正規化
    • 不只存 exception message,win32ErrorHRESULTsdkErrorphase 一起輸出

重點是 成功與失敗時,日誌格式不要變。 不然失敗時變另外一種格式,事後很難彙整。

5.3. 粒度怎麼抓

常見的錯誤是「先全部 INFO 輸出」。 但這樣之後要讀會被日誌牆擋住,很痛苦。

實務上粒度大致這樣分:

  • 定期監控
    • Handle CountPrivate BytesThread CountReconnectCount
  • 操作邊界
    • session 的 start / done / fail
  • 資源邊界
    • create/open/registerclose/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/openclose/dispose 的職責擺在一起
  • 以 session / operation 為單位留下帶情境的日誌
  • 同時記錄 process 資源與 resource lifecycle

測試推進方式:

  • 不等月級再現,用短迴圈反覆跑 timeout / reconnect / shutdown
  • 合格條件不只是「不崩」,而是「崩了能追」
  • 後篇會用 Application Verifier 把記憶體不足、handle 異常這類不易浮現的壞法提前暴露

控制類應用能跑正常系固然重要, 但在長期運維下,壞了之後「能知道發生什麼事」 才是更關鍵的差距。

handle leak 正好是這種差距特別明顯的 bug。 不要只在崩掉當下看,從增加方式、邊界、職責的配對去看,就容易多了。

後篇:工業相機控制應用跑一個月後突然崩潰時(後篇) - 什麼是 Application Verifier 與異常系測試基盤的做法

8. 參考資料

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

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

在整理與改善方式上相近的案例頁面。

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

作者檔案

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

Go Komura

小村軟體有限公司 代表

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

回到部落格一覽