工業相機長期運轉當機調查 - 控制代碼洩漏篇

· 更新日期: · · Windows 開發, 故障調查, 工業相機, 控制代碼洩漏, 日誌設計

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

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

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

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

Go Komura(2026)。〈工業相機長期運轉當機調查 - 控制代碼洩漏篇〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616255 https://comcomponent.com/zh-TW/blog/2026/03/11/002-handle-leak-industrial-camera-long-run-crash-part1/

DOI(最新版本)
10.5281/zenodo.21616255
DOI(此版本)
10.5281/zenodo.22297085

Windows 應用程式在長時間運轉之後突然當掉時,很多人第一個想到的都是記憶體洩漏。 不過實際上,主兇是 控制代碼洩漏(handle leak),直到數週後才以後續衍生故障的形式浮上檯面,這樣的情況也不少。

這次要介紹的,是一個控制工業相機的 Windows 應用程式,在連續運轉約 1 個月後突然當掉,我們去調查這個現象的案例。一路縮小範圍之後,原因是 相機重新連線相關的失敗路徑上發生的控制代碼洩漏。

前篇梳理控制代碼洩漏是什麼、我們怎麼縮小範圍,以及為了避免再次發生應該留下哪些日誌。 後篇則是 用 Application Verifier 打造 Windows 異常情境測試基礎,談的是異常情境的測試基礎。

專有名詞和部分日誌欄位有做遮蔽,但思路本身在 Windows 的裝置控制應用程式上大致都通用。

目錄

  1. 先講結論(一句話)
  2. 什麼是控制代碼洩漏
    • 2.1. 這裡說的「控制代碼」
    • 2.2. 為什麼偏偏長時間運轉才容易浮現
    • 2.3. 與記憶體洩漏的差別
  3. 案例:工業相機控制應用程式在 1 個月後突然當掉
    • 3.1. 當時發生的症狀
    • 3.2. 最先看的指標
    • 3.3. 真正原因所在的洩漏處
  4. 我們怎麼縮小範圍
    • 4.1. 不等以月為單位的重現,把時間壓短
    • 4.2. 用 Handle Count 的斜率來看
    • 4.3. 查看 create/open 與 close/dispose 的配對
    • 4.4. 控制代碼洩漏要找的是「洩漏的位置」,不是「當掉的位置」
  5. 為了避免再次發生所需要的日誌
    • 5.1. 首先該留下的最小集合
    • 5.2. 實際強化過的日誌
    • 5.3. 要用什麼粒度來取
  6. 大致的取捨
  7. 總結
  8. 參考資料

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

1. 先講結論(一句話)

  • 只在長時間運轉後才當掉的控制應用程式,不能只看 Private Bytes,一定也要看 Handle Count
  • 控制代碼洩漏很少藏在正常情境,多半藏在 timeout / reconnect / 中途失敗 / early return 的路徑上
  • 實際當掉的那一行,往往不是洩漏的地方,而是後來再也建立不了新控制代碼的地方
  • 首先需要的日誌是 operation/session 的脈絡、處理程序的 handle count、resource 的 open/close 配對,以及 Win32 / HRESULT / SDK 錯誤
  • 與其等以月為單位的重現,不如把連線、中斷連線、重新連線、失敗路徑用短迴圈跑上幾千次,這樣快得多
  • 後篇會談到的 Application Verifier 相當有效,但在那之前,先讓自家的日誌能追出 lifetime 哪裡亂掉 才是根基

簡單說,這類案子該先做的, 不是盯著「跑了很久之後當掉了」這件事,而是把資源的增加方式與失敗路徑做成可以觀測的形式。

控制代碼洩漏被找到時,多半已經是一副後續衍生故障的面孔。 所以只看當掉那一瞬間的例外,很容易走到完全不對的方向去。

應該先做的事的結構說明只盯著當掉瞬間的例外容易走向錯誤的方向,先把資源的增加方式與失敗路徑做成可觀測的形式,才能找到披著後續衍生故障外衣的控制代碼洩漏的圖。只看當掉瞬間的例外走向不對的方向觀測資源的增加方式與失敗路徑能找到洩漏的真面目

圖 1: 比起盯著「當掉了」這個事實,先把增加方式與失敗路徑做成可觀測的形式。

2. 什麼是控制代碼洩漏

2.1. 這裡說的「控制代碼」

這裡說的控制代碼,是 Windows 處理程序用來參照 OS 資源的識別碼。 會被算進來的,例如下面這些。

分類 例子
核心物件 event、mutex、semaphore、thread、process、waitable timer
I/O 類 file、pipe、socket、對 device 的 open
裝置控制常見的 相機 SDK 內部的 event、與 callback 註冊綁在一起的等待物件、擷取影像執行緒相關的控制代碼

在控制應用程式裡特別容易出事的,是 「為了某次操作暫時開啟的資源,在中途失敗的路徑上忘了關」 這種模式。

典型的流程如下。

  • 每次重新連線就建立 1 個 event
  • callback 註冊或擷取影像的啟動在中途失敗
  • success path 會 close,failure path 不會 close
  • 平常的短測試只走成功路徑,所以看不到

這一類問題,不論在程式碼審查還是實際維運裡,都相當容易被漏掉。

只在失敗路徑上洩漏的典型模式說明每次重新連線都會建立 event,callback 註冊與擷取影像啟動成功時會 close,但中途失敗的路徑不會 close 而造成洩漏,短測試只走成功路徑因此看不到的圖。成功中途失敗每次重新連線都建立 event註冊與啟動成功了嗎success path 會 closefailure path 不會 close短測試只走成功路徑,看不到

圖 2: 暫時開啟的資源,在中途失敗的路徑上忘了關。這在控制應用程式裡特別常見。

2.2. 為什麼偏偏長時間運轉才容易浮現

控制代碼洩漏不一定會一次就壞得很明顯。 反而麻煩的是 每失敗一次只漏 1 個 這種斜率很小的洩漏。

正常運轉偶爾出現 timeout / reconnect在失敗路徑上建立 Event Handle沒有呼叫 CloseHandleHandle Count 稍微增加重複好幾百次CreateEvent / SDK open 失敗在別的地方當機 / 停止

圖 3: 一次只漏 1 個的小洩漏,在 24/7 運轉的邊界條件下累積好幾百次後浮上檯面。

如果一次 reconnect 只漏 1 個,那麼幾分鐘之內什麼事都不會發生。 但在 24/7 運轉的裝置控制應用程式上,timeout、重新初始化、斷線復原這類邊界條件會反覆出現。 結果就變成只有在數週之後才浮現這種很奇怪的樣子。

這裡重要的是,控制代碼洩漏本身不見得就是當機的那一行。 常見的壞法是這樣。

  • 建立新的 event / file / thread 的 API 失敗
  • SDK 在內部無法建立需要的資源,只回傳一般性的失敗碼
  • 失敗之後的錯誤處理很單薄,踩到 null / invalid handle 就當掉
  • timeout 變多,結果被 watchdog 或上層控制 kill 掉

也就是說,當機的位置是「最後的受害者」,不見得是「最初的犯人」。

當機位置是最後的受害者說明若某處持續洩漏控制代碼,遲早會讓建立新資源的 API 失敗,並在錯誤處理單薄的另一個地方以當機或停止的形式浮現,因此當掉的那一行不是最初的犯人而是最後的受害者的圖。某處持續洩漏控制代碼建立新資源的 API 失敗在別的地方當機、停止當掉的那一行只是最後的受害者

圖 4: 洩漏本身不見得就是當機的那一行。壞掉的樣子多半是後續衍生的故障。

這裡會冒出一個很直接的疑問:才幾千個控制代碼而已,為什麼會當掉。

光看數字,上限其實還很遠。核心物件的控制代碼,每個處理程序的理論上限是 2^24(約 1677 萬)個。不過控制代碼放在分頁集區裡,實際能建立幾個要看可用的記憶體而定,在 32 位元 Windows 上會比理論值少得多。

簡單說,因為達到理論上限而當掉的情況反而是少數。實際上會先發揮作用的,通常是下面其中一項。

會先撞到上限的東西 大致的參考值 會發揮作用的場面
GDI 物件 每個工作階段理論上 65,536 個。另外每個處理程序還有預設上限,可以用登錄檔的 GDIProcessHandleQuota 在 256~65,536 的範圍內調整 同一個處理程序裡還有 GUI 的應用程式。幾千個的量級就會很正常地撞上上限
SDK 內部的管理表 看廠商而定 相機 SDK 內部持有的控制代碼表或固定長度陣列會先被填滿
分頁集區等核心資源 整台機器共用 連控制代碼以外的資源也一起吃掉的情況
32 位元處理程序的虛擬位址空間 2GB / 3GB 真正發揮作用的不是控制代碼本身,而是隨控制代碼一起配置的緩衝區

也就是說,「離上限還很遠所以沒問題」這種讀法並不成立。該看的 不是會不會達到上限,而是該回收的東西有沒有回收。斜率一旦立起來,就當成已經異常來看比較安全。

比理論上限更早撞到的東西說明因為達到核心控制代碼理論上限而當掉的情況是少數,實際上 GDI 物件的上限、SDK 內部的管理表、32 位元處理程序的位址空間會更早撞上,因此應該以該回收的東西有沒有回收來判斷的圖。核心的理論上限約 1677 萬能達到那裡的是少數實際上會更早撞到的東西GDI 的上限SDK 內部的管理表32 位元的位址空間用回不回得來判斷

圖 5: 「離上限還有餘裕」不能拿來安心。斜率一立起來就視為異常。

2.3. 與記憶體洩漏的差別

長時間運轉後的缺陷,第一個會想懷疑記憶體洩漏。 這本身當然很自然,但控制代碼洩漏有時候換一個軸來看會更快。

觀察角度 記憶體洩漏 控制代碼洩漏
先看的指標 Private Bytes、Commit、Working Set Handle Count
典型症狀 記憶體吃緊、paging、變慢、OOM Create* / Open* / SDK 內部初始化失敗、後續衍生的故障
容易潛藏的位置 快取、持續持有參照、忘記釋放 create/open 與 close/dispose 不對稱
呈現方式 記憶體慢慢往上增加 handle count 慢慢增加而且不回落

所以在釐清長時間運轉的問題時,「只看記憶體」很容易變成用一隻眼睛開車的狀態。 至少把 Handle Count 和 Thread Count 一起看,會好梳理很多。

釐清長時間運轉問題時要一起看的指標說明記憶體洩漏與控制代碼洩漏要看的指標不同,除了 Private Bytes 等記憶體類指標之外,也要一起看 Handle Count 與 Thread Count,才能避免用一隻眼睛開車的圖。釐清長時間運轉的問題記憶體類(Private Bytes 等)Handle CountThread Count增加後不回落就是控制代碼洩漏

圖 6: 只看記憶體等於用一隻眼睛開車。控制代碼與執行緒的數量也要在同一個畫面上追。

3. 案例:工業相機控制應用程式在 1 個月後突然當掉

3.1. 當時發生的症狀

現象很單純。

  • 控制工業相機的 Windows 應用程式以 24/7 持續運轉
  • 平常運作正常
  • 大約過了 1 個月,某一天應用程式突然當掉
  • 重新啟動之後,又能正常跑一段時間

第一個麻煩的是 「要很久才會當掉」。 每重現一次就要等 1 個月,以調查來說相當吃力。

更麻煩的是,當掉的位置每次並不完全一樣。 有時候是重新連線剛開始的時候,有時候是啟動擷取影像的時候,有時候是 SDK 呼叫失敗之後。

看到這種樣子,一開始下面每一項都可以懷疑。

  • 相機 SDK 那一側不穩定
  • 通訊或裝置斷線造成的暫時性故障
  • 記憶體洩漏
  • 執行緒相關的 race
  • 沒有寫進日誌的初始化失敗

也就是說,當時是 「隱約可疑的東西太多」 的狀態。

讓這個案例難以調查的兩點說明連續運轉約 1 個月後突然當掉,使得重現一次就要花 1 個月,加上當掉的位置每次不完全相同,兩者疊在一起後 SDK、通訊、記憶體等可疑候選太多的圖。約 1 個月後突然當掉重現一次要花 1 個月當掉的位置每次都有點不同可疑的候選太多

圖 7: 「要很久才會當掉」和「當掉的位置會浮動」兩點疊在一起,光靠猜是走不下去的。

3.2. 最先看的指標

於是最先做的,是觀察整個 process 的資源增加方式。 在這個案例中,觀測結果大致是下面的趨勢。

指標 觀測到的趨勢 判讀
Handle Count 在 reconnect 或 timeout 之後一點一點增加,而且不回落 懷疑控制代碼洩漏
Private Bytes 有增有減,但單調遞增的斜率很弱 主兇不見得是 heap
Thread Count 幾乎持平 thread leak 的可能性低
當掉的位置 每次都有點不同 很可能是後續衍生的故障

到這個時候,視線已經收得相當窄了。 因為與其看成 「1 個月後會當掉」,不如看成「中途一直在一點一點洩漏什麼,結果 1 個月後當掉」 才比較自然。

從最初的指標觀測收斂出來的判斷說明只有 Handle Count 增加而不回落、Private Bytes 的斜率很弱、Thread Count 持平、當掉位置每次不同,從這些觀測收斂到一點一點洩漏而結果在 1 個月後當掉這個判斷的圖。Handle Count 增加後不回落視線收斂Private Bytes 的斜率很弱Thread Count 幾乎持平一點一點洩漏,1 個月後當掉

圖 8: 把 4 個指標的形狀排在一起看,浮現出來的不是「1 個月後會當掉」,而是「一直在洩漏」。

3.3. 真正原因所在的洩漏處

最後查到的原因,是 相機重新連線時,在初始化失敗路徑上建立的 event handle 沒有 close。

把流程簡化之後是這樣。

相機 SDKWindows控制應用程式相機 SDKWindows控制應用程式在 failure path 直接 return沒有呼叫 CloseHandleloop[反覆 reconnect]CreateEventcallback 註冊中途失敗 / timeoutHandle Count 一點一點增加下一次 CreateEvent / Open失敗以後續衍生的故障形式當機

圖 9: 真正的原因是重新連線失敗路徑上 event handle 沒有 close。累積到最後,在別的地方當掉。

用程式碼來想像的話,洩漏長這樣。

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 註冊與擷取影像啟動的前後,把 ownership 講清楚
  • 「由誰來關」用程式碼的職責表達,而不是用 comments
修正方針的骨架說明讓 create 與 close 的職責靠近,把釋放集中到 finally 或解構函式或 session object 一側讓中途失敗也一定會釋放,並把由誰來關表達成程式碼的職責而不是註解的修正方針的圖。修正方針讓 create 與 close 的職責靠近把釋放集中到 finally 或解構函式用程式碼的職責表達所有權不要做成靠註解維持的約定

圖 10: 不是花俏的修正。把資源的生命週期直接埋進程式碼的結構裡。

光用文字不好懂,所以也把同一段處理改寫後的樣子放上來。

在 C++ 中,準備一個持有控制代碼的小型 RAII 型別,讓函式裡不要直接放裸的 HANDLE。

// C++17 / Windows
#include <windows.h>
#include <utility>

class UniqueHandle
{
public:
    UniqueHandle() noexcept = default;
    explicit UniqueHandle(HANDLE h) noexcept : h_(h) {}

    UniqueHandle(const UniqueHandle&) = delete;
    UniqueHandle& operator=(const UniqueHandle&) = delete;

    UniqueHandle(UniqueHandle&& other) noexcept
        : h_(std::exchange(other.h_, nullptr)) {}

    UniqueHandle& operator=(UniqueHandle&& other) noexcept
    {
        if (this != &other)
        {
            reset(std::exchange(other.h_, nullptr));
        }
        return *this;
    }

    ~UniqueHandle() { reset(); }

    HANDLE get() const noexcept { return h_; }
    explicit operator bool() const noexcept { return h_ != nullptr; }

    void reset(HANDLE h = nullptr) noexcept
    {
        if (h_ != nullptr)
        {
            ::CloseHandle(h_);
        }
        h_ = h;
    }

private:
    HANDLE h_ = nullptr;
};

用了這個之後,就不必在失敗路徑上補寫 CloseHandle。

// CameraSession 的成員:UniqueHandle frameReady_;
bool CameraSession::Reconnect()
{
    UniqueHandle frameReady{ ::CreateEventW(nullptr, TRUE, FALSE, nullptr) };
    if (!frameReady)
    {
        return false;   // 建立本身就失敗了,沒有東西要關
    }

    if (!RegisterCallback(frameReady.get()))
    {
        return false;   // 在這裡 return 也沒關係,解構函式會關掉
    }

    if (!StartAcquisition())
    {
        // 註冊完成之後才失敗的話,要在關閉前先解除註冊。
        // 沒解除就離開的話,解構函式會 CloseHandle,但 SDK 那一側
        // 仍然握著傳過去的控制代碼。下一個影格就會對已釋放的編號
        // 送出訊號,如果那個編號已經被別的資源重複使用,就會以
        // 「不相干的事件莫名其妙被觸發」的形式浮上檯面
        UnregisterCallback();
        return false;
    }

    // 只有成功時才把所有權移交給 session 這一側
    frameReady_ = std::move(frameReady);
    return true;
}

在 C# 中,很多情況沒辦法用一個 using 解決,所以做成 用「所有權有沒有交出去」當旗標,只有沒交出去時才在 finally 裡丟掉 的形式。因為單純寫 using var 的話,連成功的時候也會被處置掉。

// C# / .NET 8
// CameraSession 的欄位:private ManualResetEvent? _frameReady;
public bool Reconnect()
{
    var frameReady = new ManualResetEvent(false);
    var handedOver = false;
    var registered = false;

    try
    {
        if (!RegisterCallback(frameReady))
        {
            return false;
        }

        registered = true;

        if (!StartAcquisition())
        {
            return false;
        }

        _frameReady?.Dispose();
        _frameReady = frameReady;
        handedOver = true;
        return true;
    }
    finally
    {
        if (!handedOver)
        {
            // 丟掉之前,先解除外部握著的參照。
            // SDK 會保留註冊時傳過去的控制代碼,
            // 順序反過來的話,已釋放的控制代碼就會被打到
            if (registered)
            {
                UnregisterCallback();
            }

            frameReady.Dispose();
        }
    }
}

兩邊做的事情其實一樣,都是把結構做成 中途不管從哪裡離開,沒有確定所有者的資源一定會被丟掉。也就是不要讓人每次都手寫「失敗就關掉」,而是交給型別和 finally 代勞。

由所有權移交決定誰負責釋放說明處理過程中不論從哪裡離開,只要所有權交給了 session 那一側之後的釋放就由 session 負責,沒有交出去時則由型別或 finally 一定丟掉,而且丟掉之前要先解除對 SDK 的註冊的圖。交出去了沒交出去處理過程中不論從哪裡離開所有權交出去了嗎之後由 session 那一側負責釋放型別或 finally 一定會丟掉丟掉之前先解除註冊

圖 11: 不要每次都手寫「失敗就關掉」,而是用所有權的去向決定由誰釋放。

這裡與其說是什麼特別的技巧,不如說是把資源的生命週期埋進程式碼的一種梳理。

4. 我們怎麼縮小範圍

從這一章開始,調查現場的英文詞會直接出現。先放一份簡短的用語對照。

術語 中文說法 本文中的意思
baseline 基準值 暖機結束、數值穩定下來時的值。之後都用與它的差異來看
leakSlope 洩漏的斜率 每個循環增加了幾個。是用來表示增加速度的自訂指標
structured log 結構化日誌 不是寫成句子,而是像 key=value 這樣先決定欄位再輸出的日誌。之後可以用程式自動彙總
heartbeat 定期回報 以固定間隔持續輸出存活確認與資源數值的日誌
harness 測試用的外框 用來取代主應用程式,只把想測的處理反覆跑起來的小型執行程式
phase 階段 像 OpenStart、ReconnectStart 這樣,標示目前處理走到哪個階段的記號

4.1. 不等以月為單位的重現,把時間壓短

這種調查每次都等 1 個月,方向並不對。 該做的是 在短時間內反覆走過可疑的路徑。

在這個案例中,我們跑了這樣的迴圈,把重現壓縮起來。

是否啟動相機 open開始擷取影像模擬 timeout / 斷線重新連線重新開始擷取影像重複 N 次查看結束時的差異

圖 12: 不等以月為單位的重現,只把 open、斷線、重新連線的邊界用短迴圈跑上幾千次。

重點是 把時間花在邊界的生命週期操作上,而不是平常「正在擷取影像」的時間。

具體來說有效的是這些情境。

  • 大量重複跑 open -> start -> stop -> close
  • 刻意製造 timeout,讓 reconnect 一直跑
  • 在 callback 註冊完的當下讓它失敗
  • 加入中斷斷線、中斷重新連線、shutdown 競爭

不需要完美重現 1 個月份的實際維運。 反而是 把懷疑的 lifetime edge 踩上幾千次,離原因近得多。

4.2. 用 Handle Count 的斜率來看

在那之前先寫清楚 Handle Count 要去哪裡看。這一點不清楚的話,這一節全都只是紙上談兵。

方式 操作 適合的場面
工作管理員 開啟「詳細資料」索引標籤,在資料行標題上按右鍵 →「選擇資料行」→ 勾選「控制代碼」 想馬上看到現在有幾個
Process Explorer 選取處理程序後開啟內容,看 Process Performance 索引標籤裡的 Handle Count。把下方窗格的 Handles 檢視依 Type 排序,還能看到各類型的細分 想知道增加的是哪一種控制代碼
handle.exe 用 handle -s -p CameraApp 以文字取得依類型彙總的結果 想把定點觀測留在日誌裡
PowerShell Get-Process -Name CameraApp \| Select-Object Name, Id, HandleCount 想用指令碼定期取得
typeperf typeperf "\Process(CameraApp)\Handle Count" -si 60 -sc 1440 -o handles.csv 想直接用 CSV 長時間記錄
應用程式自己 把 GetProcessHandleCount 或 Process.HandleCount 埋進 heartbeat 日誌 想在正式機上只回收日誌

各類型的細分,以及匿名 event 增加方式的追法,整理成步驟放在 Process Explorer / Handle / VMMap 實戰 那一篇。

長期運轉的調查裡真正的主力是最下面那一列「應用程式自己輸出」。要人一直盯著工作管理員,在 24/7 的情況下撐不下去。

在控制代碼洩漏的調查中,光看絕對值有時候看不出什麼。 重要的是 在應該回落的操作之後有沒有回落,以及 幾次操作會增加幾個。

看法上大致照下面的順序會比較好懂。

  1. 決定暖機之後的 baseline
  2. 在 reconnect / start-stop / close 之後記錄 Handle Count
  3. 查看每一個循環的差異
  4. 也看合併數個循環之後的斜率

例如像下面這樣看。

leakSlope =
    (currentHandleCount - baselineHandleCount)
    / reconnectCount

絕對值 2000 算多還是算少,會隨應用程式而浮動。 不過如果是 每 reconnect 一次就 +1 而且不回落,那就相當可疑。

那麼正常情境應該看起來是什麼樣子,這裡也寫一個判斷基準。數值本身要看應用程式,所以用形狀來判斷。

  • 剛啟動時會增加。這一段不拿來判讀
  • 暖機結束之後,數值會隨操作增減,但應該呈現 在一定範圍內進出 的形狀
  • 跑完 1 個 open -> start -> stop -> close 循環之後,數值回到 與循環前幾乎相同 才是正常
  • 跑 100 個循環之後,與 baseline 的差距若在幾個以內,大致就算健康
  • 反過來,如果與循環次數成正比,乾淨地一路往右上爬,那就是每次都照那個斜率在洩漏

要看的不是「多還是少」,而是 回不回得來。這裡搞錯的話,就會去懷疑正常的應用程式,把時間白白燒掉。

Handle Count 斜率的判讀方式說明暖機之後先定出 baseline,查看每一個循環的差異,循環後回到原值大致就算健康,若與循環次數成正比一路往右上爬則是每次都照那個斜率在洩漏的判讀方式的圖。會回去成正比往右上爬暖機之後定出 baseline查看每一個循環的差異循環後會回到原值嗎大致算健康每次都照那個斜率在洩漏

圖 13: 不是看絕對值的多寡,而是用「回不回得來」的形狀來判斷。

這裡的訣竅是不要只看 Handle Count,至少要一起記下下面這些。

  • Handle Count
  • Private Bytes
  • Thread Count
  • ReconnectCount
  • 目前在哪個 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 的控制代碼數值之後可能被重複使用,所以日誌上至少帶上下面這些會比較好追。

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

這樣做之後,就比較容易找出 有 Create 卻沒有 Close 這種只剩單邊的流程。

成對追蹤資源生命週期的日誌說明把 Create 與 Close 成對記錄並用 sessionId 與 resourceId 把同一個資源串起來,就能找出有 Create 卻沒有 Close 的單邊流程,同時說明 osHandle 會被重複使用因此單靠它追不到的圖。把 Create 與 Close 成對記錄用 sessionId 與 resourceId 串起來找出沒有被 Close 的 CreateosHandle 會被重複使用,單靠它不行

圖 14: 要從整個處理程序的數字往下走到洩漏的位置,需要把資源生命週期成對記錄的日誌。

4.4. 控制代碼洩漏要找的是「洩漏的位置」,不是「當掉的位置」

這一點相當重要。

控制代碼洩漏常常以這種形式出現。

  • 當掉的那一行:CreateEvent 失敗
  • 真正的洩漏:好幾天前就在 failure path 上少了 CloseHandle

也就是說,最後當掉的那個 API 是 災情的出口,不見得是 原因的入口。

所以調查的順序是這樣。

  1. 查看哪一種資源在持續增加
  2. 查看在哪個操作邊界沒有回落
  3. 找出 create/open 與 close/dispose 配對失衡的位置
  4. 最後才讀當機點

照這個順序,比較不容易迷路。

追到洩漏位置的調查順序說明先查看哪一種資源在持續增加,再看在哪個操作邊界沒有回落,接著找出 create 與 close 配對失衡的位置,最後才讀當機點,照這個順序比較不容易迷路的圖。查看持續增加的資源查看沒有回落的操作邊界尋找 create 與 close 配對的失衡最後才讀當機點

圖 15: 當機點只是出口。要從入口,也就是「洩漏的位置」開始追。

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 / symbol / 散發出去的檔案

我不會說這樣就夠了。 但至少沒有這些的話,日誌很容易變成 只留下「當掉了」這個事實。

5.2. 實際強化過的日誌

在這個案例中,我們往下面幾個方向強化日誌。

  1. 定期 heartbeat
    • 每隔 1~5 分鐘輸出 Handle Count / Private Bytes / Thread Count / ReconnectCount
  2. 以相機 session 為單位的邊界日誌
    • OpenStart
    • CallbackRegistered
    • AcquisitionStart
    • TimeoutDetected
    • ReconnectStart
    • ReconnectDone
    • CloseStart
    • CloseDone
  3. 資源生命週期日誌
    • event / thread / file / timer / SDK registration token 的 Create/Open/Register 與 Close/Dispose/Unregister
  4. 錯誤的正規化
    • 不要只留下例外 message,同時輸出 win32Error、HRESULT、sdkError、phase

重要的是 成功時與失敗時不要改變日誌的格式。 只有異常時才變成另一種格式的話,之後很難彙總。

強化過的 4 條日誌路線說明把定期 heartbeat、以相機 session 為單位的邊界日誌、資源生命週期日誌、錯誤的正規化這 4 條路線組合起來,並且成功時與失敗時不改變格式,就能得到之後追得到原因的日誌的圖。定期 heartbeat(資源數值)能追到原因的日誌session 的邊界日誌資源生命週期日誌錯誤的正規化成功時與失敗時不改變格式

圖 16: 不是把日誌量加大,而是備齊之後能互相對照的 4 條路線。

5.3. 要用什麼粒度來取

這裡很容易就會做出「總之全部用 INFO 吐出來」這種事。 但這樣做之後,之後要讀的時候會出現一堵日誌牆。這相當難受。

粒度上,大致照下面的分法比較實際。

  • 定期監控
    • Handle Count、Private Bytes、Thread Count、ReconnectCount
  • 操作邊界
    • session 的 start / done / fail
  • 資源邊界
    • create/open/register 與 close/dispose/unregister
  • 異常時的細節
    • error code、stack、傾印採集的觸發條件

每個影格都寫詳細日誌通常沒有必要。 反而是能讀出 「哪個職責開了、哪個職責關了」 的日誌,對長時間運轉的缺陷更有用。

日誌粒度的分法說明定期監控記資源的數量,操作邊界記 session 的開始與結束,資源邊界記 create 與 close 的配對,只有異常時才深入記錄細節,不要全部用 INFO 吐出來而築起日誌牆的圖。日誌的粒度定期監控:數量操作邊界與資源邊界只有異常時才深入記細節全部用 INFO 吐出來讀不了的日誌牆

圖 17: 比起每個影格的細節,把粒度調整成能讀出「誰開了、誰關了」。

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
  • 光看當掉那一瞬間的例外什麼也看不出來
    • 先把 operation / session / resource lifecycle 的 structured log 整理好會比較快

最後一項相當重要。 在缺陷調查中,決定勝負的往往不是分析技術本身,而是 有沒有先做成可以觀測的形式。

7. 總結

只在長時間運轉後才當掉的應用程式,不能只看記憶體,也要看 Handle Count。控制代碼洩漏很少藏在正常情境,而是藏在異常情境的 failure path 上,當機點多半不是洩漏的地方,而是後續衍生故障的出口。症狀的判讀方式,說到底就是這 3 點。

在避免再次發生上,要讓 create/open 與 close/dispose 的職責靠近,留下以 session / operation 為單位帶有脈絡的日誌,並且把 process 資源與 resource lifecycle 兩邊都記下來。測試上不要等以月為單位的重現,而是用短迴圈跑 timeout / reconnect / shutdown,把合格條件從「不會壞」擴大到「壞掉時追得到」。這次有效的就是這個組合。後篇會用 Application Verifier,把記憶體不足或控制代碼異常這類不容易出現的壞法提前逼出來。

在控制應用程式裡,正常情境能跑通當然重要, 但 壞掉時能「知道發生了什麼事」 在長期維運上相當有用。

控制代碼洩漏正是那種會被這個差距左右的缺陷。 不要只看發生的那一瞬間,改成用增加方式、邊界、職責的配對來看,會好追很多。

後篇:用 Application Verifier 打造 Windows 異常情境測試基礎

8. 參考資料

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

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

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

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

常見問題

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

什麼是控制代碼洩漏(handle leak)?
指 Windows 處理程序忘了關閉用來參照 event、mutex、file、socket 等 OS 資源的控制代碼,導致 Handle Count 一直往上增加。最常見的是為了某次操作暫時開啟的資源,在 timeout、reconnect、early return 這類中途失敗的路徑上忘了關閉;平常的短測試只會走到成功路徑,因此很容易漏掉。
記憶體洩漏和控制代碼洩漏要怎麼分辨?
要看的指標不同。記憶體洩漏是 Private Bytes 或 Commit 慢慢往上爬,控制代碼洩漏則是 Handle Count 慢慢增加而且不會回落。長時間運轉的問題若只看記憶體,等於用一隻眼睛開車,所以基本上要把 Handle Count 和 Thread Count 一起看。如果同一個處理程序裡還有 GUI,也要看 GDI Objects / USER Objects。
有控制代碼洩漏時,為什麼只有長時間運轉之後才會當掉?
因為每失敗一次只漏 1 個這種斜率很小的洩漏,在幾分鐘之內什麼也不會發生;但 24/7 運轉時 timeout、重新連線這類邊界條件會反覆發生,經過數週逐漸累積。最後在建立新的 event / file / thread 的 API 失敗的那一刻,才以後續衍生的故障形式浮現。另一個重點是:當機的位置往往是最後的受害者,而不是洩漏的地方。
控制代碼洩漏該怎麼調查?
不要等以月為單位的重現,而是把 open -> start -> stop -> close、timeout、reconnect 這些可疑的生命週期邊界操作,用短迴圈重複跑幾千次,把重現壓縮到很短的時間。先在暖機之後定出 baseline,觀察 Handle Count 每一個循環的差異與斜率,再用帶有 sessionId、resourceId、action 的 structured log 找出 create/open 與 close/dispose 配對已經失衡的位置,最後才讀當機點,照這個順序比較不會迷路。

作者檔案

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

Go Komura

小村軟體有限公司 代表

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

回到部落格一覽