Утечка хендлов: почему приложение промышленной камеры падает после месяца работы

· Обновлено: · · Разработка Windows, Расследование сбоев, Промышленная камера, Утечка хендлов, Проектирование логов

История изменений (1 обновлений, последнее 30 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
Первая публикация
Цитирование статьи(DOI: 10.5281/zenodo.21619651)

Статья заархивирована на Zenodo. Ниже приведены DOI, который всегда ведёт к последней версии, и DOI, закреплённый за версией, которую вы читаете.

Го Комура (2026). Утечка хендлов: почему приложение промышленной камеры падает после месяца работы. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21619651 https://comcomponent.com/ru/blog/2026/03/11/002-handle-leak-industrial-camera-long-run-crash-part1/

DOI (последняя версия)
10.5281/zenodo.21619651
DOI (эта версия)
10.5281/zenodo.21619652

Когда Windows-приложение внезапно падает после долгой работы, первым делом очень часто хочется заподозрить утечку памяти. На практике не реже главным виновником оказывается утечка хендлов, которая лишь спустя недели проявляется как вторичный сбой.

Ниже — случай, когда мы разбирали Windows-приложение управления промышленной камерой, которое внезапно падало примерно после месяца непрерывной работы. По мере локализации причиной оказалась утечка хендлов на пути отказа вокруг повторного подключения камеры.

В первой части разберём, что такое утечка хендлов, как мы локализовали этот случай и какие логи стоит вести, чтобы это не повторилось. Во второй части, «Инфраструктура тестов нештатных путей Windows на Application Verifier», речь пойдёт об инфраструктуре тестов нештатных путей.

Собственные имена и часть полей логов скрыты, но сам подход в целом общий для Windows-приложений управления оборудованием.

Содержание

  1. Сначала вывод
  2. Что такое утечка хендлов
    • 2.1. Что здесь имеется в виду под «хендлом»
    • 2.2. Почему это чаще всплывает именно после долгой работы
    • 2.3. Чем это отличается от утечки памяти
  3. Случай: приложение управления промышленной камерой внезапно падает через месяц
    • 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. Справочные материалы

Карта знаний этой статьи

Статья разбирает, как находить утечку хендлов и как проектировать логи, на случае приложения управления промышленной камерой, которое внезапно падало примерно после месяца непрерывной работы. Утечка хендлов возникает, когда на failure path timeout или reconnect хендл CreateEvent не закрывают CloseHandle; это видно как Handle Count, который не возвращается, а в приложении с GUI раньше достигается лимит объектов GDI и всплывает вторичный сбой. Мера — сблизить владение и освобождение типом в стиле RAII или блоком finally, чтобы CloseHandle не забывали вызывать. При локализации падения после долгой работы не ждут воспроизведения масштаба месяца: failure path гоняют в коротком цикле много раз и вместе смотрят Handle Count, Private Bytes, Thread Count и структурированные логи. Application Verifier из второй части садится поверх этой основы.

Карта знаний: расследование падения приложения промышленной камеры после долгой работы (утечка хендлов)Рисунок показывает, что при локализации падения после долгой работы вместе смотрят Handle Count, Private Bytes и Thread Count; как хендл, не освобождённый на failure path, становится утечкой хендлов и всплывает как вторичный сбой; и место RAII и CloseHandle в предотвращении, а structured log и Application Verifier — как слои поверх этого.может вызватьпроверяетсяпроверяетсятребуетрекомендуется длядолжен предшествоватьпредотвращаетиспользуетснижаетможет вызватьрекомендуется дляпредотвращаетможет вызватьрекомендуется длярекомендуется длярекомендуется длярекомендуется длярекомендуется дляутечка хендловHandle Countfailure path (путь промежуточного сбоя)утечка памятиPrivate BytesCreateEventCloseHandleструктурированный логApplication VerifierRAII (Resource Acquisition Is Initialization)вторичный сбой (secondary failure)объект GDIразбор сбоя после долгой работычисло потоков (Thread Count)

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 18, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

1. Сначала вывод

  • В управляющем приложении, которое падает только после долгой работы, обязательно смотрите не только Private Bytes, но и Handle Count
  • Утечка хендлов чаще прячется не на штатном пути, а на путях timeout / reconnect / частичного отказа / early return
  • Строка, на которой приложение реально падает, чаще не место утечки, а место, где позже уже не удалось создать новый хендл
  • В первую очередь нужны логи с контекстом operation/session, handle count процесса, соответствием open/close ресурсов и ошибками Win32 / HRESULT / SDK
  • Ждать воспроизведения масштаба месяца дольше, чем прогнать подключение, отключение, повторное подключение и пути отказа тысячи раз в коротком цикле
  • Application Verifier из второй части весьма полезен, но до него основа — умение по собственным логам проследить, где разъехалось время жизни ресурса

Иначе говоря, в таких случаях сначала нужно не разглядывать факт «упало после долгой работы», а сделать наблюдаемыми рост ресурсов и пути отказа.

К моменту обнаружения утечка хендлов чаще всего уже выглядит как вторичный сбой. Поэтому если смотреть только на исключение в момент падения, легко уйти совсем не туда.

Что делать в первую очередьЕсли смотреть только на исключение в момент падения, легко уйти не туда. Сначала нужно сделать наблюдаемыми рост ресурсов и пути отказа — тогда можно добраться до утечки хендлов, которая уже выглядит как вторичный сбой.Смотреть только исключение в момент паденияУход не в ту сторонуНаблюдать рост ресурсов и пути отказаМожно добраться до сути утечки

Рис. 1: Сначала сделайте наблюдаемыми рост и пути отказа, а не сам факт падения.

2. Что такое утечка хендлов

2.1. Что здесь имеется в виду под «хендлом»

Здесь хендл — это идентификатор, через который процесс Windows обращается к ресурсам ОС. В эту категорию попадают, например, такие объекты.

Категория Примеры
Объекты ядра event, mutex, semaphore, thread, process, waitable timer
Ввод-вывод open для file, pipe, socket, device
Часто встречается в управлении оборудованием внутренние event SDK камеры, объекты ожидания, связанные с регистрацией callback, хендлы потока захвата изображения

В управляющих приложениях особенно опасен паттерн «ресурс временно открыли под конкретную операцию и забыли закрыть на пути частичного отказа».

Типичный ход такой:

  • на каждом повторном подключении создаётся один event;
  • регистрация callback или старт захвата где-то по пути завершается отказом;
  • на успешном пути ресурс закрывается, на пути отказа — нет;
  • в обычных коротких тестах проходит только успешный путь, поэтому это пропускают.

Такой тип утечки вполне обычен и на code review, и в реальной эксплуатации.

Типичный паттерн утечки только на пути отказаНа каждом повторном подключении создаётся event. Если регистрация callback или старт захвата прошли, хендл закрывается на успешном пути. Если отказ случился посредине, close не вызывается, а короткие тесты почти всегда идут по успешному пути и это пропускают.УспехОтказ посрединеНа каждом reconnect создаётся eventРегистрация и старт прошли?На success path вызывается closeНа failure path close не вызываетсяКороткий тест идёт по success path и пропускает это

Рис. 2: Временно открытый ресурс не закрывают на пути частичного отказа. В управляющих приложениях это особенно частый случай.

2.2. Почему это чаще всплывает именно после долгой работы

Утечка хендлов не обязательно ломает всё эффектно за один раз. Опаснее как раз утечка с небольшим наклоном, когда на один отказ утекает всего один хендл.

Штатная работаИногда timeout / reconnectНа пути отказа создаётся Event HandleCloseHandle не вызываетсяHandle Count чуть растётПовторяется сотни разCreateEvent / SDK open отказываетПадение / остановка в другом месте

Рис. 3: Утечка по одному хендлу на раз, повторённая сотни раз на граничных условиях 24/7, в итоге всплывает наружу.

Если на один reconnect утекает всего один хендл, за несколько минут ничего не произойдёт. Но в приложении управления оборудованием, которое работает 24/7, граничные условия вроде timeout, повторной инициализации, восстановления после разрыва связи случаются много раз. В результате получается странная картина: проблема проявляется только спустя несколько недель.

Важно здесь то, что сама утечка хендлов не обязательно оказывается строкой, на которой приложение падает. Чаще ломается так:

  • отказывает API, который создаёт новый event / file / thread;
  • SDK внутри не может создать нужный ресурс и возвращает только общий код отказа;
  • обработка ошибок после отказа тонкая, приложение натыкается на null / недействительный хендл и падает;
  • растёт число timeout, и в итоге процесс убивает watchdog или вышестоящий контроллер.

То есть место падения — «последний пострадавший», а не обязательно «изначальный виновник».

Место падения — последний пострадавшийЕсли хендлы продолжают утекать, рано или поздно откажет API создания нового ресурса, и падение или остановка всплывут в другом месте, где обработка ошибок тонкая. Упавшая строка — не изначальный виновник, а последний пострадавший.Где-то хендлы продолжают утекатьОтказывает API создания нового ресурсаПадение или остановка в другом местеУпавшая строка — лишь последний пострадавший

Рис. 4: Сама утечка не обязательно становится строкой падения. Ломается это обычно уже как вторичный сбой.

Здесь возникает простой вопрос. Почему приложение падает уже на нескольких тысячах хендлов?

Если смотреть только на цифры, потолок далеко. Теоретический верхний предел хендлов объектов ядра — 2^24 на процесс (около 16,77 млн). Но хендлы живут в пуле страниц, поэтому сколько их реально можно создать, зависит от доступной памяти, а на 32-bit Windows это заметно меньше теоретического значения.

Итого: случаи, когда процесс падает, упёршись в теоретический предел, скорее меньшинство. На практике раньше срабатывает обычно одно из следующего.

Что упирается раньше Ориентир Когда это важно
Объекты GDI Теоретически 65 536 на сессию. Плюс есть предел по умолчанию на процесс, его можно менять реестровым GDIProcessHandleQuota в диапазоне 256–65 536 Приложения, где рядом живёт GUI. На нескольких тысячах упереться — обычное дело
Внутренние таблицы SDK Зависит от вендора Сначала заполняется таблица хендлов или массив фиксированной длины внутри SDK камеры
Ресурсы ядра вроде пула страниц Общие на всю машину Когда вместе с хендлами едят и другие ресурсы
Виртуальное адресное пространство 32-bit процесса 2GB / 3GB Часто важнее не сам хендл, а буферы, которые выделяются вместе с ним

Поэтому читать ситуацию как «до предела ещё далеко, значит всё в порядке» нельзя. Смотреть нужно не достигнут ли предел, а возвращается ли то, что должно возвращаться. Как только появился устойчивый наклон, это уже аномалия, и так безопаснее считать.

Что упирается раньше теоретического пределаПадение из-за теоретического предела хендлов ядра — меньшинство. На практике раньше срабатывают лимит GDI, внутренние таблицы SDK, адресное пространство 32-bit процесса. Судить нужно по тому, возвращается ли то, что должно возвращаться.Теоретический предел ядра — около 16,77 млнДо него доходят редкоЧто срабатывает раньшеЛимит GDIВнутренние таблицы SDKАдресное пространство 32-bitСудить по тому, возвращается ли значение

Рис. 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. Случай: приложение управления промышленной камерой внезапно падает через месяц

3.1. Какие симптомы наблюдались

Симптомы были простыми.

  • Windows-приложение, управляющее промышленной камерой, работает 24/7;
  • в обычном режиме работает нормально;
  • примерно через месяц в какой-то день приложение внезапно падает;
  • после перезапуска снова какое-то время работает.

Первая трудность в том, что «до падения проходит слишком много времени». Ждать месяц на каждое воспроизведение как метод расследования довольно тяжело.

Ещё неприятнее было то, что место падения не было в точности одним и тем же каждый раз. Иногда сразу после начала повторного подключения, иногда при старте захвата, иногда после отказа вызова SDK.

При такой картине сначала можно подозревать что угодно из следующего:

  • нестабильность на стороне SDK камеры;
  • временные сбои из-за связи или отключения устройства;
  • утечку памяти;
  • гонку вокруг потоков;
  • сбой инициализации, который не попал в логи.

Иначе говоря, подозрительных кандидатов было слишком много.

Две вещи, из-за которых этот случай было трудно разбиратьПриложение падает примерно после месяца непрерывной работы, так что одно воспроизведение занимает месяц, и место падения каждый раз чуть другое. В сумме получается слишком много подозрительных кандидатов: SDK, связь, память и так далее.Примерно через месяц внезапно падаетОдно воспроизведение занимает месяцМесто падения каждый раз чуть другоеСлишком много подозрительных кандидатов

Рис. 7: Когда «долго до падения» складывается с «место падения плавает», наугад идти нельзя.

3.2. Показатели, на которые посмотрели в первую очередь

Поэтому первым делом посмотрели, как растут ресурсы процесса в целом. В этом случае наблюдаемые тенденции были примерно такими.

Показатель Наблюдаемая тенденция Как читать
Handle Count После reconnect и timeout постепенно растёт и не возвращается Подозревать утечку хендлов
Private Bytes Есть колебания, но наклон монотонного роста слабый Главный виновник не обязательно куча
Thread Count Почти горизонтально Утечка потоков маловероятна
Место падения Каждый раз чуть другое Вероятен вторичный сбой

На этом этапе фокус уже заметно сузился. Потому что естественнее было читать ситуацию не как «падает через месяц», а как «по пути что-то понемногу утекает, и в результате падает через месяц».

Какая гипотеза сложилась по первым показателямHandle Count растёт и не возвращается, наклон Private Bytes слабый, Thread Count почти горизонтальный, место падения каждый раз другое. Из этого сложилась гипотеза: что-то понемногу утекает, и через месяц приложение падает.Handle Count растёт и не возвращаетсяФокус сужаетсяНаклон Private Bytes слабыйThread Count почти горизонтальныйПонемногу утекает и через месяц падает

Рис. 8: Если поставить рядом форму четырёх показателей, видно не «падает через месяц», а «утекает всё это время».

3.3. Место утечки, оказавшееся истинной причиной

В итоге причиной оказался пропущенный close event-хендла, созданного на пути отказа инициализации при повторном подключении камеры.

Если упростить поток, получается так.

SDK камерыWindowsУправляющее приложениеSDK камерыWindowsУправляющее приложениеreturn на failure pathCloseHandle не вызываетсяloop[многократный reconnect]CreateEventрегистрация callbackчастичный отказ / timeoutHandle Count постепенно растётследующий CreateEvent / Openотказпадение как вторичный сбой

Рис. 9: Истинная причина — пропущенный close event-хендла на пути отказа при повторном подключении. Накопившись, это валит уже другое место.

В коде утечка выглядит примерно так.

handle = CreateEvent(...)

if (!RegisterCallback(handle))
{
    return Error;   // пропущен CloseHandle(handle)
}

if (!StartAcquisition())
{
    return Error;   // и здесь close тоже пропущен
}

...
CloseHandle(handle)

Почему это легко пропускают короткие тесты, тоже довольно понятно.

  • при обычном запуске и обычном завершении хендл закрывается;
  • отказ случается только посреди повторного подключения;
  • теста, который массово проходит именно этот путь отказа, нет;
  • в продакшене утечка копится постепенно, за несколько недель.

То есть структура была такая: «на штатном пути не видно, на путях отказа утекает вполне обычно».

Направление исправления не было эффектным.

  • сблизить ответственность create/open и close/dispose;
  • перенести освобождение в finally / деструктор / объект сессии, чтобы оно срабатывало и при частичном отказе;
  • явно определить владение вокруг регистрации callback и старта захвата;
  • выражать «кто закрывает» не комментарием, а ответственностью в самом коде.
Суть направления исправленияСблизить ответственность 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())
    {
        // После успешной регистрации при сбое сначала снимаем регистрацию,
        // и только потом закрываем хендл.
        // Если выйти без Unregister, деструктор вызовет CloseHandle, а SDK
        // продолжит держать переданный хендл. На следующем кадре он подаст
        // сигнал на уже освобождённый номер; если номер успели отдать другому
        // ресурсу, снаружи это выглядит как «вдруг срабатывает чужой event».
        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. Сжимаем время, не дожидаясь воспроизведения масштаба месяца

В таком расследовании ждать месяц на каждую попытку — плохая стратегия. Нужно за короткое время многократно пройти подозрительные пути.

В этом случае мы сжали воспроизведение таким циклом.

ДаНетЗапускОткрытие камерыСтарт захватаИмитация timeout / разрываПовторное подключениеВозобновление захватаПовторить N разПроверить разницу в конце

Рис. 12: Не ждать воспроизведения масштаба месяца, а тысячи раз в коротком цикле гонять только границы open, разрыва и повторного подключения.

Суть в том, чтобы тратить время не на обычный период «съёмка идёт», а на граничные операции времени жизни.

Конкретно хорошо работают такие сценарии.

  • массово гонять open -> start -> stop -> close;
  • намеренно вызывать timeout и крутить reconnect;
  • вызывать отказ сразу после регистрации callback;
  • вносить прерывание разрыва связи, прерывание повторного подключения, гонку при shutdown.

Идеально воспроизводить месяц реальной эксплуатации не нужно. Наоборот, тысячи раз наступить на подозреваемую границу lifetime гораздо ближе к причине.

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. записывать Handle Count после reconnect / start-stop / close;
  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
  • в какой фазе мы сейчас находимся

Тогда довольно быстро видно, растёт ли память, растут ли потоки, или ресурс не возвращается на каждом повторном подключении.

4.3. Смотрим соответствие create/open и close/dispose

Даже поняв, что 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Найти Create, у которого нет CloseosHandle переиспользуется, одним им нельзя

Рис. 14: Чтобы от числа по процессу спуститься к месту утечки, нужен лог, где жизненный цикл ресурса записан парами.

4.4. При утечке хендлов ищем не «место падения», а «место утечки»

Этот момент довольно важен.

Утечка хендлов часто выглядит так.

  • строка падения: отказ CreateEvent;
  • реальная утечка: CloseHandle пропущен на пути отказа ещё несколько дней назад.

То есть API, на котором в итоге всё упало, — это выход ущерба, а не обязательно вход причины.

Поэтому порядок расследования такой:

  1. посмотреть, какой ресурс продолжает расти;
  2. посмотреть, на какой операционной границе он не возвращается;
  3. найти место, где разъехались create/open и close/dispose;
  4. и лишь в конце прочитать место падения.

В таком порядке заметно проще не заблудиться.

Порядок расследования, который ведёт к месту утечкиСмотрим, какой ресурс продолжает расти, на какой границе операции он не возвращается, где разъехалась пара create и close, и только потом читаем место падения. Так меньше шансов заблудиться.Смотрим, какой ресурс растётСмотрим, где он не возвращаетсяИщем разъезд пары create и closeВ конце читаем место падения

Рис. 15: Место падения — лишь выход. Идти нужно от входа, то есть от места утечки.

5. Логи, без которых это повторится

5.1. Минимальный набор, который стоит вести с самого начала

В этом расследовании сработало не простое увеличение объёма логов. Сработало методичное добавление информации, по которой потом можно дойти до причины.

Как минимум стоит вести следующее.

Категория Минимально нужные поля Зачем
Контекст операции cameraId, sessionId, operationId, reconnectCount, phase Связать событие с конкретной операцией и её конкретным повтором
Ресурсы процесса handleCount, privateBytes, workingSet, threadCount Сначала понять, что именно растёт
Жизненный цикл ресурса action, resourceId, kind, osHandle, owner Проследить пары create/open и close/dispose
Результаты внешних вызовов win32Error, HRESULT, sdkError, timeoutMs Потом сравнивать типы отказов
Переходы состояний OpenStart, OpenDone, ReconnectStart, ReconnectDone, ShutdownStart и подобные Понять, в середине какой фазы всё разъехалось
Среда выполнения pid, tid, buildVersion, machineName Сопоставить с дампами, символами и выложенными артефактами

Мы не утверждаем, что этого достаточно. Но без как минимум этого легко получить логи, в которых остался только факт «упало».

5.2. Какие логи мы реально усилили

В этом случае мы усилили логи в следующих направлениях.

  1. Периодический heartbeat
    • раз в 1–5 минут выводить Handle Count / Private Bytes / Thread Count / ReconnectCount
  2. Граничные логи в рамках сессии камеры
    • OpenStart
    • CallbackRegistered
    • AcquisitionStart
    • TimeoutDetected
    • ReconnectStart
    • ReconnectDone
    • CloseStart
    • CloseDone
  3. Логи жизненного цикла ресурса
    • Create/Open/Register и Close/Dispose/Unregister для event / thread / file / timer / токенов регистрации SDK
  4. Нормализация ошибок
    • не ограничиваться одним сообщением исключения, а выводить одновременно win32Error, HRESULT, sdkError, phase

Важно не менять форму логов между успехом и отказом. Если при аномалии формат становится другим, потом их тяжело сводить.

Четыре усиленных семейства логовПериодический heartbeat, граничные логи сессии камеры, логи жизненного цикла ресурса и нормализация ошибок. Форму при успехе и при отказе не меняют — тогда по логам потом можно дойти до причины.Периодический heartbeat (значения ресурсов)Лог, по которому можно дойти до причиныГраничные логи сессииЛоги жизненного цикла ресурсаНормализация ошибокФорму при успехе и при отказе не менять

Рис. 16: Не раздувать объём логов, а собрать четыре семейства, которые потом можно сопоставить.

5.3. С какой детализацией собирать

Здесь часто соблазняются вариантом «на всякий случай выводить всё на уровне INFO». Но если так сделать, при последующем чтении встаёт стена текста. Это довольно тяжело.

По детализации реалистично примерно такое разделение.

  • Периодический мониторинг
    • Handle Count, Private Bytes, Thread Count, ReconnectCount
  • Границы операций
    • start / done / fail сессии
  • Границы ресурсов
    • create/open/register и close/dispose/unregister
  • Подробности при аномалии
    • код ошибки, стек, триггер сбора дампа

Подробный лог каждого кадра обычно не нужен. Для проблем после долгой работы полезнее логи, по которым видно, какая ответственность открыла и какая закрыла.

Как разделять детализацию логовПериодический мониторинг — счётчики ресурсов. Границы операций — старт и конец сессии. Границы ресурсов — пары create и close. Подробности глубоко пишут только при аномалии. Всё подряд на INFO даёт нечитаемую стену текста.Детализация логовПериодический мониторинг: счётчикиГраницы операций и ресурсовПодробности глубоко только при аномалииВсё подряд на INFOНечитаемая стена логов

Рис. 17: Не подробности каждого кадра, а детализация, по которой видно, кто открыл и кто закрыл.

6. Как выбирать, вкратце

  • Падает только через несколько дней — недель
    • в первую очередь добавьте heartbeat по Handle Count / Private Bytes / Thread Count
  • Есть retry / reconnect / shutdown
    • сначала постройте harness, который массово гоняет именно эти границы
  • Активно используются native SDK / P/Invoke / Win32
    • применить Application Verifier из второй части — весьма оправданно
  • Рядом есть GUI
    • помимо Handle Count стоит смотреть и GDI Objects / USER Objects
  • Одно лишь исключение в момент падения ничего не объясняет
    • быстрее сначала привести в порядок structured log по operation / session / resource lifecycle

Последний пункт довольно важен. В расследовании сбоев исход часто решает не сама техника анализа, а то, приведено ли всё в наблюдаемую форму.

7. Итог

В приложении, которое падает только после долгой работы, смотрите не только на память, но и на Handle Count. Утечки хендлов чаще прячутся не на штатном пути, а на failure path нештатных сценариев, а место падения чаще оказывается выходом вторичного сбоя, а не местом самой утечки. Если сводить чтение симптомов к сути, всё сводится именно к этим трём пунктам.

Для предотвращения повторения сблизьте ответственность create/open и close/dispose, ведите логи с контекстом на уровне сессии и операции и фиксируйте одновременно и ресурсы процесса, и жизненный цикл ресурсов. В тестах вместо ожидания воспроизведения масштаба месяца гоняйте timeout / reconnect / shutdown в коротких циклах и делайте критерием приёмки не только «не ломается», но и «прослеживаемо, когда сломалось». В этом случае сработала именно такая комбинация. Во второй части мы используем Application Verifier, чтобы заранее выявлять трудновоспроизводимые поломки вроде нехватки памяти и аномалий хендлов.

В управляющих приложениях важно, чтобы работал штатный путь, но при долгой эксплуатации очень многое решает возможность понять, что произошло, когда что-то сломалось.

Утечка хендлов — как раз тот тип дефекта, где эта разница окупается сполна. Если смотреть не только в момент возникновения проблемы, а через рост, границы и пары ответственности, отслеживать её становится заметно проще.

Часть 2: Инфраструктура тестов нештатных путей Windows на Application Verifier

8. Справочные материалы

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

В этих примерах используется сходный подход к анализу, расстановке приоритетов или переработке.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Что такое утечка хендлов?
Ситуация, когда процесс Windows открывает хендл на ресурс ОС — event, mutex, file, socket и подобные — и забывает его закрыть, из-за чего Handle Count растёт без остановки. Особенно часто встречается паттерн, когда ресурс, временно открытый под конкретную операцию, не закрывают на пути частичного отказа: timeout, reconnect, early return. В обычных коротких тестах почти всегда проходит только успешный путь, поэтому такое легко пропустить.
Как отличить утечку памяти от утечки хендлов?
Смотреть нужно на разные показатели. При утечке памяти постепенно растут Private Bytes и Commit. При утечке хендлов постепенно растёт Handle Count и не возвращается. Если при длительной работе смотреть только на память, картина получается однобокой, поэтому вместе с ними стоит смотреть Handle Count и Thread Count. Если рядом есть GUI, смотрите ещё GDI Objects / USER Objects.
Почему при утечке хендлов приложение падает только после долгой работы?
Если утечка небольшая — по одному хендлу на каждый отказ — за несколько минут ничего не случится. Но при работе 24/7 граничные условия вроде timeout и повторного подключения повторяются снова и снова и копятся неделями. В итоге это всплывает как вторичный сбой в момент, когда API создания нового event/file/thread уже не проходит. Важно и то, что место падения чаще всего не то место, где хендл утекли, а последний пострадавший.
Как расследовать утечку хендлов?
Не ждите воспроизведения масштаба месяца. Сожмите время: в коротком цикле прогоните тысячи раз подозрительные границы времени жизни — open -> start -> stop -> close, timeout, reconnect. После прогрева зафиксируйте baseline, смотрите разницу Handle Count за цикл и наклон, а места, где разъехались create/open и close/dispose, ищите по structured log с sessionId, resourceId и action. Место падения читайте в конце — так меньше шансов уйти не туда.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог