工业相机长期运行崩溃调查 - handle 泄漏篇

· 更新日期: · · Windows 开发, 故障排查, 工业相机, handle 泄漏, 日志设计

更新记录(2 条,最后更新 2026年09月03日)

本文的修改记录。已保存的更新前版本,可通过带有 DOI 的永久链接阅读。

本文此前是日文原文的节译,缺少大量章节、表格、Mermaid 图、图题、脚注与 FAQ。现已改写为日文原文的完整译文,技术主张与日文版一致,并补上了此前缺失的图与表,同时统一了全篇的术语译法。 查看更新前的版本 (DOI: 10.5281/zenodo.22276581)
补充了日文原文中已有的咨询引导(consultation_services)。正文内容没有改动。 查看更新前的版本 (DOI: 10.5281/zenodo.21615435)
首次发布
引用本文(DOI: 10.5281/zenodo.21615434)

本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。

小村 豪(2026)。《工业相机长期运行崩溃调查 - handle 泄漏篇》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615434 https://comcomponent.com/zh-CN/blog/2026/03/11/002-handle-leak-industrial-camera-long-run-crash-part1/

DOI(最新版本)
10.5281/zenodo.21615434
DOI(此版本)
10.5281/zenodo.22281942

当 Windows 应用在长时间运行后突然崩溃时,很多人第一反应都会怀疑是内存泄漏。 不过实际上,handle 泄漏 才是真正的元凶,直到数周之后才以二次故障的形式浮出水面,这样的案例也不少见。

本文要介绍的是一个真实案例:某个用于控制工业相机的 Windows 应用,在连续运行约 1 个月之后突然崩溃。经过排查,最终确认原因是 相机重新连接相关的失败路径中发生的 handle 泄漏。

上篇整理 handle 泄漏是什么、这次事件是如何排查的,以及为了防止再次发生应该保留哪些日志。 下篇会以 用 Application Verifier 搭建的 Windows 异常路径测试基础设施 为题,介绍异常路径测试基础设施的内容。

文中隐去了一些专有名词和部分日志字段,但这里的思路本身,在 Windows 设备控制类应用中具有相当高的通用性。

目录

  1. 先说结论(一句话)
  2. 什么是 handle 泄漏
    • 2.1. 这里所说的“handle”
    • 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. handle 泄漏要找的不是“崩溃的位置”而是“泄漏的位置”
  5. 为防止再次发生所需的日志
    • 5.1. 首先应保留的最小集合
    • 5.2. 实际强化的日志
    • 5.3. 该以什么粒度采集
  6. 大致的使用区分
  7. 总结
  8. 参考资料

图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 18 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle

1. 先说结论(一句话)

  • 对于只在长时间运行后才崩溃的控制应用,除了 Private Bytes,一定还要看 Handle Count
  • handle 泄漏往往不藏在正常路径中,而是潜伏在 timeout / reconnect / 中途失败 / early return 这类路径里
  • 真正崩溃的那一行,往往不是泄漏发生的地方,而是之后无法再创建新 handle 的地方
  • 最先需要的日志包括:operation/session 的上下文、进程的 handle count、resource 的 open/close 对应关系,以及 Win32 / HRESULT / SDK 错误
  • 与等待以月为单位的再现相比,把连接、断开、重新连接、失败路径放进短循环里跑上几千次会快得多
  • 下篇会提到的 Application Verifier 相当有效,但在此之前,先让自己的日志能够追踪 lifetime 被破坏的过程 才是基础

总而言之,这类项目应该优先做的, 不是盯着“运行很久之后崩溃了”这件事本身,而是把资源增长方式和失败路径变成可观测的形式。

handle 泄漏被发现的时候,往往已经戴上了二次故障的面具。 因此,如果只盯着崩溃瞬间的异常,很容易走向完全偏离方向的排查思路。

应该优先做什么的结构只盯着崩溃瞬间的异常容易走向偏离方向的排查,而先把资源增长方式和失败路径变成可观测的形式,就能找到戴着二次故障面具的 handle 泄漏。只看崩溃瞬间的异常走向偏离方向的排查观测资源增长方式与失败路径能找到泄漏的真面目

图1:与其盯着“崩溃了”这个事实,不如先把增长方式和失败路径变成可观测的形式。

2. 什么是 handle 泄漏

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 中会被 close,但在 failure path 中不会
  • 平时的短时间测试大多只会走成功路径,因此容易被忽略

无论是代码评审还是实际运行中,这类问题都相当常见。

只在失败路径上泄漏的典型模式每次重新连接都创建 event,callback 注册和采集启动成功时会被 close,但在中途失败的路径上不会 close 而发生泄漏,短时间测试只走成功路径因此会被忽略。成功中途失败每次重新连接都创建 event注册与启动是否成功在 success path 中被 close在 failure path 中不会 close短时间测试只走成功路径而被忽略

图2:临时打开的资源在中途失败的路径上忘记关闭。这是控制类应用中特别多见的形态。

2.2. 为什么只有长时间运行后才容易表面化

handle 泄漏未必会在一次失败中就轰然崩溃。 更麻烦的其实是 每次失败只泄漏一个 这种斜率很小的泄漏。

正常运行偶尔出现 timeout / reconnect在失败路径中创建 Event Handle没有调用 CloseHandleHandle Count 略微增加重复几百次CreateEvent / SDK open 失败在别的地方崩溃 / 停止

图3:每次只泄漏一个的小泄漏,在 24/7 运行的边界条件下重复几百次之后表面化。

如果每次 reconnect 只泄漏一个,几分钟内什么都不会发生。 但是,在 24/7 运行的设备控制应用中,timeout、重新初始化、断线恢复这类边界条件会反复出现。 结果就变成了一种奇怪的现象:只有在数周之后才会表面化。

这里重要的一点是:handle 泄漏本身未必就是崩溃的那一行。 更常见的破坏方式是这样的:

  • 创建新 event / file / thread 的 API 调用失败
  • SDK 内部无法创建所需的资源,只返回一个通用的失败代码
  • 失败后的错误处理比较薄弱,踩到 null / invalid handle 而崩溃
  • timeout 增多,结果被 watchdog 或上层控制逻辑 kill 掉

也就是说,崩溃发生的位置是“最后的受害者”,未必是“最初的犯人”。

崩溃位置只是最后的受害者在某处持续泄漏 handle 之后,创建新资源的 API 终将失败,并在错误处理薄弱的另一处以崩溃或停止的形式表面化,因此崩溃的那一行不是最初的犯人而是最后的受害者。在某处持续泄漏 handle创建新资源的 API 失败在别的地方崩溃或停止崩溃的那一行只是最后的受害者

图4:泄漏本身未必就是崩溃的那一行。破坏方式大多表现为二次故障。

这里会冒出一个朴素的疑问:区区几千个 handle,为什么就会崩溃。

只看数字的话,上限相当遥远。内核对象的 handle,每个进程理论上限是 2^24(约 1677 万)。不过 handle 存放在分页池中,所以实际能创建的数量取决于可用内存,而在 32 位 Windows 上会远远少于理论值。

也就是说,真正达到理论上限才崩溃的情况反而是少数派。实际上先起作用的,通常是下面这几种之一。

先触顶的东西 大致数值 起作用的场景
GDI 对象 每个会话理论上限 65,536。此外每个进程还有默认上限,可通过注册表的 GDIProcessHandleQuota 在 256〜65,536 的范围内调整 同时带有 GUI 的应用。在几千这个量级上就会正常地触顶
SDK 内部的管理表 取决于厂商 相机 SDK 内部持有的 handle 表或定长数组先被填满
分页池等内核资源 整台机器共享 除 handle 之外还同时消耗其他资源的情况
32 位进程的虚拟地址空间 2GB / 3GB 起作用的与其说是 handle 本身,不如说是随 handle 一起分配的缓冲区

因此,“离上限还很宽裕所以没问题”这种解读并不成立。判断依据不是是否达到上限,而是该回落的东西有没有回落。斜率一旦立起来,就应该认为已经异常,这样更稳妥。

比理论上限更早触顶的东西达到内核 handle 理论上限才崩溃的情况是少数派,实际上 GDI 对象的上限、SDK 内部的管理表、32 位进程的地址空间会更早触顶,因此应当以该回落的东西是否回落来判断。内核的理论上限约为 1677 万能达到那里的是少数派实际上更早触顶的东西GDI 的上限SDK 内部的管理表32 位的地址空间以是否回落来判断

图5:“离上限还很宽裕”不能成为安心的理由。斜率一旦立起来就要视为异常。

2.3. 与内存泄漏的区别

长时间运行后出现的故障,第一反应通常是怀疑内存泄漏。 这种反应本身很自然,但 handle 泄漏往往需要从另一个维度去看,才能更快找到问题。

视角 内存泄漏 handle 泄漏
最先看的指标 Private Bytes、Commit、Working Set Handle Count
典型症状 内存紧张、paging、变慢、OOM Create* / Open* / SDK 内部初始化失败、二次故障
容易潜伏的位置 缓存、持有引用、忘记释放 create/open 与 close/dispose 的不对称
表现形式 内存缓慢增加 handle count 缓慢增加且不回落

因此,在长时间运行的排查中,“只看内存”很容易变成用一只眼睛在观察。 至少把 Handle Count 和 Thread Count 放在一起看,会大大提升排查效率。

长时间运行排查中要一起看的指标内存泄漏和 handle 泄漏要看的指标不同,因此除了 Private Bytes 等内存类指标,还要把 Handle Count 与 Thread Count 一起看,避免只用一只眼睛观察。长时间运行的排查内存类(Private Bytes 等)Handle CountThread Count增加后不回落就是 handle 泄漏

图6:只看内存等于只用一只眼睛。把 handle 和线程的数量放在同一个画面里追踪。

3. 案例:工业相机控制应用在 1 个月后突然崩溃

3.1. 出现的症状

现象本身很简单。

  • 用于控制工业相机的 Windows 应用以 24/7 方式运行
  • 平时运行一切正常
  • 大约经过 1 个月左右,某天应用突然崩溃
  • 重启之后,又可以正常运行一段时间

首先令人头疼的是,“崩溃之前的时间太长”。 每次再现都要等 1 个月,作为排查手段来说相当吃力。

更麻烦的是,崩溃的位置每次都不完全一样。 有时发生在重新连接开始之后,有时发生在开始采集时,有时发生在 SDK 调用失败之后。

看到这种现象,一开始可以怀疑的方向有很多:

  • 相机 SDK 本身不稳定
  • 通信或设备断开引起的临时故障
  • 内存泄漏
  • 与线程相关的 race
  • 日志中没有体现的初始化失败

也就是说,一开始处于 “隐约觉得可疑的东西太多” 的状态。

让本案例排查变难的两点连续运行约 1 个月后才突然崩溃导致一次再现要等一个月,加上崩溃位置每次都不完全相同,两者叠加使得 SDK、通信、内存等可疑候选太多。约 1 个月后突然崩溃一次再现要等 1 个月崩溃位置每次略有不同可疑候选太多的状态

图7:“崩溃之前时间太长”和“崩溃位置会漂移”这两点叠加之后,靠猜是推进不下去的。

3.2. 最先查看的指标

因此最先做的事情,是观察整个 process 资源的增长方式。 在这次案例中,观测到的趋势大致如下。

指标 观测到的趋势 解读
Handle Count 在 reconnect 或 timeout 之后逐渐增加,且不会回落 怀疑是 handle 泄漏
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]CreateEvent注册 callback中途失败 / 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
  • 用代码的职责本身来表达“谁来关闭”,而不是靠注释
修复方针的骨架让 create 与 close 的职责靠近,把释放交给 finally、析构函数或 session object 以保证中途失败时也会释放,并用代码的职责而不是注释来表达谁来关闭。修复方针让 create 与 close 的职责靠近把释放交给 finally 或析构函数用代码的职责表达所有权不做只靠注释约定的规范

图10:这不是炫技的修复。而是把资源的生命周期嵌入到代码结构本身。

只用文字不太好理解,所以这里也放上把同一段处理重写之后的形态。

用 C++ 的话,就准备一个持有 handle 的小型 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 侧仍然
        // 持有传进去的 handle。下一帧就会向已释放的编号发出信号,如果
        // 该编号已被别的资源重新使用,就会以“毫无关系的事件被莫名触发”
        // 的形式暴露出来
        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 保存着注册时传入的 handle,
            // 顺序反过来的话就会去访问已经释放的 handle
            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
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 日志 想在生产设备上只回收日志

按类型划分的明细,以及如何追踪无名事件的增长方式,作为具体步骤整理在 Process Explorer / Handle / VMMap 实战 一文中。

在长期运行的调查中,真正的主力是最下面那一行“由应用自身输出”。让人盯着任务管理器看,在 24/7 的场景下是坚持不下去的。

在 handle 泄漏的排查中,只看绝对值有时很难判断问题所在。 重要的是 该回落的操作之后是否真的回落了,以及 多少次操作会增加多少个。

大致按下面的顺序来看会比较容易理解:

  1. 先确定预热之后的 baseline
  2. 在 reconnect / start-stop / close 之后记录 Handle Count
  3. 查看每个循环的差值
  4. 再查看多个循环合计后的斜率

举例来说,可以用这样的方式来看:

leakSlope =
    (currentHandleCount - baselineHandleCount)
    / reconnectCount

绝对值 2000 算多还是算少,取决于具体应用,会有很大差异。 但如果 每 reconnect 一次就 +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 的 handle 值之后可能会被重复利用,所以日志中至少应该带上以下字段,才更容易追踪:

  • 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. handle 泄漏要找的不是“崩溃的位置”而是“泄漏的位置”

这一点相当重要。

handle 泄漏经常会呈现出这样的形态:

  • 崩溃发生的那一行: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

重要的是,成功和失败时都不要改变日志的格式。 如果只在异常时改用另一种格式,事后统计分析会变得很麻烦。

强化后的四类日志把定期 heartbeat、以相机 session 为单位的边界日志、资源生命周期日志、错误的规范化这四类组合起来,并且成功和失败时不改变格式,就能得到事后可以追溯原因的日志。定期 heartbeat(资源数值)能追溯到原因的日志session 的边界日志资源生命周期日志错误的规范化成功和失败时不改变格式

图16:要做的不是增加日志量,而是备齐这四类事后可以互相对照的日志。

5.3. 该以什么粒度采集

这里容易犯的一个错误是“先全部用 INFO 级别输出”。 但这样做的话,事后阅读日志时会形成一堵日志墙,读起来相当痛苦。

在粒度上,比较现实的划分大致是这样:

  • 定期监控
    • Handle Count、Private Bytes、Thread Count、ReconnectCount
  • 操作边界
    • session 的 start / done / fail
  • 资源边界
    • create/open/register 与 close/dispose/unregister
  • 异常时的详细信息
    • error code、stack、dump 采集触发条件

通常不需要每一帧都输出详细日志。 相反,能读出 “哪个职责打开了资源,哪个职责关闭了资源” 的日志,对长时间运行的故障排查更有帮助。

日志粒度的划分方式定期监控采集资源计数,操作边界记录 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。handle 泄漏往往不藏在正常路径中,而是潜伏在异常路径的 failure path 里,崩溃发生的位置通常不是泄漏发生的地方,而是二次故障的出口。归根结底,症状的解读方式就是这 3 点。

在防止再次发生方面,应该让 create/open 与 close/dispose 的职责更加靠近,以 session / operation 为单位保留带有上下文的日志,同时记录 process 资源与 resource lifecycle 两方面的信息。在测试上,不等待以月为单位的再现,而是用短循环反复执行 timeout / reconnect / shutdown,把合格标准设定为不仅“不会崩溃”,还要“崩溃时能够追溯原因”。这次真正起作用的,就是这样的组合。下篇会使用 Application Verifier,把内存不足或 handle 异常这类不容易出现的破坏方式提前逼出来。

对于控制类应用,正常路径能跑通固然重要, 但 崩溃时能够“知道发生了什么” 在长期运行中往往更加关键。

handle 泄漏正是这种差异能够发挥作用的典型故障类型。 不要只在发生的瞬间去看,而是从增长方式、边界、职责配对这几个角度去看,会大大提升排查效率。

下篇:用 Application Verifier 搭建的 Windows 异常路径测试基础设施

8. 参考资料

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

在梳理与改进方式上相近的案例页面。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

什么是 handle 泄漏?
handle 泄漏是指 Windows 进程忘记关闭用于引用 event、mutex、file、socket 等 OS 资源的 handle,导致 Handle Count 持续增加的现象。尤其常见的模式是:为某个操作临时打开的资源,在 timeout、reconnect、early return 等中途失败的路径上忘记关闭,而平时的短时间测试大多只会走成功路径,因此很容易被忽略。
内存泄漏和 handle 泄漏该如何区分?
两者要看的指标不同。内存泄漏表现为 Private Bytes 或 Commit 持续缓慢增加,而 handle 泄漏表现为 Handle Count 持续缓慢增加且不会回落。在长时间运行的排查中,如果只看内存,很容易变成用一只眼睛在观察,因此基本原则是把 Handle Count 和 Thread Count 一起看。如果应用还带有 GUI,还要同时关注 GDI Objects / USER Objects。
为什么有 handle 泄漏时,只有长时间运行后才会崩溃?
因为每次失败只泄漏一个的这种小斜率泄漏,在几分钟内不会有任何异常,但在 24/7 运行的场景下,timeout、重新连接等边界条件会反复发生,经过数周不断累积。最终会在创建新的 event/file/thread 的 API 调用失败时,以二次故障的形式表现出来。还有一个重点是:崩溃发生的位置往往不是泄漏发生的地方,而是最后的受害者。
handle 泄漏应该如何排查?
不要等待以月为单位的再现,而是把 open -> start -> stop -> close,以及 timeout、reconnect 这类可疑的生命周期操作边界,放进短循环里跑上几千次,压缩再现所需的时间。先确定预热之后的 baseline,观察 Handle Count 每个循环的差值与斜率,再用带有 sessionId、resourceId、action 的 structured log 找出 create/open 与 close/dispose 对应关系被破坏的位置,最后才去读崩溃发生的位置——按这个顺序排查,比较不容易迷路。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表