Утечка хендлов: почему приложение промышленной камеры падает после месяца работы
· Обновлено: · Го Комура · Разработка 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-приложений управления оборудованием.
Содержание
- Сначала вывод
- Что такое утечка хендлов
- 2.1. Что здесь имеется в виду под «хендлом»
- 2.2. Почему это чаще всплывает именно после долгой работы
- 2.3. Чем это отличается от утечки памяти
- Случай: приложение управления промышленной камерой внезапно падает через месяц
- 3.1. Какие симптомы наблюдались
- 3.2. Показатели, на которые посмотрели в первую очередь
- 3.3. Место утечки, оказавшееся истинной причиной
- Как мы локализовали проблему
- 4.1. Сжимаем время, не дожидаясь воспроизведения масштаба месяца
- 4.2. Смотрим на наклон
Handle Count - 4.3. Смотрим соответствие
create/openиclose/dispose - 4.4. При утечке хендлов ищем не «место падения», а «место утечки»
- Логи, без которых это повторится
- 5.1. Минимальный набор, который стоит вести с самого начала
- 5.2. Какие логи мы реально усилили
- 5.3. С какой детализацией собирать
- Как выбирать, вкратце
- Итог
- Справочные материалы
Карта знаний этой статьи
Статья разбирает, как находить утечку хендлов и как проектировать логи, на случае приложения управления промышленной камерой, которое внезапно падало примерно после месяца непрерывной работы. Утечка хендлов возникает, когда на failure path timeout или reconnect хендл CreateEvent не закрывают CloseHandle; это видно как Handle Count, который не возвращается, а в приложении с GUI раньше достигается лимит объектов GDI и всплывает вторичный сбой. Мера — сблизить владение и освобождение типом в стиле RAII или блоком finally, чтобы CloseHandle не забывали вызывать. При локализации падения после долгой работы не ждут воспроизведения масштаба месяца: failure path гоняют в коротком цикле много раз и вместе смотрят Handle Count, Private Bytes, Thread Count и структурированные логи. Application Verifier из второй части садится поверх этой основы.
flowchart LR
accTitle: Карта знаний: расследование падения приложения промышленной камеры после долгой работы (утечка хендлов)
accDescr: Рисунок показывает, что при локализации падения после долгой работы вместе смотрят Handle Count, Private Bytes и Thread Count; как хендл, не освобождённый на failure path, становится утечкой хендлов и всплывает как вторичный сбой; и место RAII и CloseHandle в предотвращении, а structured log и Application Verifier — как слои поверх этого.
handle_leak["утечка хендлов"]
handle_count["Handle Count"]
failure_path["failure path (путь промежуточного сбоя)"]
memory_leak["утечка памяти"]
private_bytes["Private Bytes"]
createevent_api["CreateEvent"]
closehandle["CloseHandle"]
structured_logging["структурированный лог"]
application_verifier["Application Verifier"]
raii["RAII (Resource Acquisition Is Initialization)"]
secondary_failure["вторичный сбой (secondary failure)"]
gdi_object["объект GDI"]
long_run_crash_investigation["разбор сбоя после долгой работы"]
thread_count["число потоков (Thread Count)"]
failure_path -->|"может вызвать"| handle_leak
handle_leak -->|"проверяется"| handle_count
memory_leak -->|"проверяется"| private_bytes
createevent_api -->|"требует"| closehandle
structured_logging -->|"рекомендуется для"| handle_leak
structured_logging -->|"должен предшествовать"| application_verifier
raii -->|"предотвращает"| handle_leak
raii -.->|"использует"| closehandle
application_verifier -.->|"снижает"| handle_leak
handle_leak -->|"может вызвать"| secondary_failure
structured_logging -->|"рекомендуется для"| secondary_failure
closehandle -->|"предотвращает"| handle_leak
gdi_object -.->|"может вызвать"| secondary_failure
handle_count -->|"рекомендуется для"| long_run_crash_investigation
structured_logging -->|"рекомендуется для"| long_run_crash_investigation
failure_path -->|"рекомендуется для"| long_run_crash_investigation
private_bytes -->|"рекомендуется для"| long_run_crash_investigation
thread_count -->|"рекомендуется для"| long_run_crash_investigation
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 18, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle
1. Сначала вывод
- В управляющем приложении, которое падает только после долгой работы, обязательно смотрите не только
Private Bytes, но иHandle Count - Утечка хендлов чаще прячется не на штатном пути, а на путях
timeout/reconnect/ частичного отказа / early return - Строка, на которой приложение реально падает, чаще не место утечки, а место, где позже уже не удалось создать новый хендл
- В первую очередь нужны логи с контекстом
operation/session,handle countпроцесса, соответствиемopen/closeресурсов и ошибками Win32 / HRESULT / SDK - Ждать воспроизведения масштаба месяца дольше, чем прогнать подключение, отключение, повторное подключение и пути отказа тысячи раз в коротком цикле
- Application Verifier из второй части весьма полезен, но до него основа — умение по собственным логам проследить, где разъехалось время жизни ресурса
Иначе говоря, в таких случаях сначала нужно не разглядывать факт «упало после долгой работы», а сделать наблюдаемыми рост ресурсов и пути отказа.
К моменту обнаружения утечка хендлов чаще всего уже выглядит как вторичный сбой. Поэтому если смотреть только на исключение в момент падения, легко уйти совсем не туда.
flowchart TB
accTitle: Что делать в первую очередь
accDescr: Если смотреть только на исключение в момент падения, легко уйти не туда. Сначала нужно сделать наблюдаемыми рост ресурсов и пути отказа — тогда можно добраться до утечки хендлов, которая уже выглядит как вторичный сбой.
crash["Смотреть только исключение в момент падения"] -.-> wrong["Уход не в ту сторону"]
obs["Наблюдать рост ресурсов и пути отказа"] --> right["Можно добраться до сути утечки"]
Рис. 1: Сначала сделайте наблюдаемыми рост и пути отказа, а не сам факт падения.
2. Что такое утечка хендлов
2.1. Что здесь имеется в виду под «хендлом»
Здесь хендл — это идентификатор, через который процесс Windows обращается к ресурсам ОС. В эту категорию попадают, например, такие объекты.
| Категория | Примеры |
|---|---|
| Объекты ядра | event, mutex, semaphore, thread, process, waitable timer |
| Ввод-вывод | open для file, pipe, socket, device |
| Часто встречается в управлении оборудованием | внутренние event SDK камеры, объекты ожидания, связанные с регистрацией callback, хендлы потока захвата изображения |
В управляющих приложениях особенно опасен паттерн «ресурс временно открыли под конкретную операцию и забыли закрыть на пути частичного отказа».
Типичный ход такой:
- на каждом повторном подключении создаётся один event;
- регистрация callback или старт захвата где-то по пути завершается отказом;
- на успешном пути ресурс закрывается, на пути отказа — нет;
- в обычных коротких тестах проходит только успешный путь, поэтому это пропускают.
Такой тип утечки вполне обычен и на code review, и в реальной эксплуатации.
flowchart TB
accTitle: Типичный паттерн утечки только на пути отказа
accDescr: На каждом повторном подключении создаётся event. Если регистрация callback или старт захвата прошли, хендл закрывается на успешном пути. Если отказ случился посредине, close не вызывается, а короткие тесты почти всегда идут по успешному пути и это пропускают.
ev["На каждом reconnect создаётся event"] --> q{"Регистрация и старт прошли?"}
q -->|"Успех"| close["На success path вызывается close"]
q -->|"Отказ посредине"| leak["На failure path close не вызывается"]
leak -.-> unseen["Короткий тест идёт по success path и пропускает это"]
Рис. 2: Временно открытый ресурс не закрывают на пути частичного отказа. В управляющих приложениях это особенно частый случай.
2.2. Почему это чаще всплывает именно после долгой работы
Утечка хендлов не обязательно ломает всё эффектно за один раз. Опаснее как раз утечка с небольшим наклоном, когда на один отказ утекает всего один хендл.
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[Падение / остановка в другом месте]
Рис. 3: Утечка по одному хендлу на раз, повторённая сотни раз на граничных условиях 24/7, в итоге всплывает наружу.
Если на один reconnect утекает всего один хендл, за несколько минут ничего не произойдёт. Но в приложении управления оборудованием, которое работает 24/7, граничные условия вроде timeout, повторной инициализации, восстановления после разрыва связи случаются много раз. В результате получается странная картина: проблема проявляется только спустя несколько недель.
Важно здесь то, что сама утечка хендлов не обязательно оказывается строкой, на которой приложение падает. Чаще ломается так:
- отказывает API, который создаёт новый event / file / thread;
- SDK внутри не может создать нужный ресурс и возвращает только общий код отказа;
- обработка ошибок после отказа тонкая, приложение натыкается на
null/ недействительный хендл и падает; - растёт число timeout, и в итоге процесс убивает watchdog или вышестоящий контроллер.
То есть место падения — «последний пострадавший», а не обязательно «изначальный виновник».
flowchart TB
accTitle: Место падения — последний пострадавший
accDescr: Если хендлы продолжают утекать, рано или поздно откажет API создания нового ресурса, и падение или остановка всплывут в другом месте, где обработка ошибок тонкая. Упавшая строка — не изначальный виновник, а последний пострадавший.
leak2["Где-то хендлы продолжают утекать"] --> fail["Отказывает API создания нового ресурса"]
fail --> vict["Падение или остановка в другом месте"]
vict -.-> note["Упавшая строка — лишь последний пострадавший"]
Рис. 4: Сама утечка не обязательно становится строкой падения. Ломается это обычно уже как вторичный сбой.
Здесь возникает простой вопрос. Почему приложение падает уже на нескольких тысячах хендлов?
Если смотреть только на цифры, потолок далеко. Теоретический верхний предел хендлов объектов ядра — 2^24 на процесс (около 16,77 млн). Но хендлы живут в пуле страниц, поэтому сколько их реально можно создать, зависит от доступной памяти, а на 32-bit Windows это заметно меньше теоретического значения.
Итого: случаи, когда процесс падает, упёршись в теоретический предел, скорее меньшинство. На практике раньше срабатывает обычно одно из следующего.
| Что упирается раньше | Ориентир | Когда это важно |
|---|---|---|
| Объекты GDI | Теоретически 65 536 на сессию. Плюс есть предел по умолчанию на процесс, его можно менять реестровым GDIProcessHandleQuota в диапазоне 256–65 536 |
Приложения, где рядом живёт GUI. На нескольких тысячах упереться — обычное дело |
| Внутренние таблицы SDK | Зависит от вендора | Сначала заполняется таблица хендлов или массив фиксированной длины внутри SDK камеры |
| Ресурсы ядра вроде пула страниц | Общие на всю машину | Когда вместе с хендлами едят и другие ресурсы |
| Виртуальное адресное пространство 32-bit процесса | 2GB / 3GB | Часто важнее не сам хендл, а буферы, которые выделяются вместе с ним |
Поэтому читать ситуацию как «до предела ещё далеко, значит всё в порядке» нельзя. Смотреть нужно не достигнут ли предел, а возвращается ли то, что должно возвращаться. Как только появился устойчивый наклон, это уже аномалия, и так безопаснее считать.
flowchart TB
accTitle: Что упирается раньше теоретического предела
accDescr: Падение из-за теоретического предела хендлов ядра — меньшинство. На практике раньше срабатывают лимит GDI, внутренние таблицы SDK, адресное пространство 32-bit процесса. Судить нужно по тому, возвращается ли то, что должно возвращаться.
limit["Теоретический предел ядра — около 16,77 млн"] -.-> rare["До него доходят редко"]
rare -.-> first["Что срабатывает раньше"]
first --> g1["Лимит GDI"]
first --> g2["Внутренние таблицы SDK"]
first --> g3["Адресное пространство 32-bit"]
g1 --> see["Судить по тому, возвращается ли значение"]
g2 --> see
g3 --> see
Рис. 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 стоит смотреть вместе — так картина быстро складывается.
flowchart TB
accTitle: Какие показатели смотреть вместе при длительной работе
accDescr: У утечки памяти и утечки хендлов разные показатели. Кроме памяти вроде Private Bytes нужно вместе смотреть Handle Count и Thread Count, иначе картина будет однобокой.
watch["Локализация после долгой работы"] --> m1["Память (Private Bytes и др.)"]
watch --> m2["Handle Count"]
watch --> m3["Thread Count"]
m2 -.-> hint["Растёт и не возвращается — подозревать утечку хендлов"]
Рис. 6: Смотреть только на память — видеть половину картины. Число хендлов и потоков держите на том же экране.
3. Случай: приложение управления промышленной камерой внезапно падает через месяц
3.1. Какие симптомы наблюдались
Симптомы были простыми.
- Windows-приложение, управляющее промышленной камерой, работает 24/7;
- в обычном режиме работает нормально;
- примерно через месяц в какой-то день приложение внезапно падает;
- после перезапуска снова какое-то время работает.
Первая трудность в том, что «до падения проходит слишком много времени». Ждать месяц на каждое воспроизведение как метод расследования довольно тяжело.
Ещё неприятнее было то, что место падения не было в точности одним и тем же каждый раз. Иногда сразу после начала повторного подключения, иногда при старте захвата, иногда после отказа вызова SDK.
При такой картине сначала можно подозревать что угодно из следующего:
- нестабильность на стороне SDK камеры;
- временные сбои из-за связи или отключения устройства;
- утечку памяти;
- гонку вокруг потоков;
- сбой инициализации, который не попал в логи.
Иначе говоря, подозрительных кандидатов было слишком много.
flowchart TB
accTitle: Две вещи, из-за которых этот случай было трудно разбирать
accDescr: Приложение падает примерно после месяца непрерывной работы, так что одно воспроизведение занимает месяц, и место падения каждый раз чуть другое. В сумме получается слишком много подозрительных кандидатов: SDK, связь, память и так далее.
sym["Примерно через месяц внезапно падает"] --> hard1["Одно воспроизведение занимает месяц"]
sym --> hard2["Место падения каждый раз чуть другое"]
hard2 -.-> many["Слишком много подозрительных кандидатов"]
Рис. 7: Когда «долго до падения» складывается с «место падения плавает», наугад идти нельзя.
3.2. Показатели, на которые посмотрели в первую очередь
Поэтому первым делом посмотрели, как растут ресурсы процесса в целом. В этом случае наблюдаемые тенденции были примерно такими.
| Показатель | Наблюдаемая тенденция | Как читать |
|---|---|---|
Handle Count |
После reconnect и timeout постепенно растёт и не возвращается | Подозревать утечку хендлов |
Private Bytes |
Есть колебания, но наклон монотонного роста слабый | Главный виновник не обязательно куча |
Thread Count |
Почти горизонтально | Утечка потоков маловероятна |
| Место падения | Каждый раз чуть другое | Вероятен вторичный сбой |
На этом этапе фокус уже заметно сузился. Потому что естественнее было читать ситуацию не как «падает через месяц», а как «по пути что-то понемногу утекает, и в результате падает через месяц».
flowchart TB
accTitle: Какая гипотеза сложилась по первым показателям
accDescr: Handle Count растёт и не возвращается, наклон Private Bytes слабый, Thread Count почти горизонтальный, место падения каждый раз другое. Из этого сложилась гипотеза: что-то понемногу утекает, и через месяц приложение падает.
o1["Handle Count растёт и не возвращается"] --> narrow["Фокус сужается"]
o2["Наклон Private Bytes слабый"] --> narrow
o3["Thread Count почти горизонтальный"] --> narrow
narrow --> view["Понемногу утекает и через месяц падает"]
Рис. 8: Если поставить рядом форму четырёх показателей, видно не «падает через месяц», а «утекает всё это время».
3.3. Место утечки, оказавшееся истинной причиной
В итоге причиной оказался пропущенный close event-хендла, созданного на пути отказа инициализации при повторном подключении камеры.
Если упростить поток, получается так.
sequenceDiagram
participant App as Управляющее приложение
participant OS as Windows
participant SDK as SDK камеры
App->>OS: CreateEvent
App->>SDK: регистрация callback
SDK-->>App: частичный отказ / timeout
Note over App: return на failure path
Note over App: CloseHandle не вызывается
loop многократный reconnect
App->>OS: Handle Count постепенно растёт
end
App->>OS: следующий CreateEvent / Open
OS-->>App: отказ
App-->>App: падение как вторичный сбой
Рис. 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 и старта захвата;
- выражать «кто закрывает» не комментарием, а ответственностью в самом коде.
flowchart TB
accTitle: Суть направления исправления
accDescr: Сблизить ответственность create и close, перенести освобождение в finally, деструктор или session object, чтобы оно срабатывало и при частичном отказе, и выражать, кто закрывает, ответственностью кода, а не комментарием.
pol["Направление исправления"] --> p1["Сблизить ответственность create и close"]
pol --> p2["Перенести освобождение в finally или деструктор"]
pol --> p3["Выражать владение ответственностью кода"]
p3 -.-> nc["Не оставлять это соглашением в комментариях"]
Рис. 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.
flowchart TB
accTitle: Кто освобождает ресурс, решает передача владения
accDescr: Откуда бы ни выйти посреди обработки, если владение передали session, дальше освобождает session. Если не передали, тип или finally обязательно уничтожает ресурс, а перед уничтожением снимает регистрацию в SDK.
exit["Откуда бы ни выйти посреди обработки"] --> q2{"Владение передали?"}
q2 -->|"Передали"| keep["Дальше освобождает session"]
q2 -->|"Не передали"| drop["Тип или finally обязательно уничтожает"]
drop -.-> unreg["Перед уничтожением снимаем регистрацию"]
Рис. 11: Не писать каждый раз «при отказе закрыть», а решать, кто освобождает, по тому, куда ушло владение.
Это не особый приём, а наведение порядка, которое встраивает время жизни ресурса прямо в код.
4. Как мы локализовали проблему
С этой главы в тексте появляются английские термины расследования. Короткий словарь заранее.
| Термин | По-русски | Что это значит в статье |
|---|---|---|
| baseline | опорное значение | Значение после прогрева, когда процесс уже успокоился. Смотрим разницу от него |
| leakSlope | наклон утечки | Сколько штук прибывает за один цикл. Самодельный показатель скорости роста |
| structured log | структурированный лог | Не свободный текст, а лог с фиксированными полями вроде key=value. Потом его можно сводить программно |
| heartbeat | периодический отчёт | Лог, который с фиксированным интервалом пишет, что процесс жив, и текущие значения ресурсов |
| harness | испытательная обвязка | Небольшое исполняемое, которое вместо основного приложения многократно гоняет только нужный фрагмент |
| phase | фаза | Метка вроде OpenStart или ReconnectStart: на каком этапе обработки мы сейчас |
4.1. Сжимаем время, не дожидаясь воспроизведения масштаба месяца
В таком расследовании ждать месяц на каждую попытку — плохая стратегия. Нужно за короткое время многократно пройти подозрительные пути.
В этом случае мы сжали воспроизведение таким циклом.
flowchart LR
A[Запуск] --> B[Открытие камеры]
B --> C[Старт захвата]
C --> D[Имитация timeout / разрыва]
D --> E[Повторное подключение]
E --> F[Возобновление захвата]
F --> G{Повторить N раз}
G -- Да --> D
G -- Нет --> H[Проверить разницу в конце]
Рис. 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 смотреть в диспетчер задач.
При расследовании утечки хендлов одного абсолютного значения часто недостаточно. Важно, вернулся ли счётчик после операции, которая должна его вернуть, и сколько хендлов прибывает за сколько операций.
Читать удобно примерно в таком порядке.
- зафиксировать baseline после прогрева;
- записывать
Handle Countпосле reconnect / start-stop / close; - смотреть разницу за каждый цикл;
- смотреть и наклон, усреднённый по нескольким циклам.
Например, так.
leakSlope =
(currentHandleCount - baselineHandleCount)
/ reconnectCount
Много это или мало — абсолютные 2000 зависят от приложения. Но если на каждый reconnect приходится +1 и оно не возвращается, это уже очень подозрительно.
Как должен выглядеть штатный путь — тоже стоит зафиксировать ориентир. Сами числа зависят от приложения, судят по форме.
- сразу после запуска число растёт. Этот участок не читаем;
- после прогрева значение должно ходить вверх-вниз внутри устойчивого диапазона;
- после цикла
open -> start -> stop -> closeзначение должно вернуться почти к тому, что было до цикла; - если за 100 циклов разница с baseline укладывается в несколько штук, это скорее здорово;
- если же значение чисто растёт пропорционально числу циклов, на каждый цикл утекает ровно этот наклон.
Смотреть нужно не «много или мало», а возвращается или нет. Если перепутать, начнёте подозревать здоровое приложение и сожжёте время.
flowchart TB
accTitle: Как читать наклон Handle Count
accDescr: После прогрева фиксируют baseline и смотрят разницу за цикл. Если после цикла значение возвращается, это скорее здорово. Если растёт пропорционально числу циклов, на каждый цикл утекает ровно этот наклон.
base["После прогрева фиксируем baseline"] --> cyc["Смотрим разницу за цикл"]
cyc --> q3{"После цикла возвращается?"}
q3 -->|"Возвращается"| ok["Скорее здорово"]
q3 -->|"Растёт пропорционально циклам"| ng["На каждый цикл утекает этот наклон"]
Рис. 13: Судить не по «много или мало», а по форме «возвращается или нет».
Хитрость здесь — не смотреть на Handle Count изолированно, а рядом фиксировать как минимум следующее.
Handle CountPrivate BytesThread CountReconnectCount- в какой фазе мы сейчас находимся
Тогда довольно быстро видно, растёт ли память, растут ли потоки, или ресурс не возвращается на каждом повторном подключении.
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 потом могут переиспользоваться, поэтому в логе удобнее держать как минимум следующее.
sessionIdresourceIdkindaction(Create/Open/Register/Close/Dispose/Unregister)osHandlephase
Так проще заметить однобокий поток, где Create есть, а Close нет.
flowchart TB
accTitle: Лог, в котором жизненный цикл ресурса виден парами
accDescr: Create и Close пишут парой и связывают один и тот же ресурс через sessionId и resourceId — тогда видно Create без Close. osHandle потом переиспользуется, поэтому одним им следить нельзя.
lg["Писать Create и Close парой"] --> ids["Связывать через sessionId и resourceId"]
ids --> find["Найти Create, у которого нет Close"]
lg -.-> reuse["osHandle переиспользуется, одним им нельзя"]
Рис. 14: Чтобы от числа по процессу спуститься к месту утечки, нужен лог, где жизненный цикл ресурса записан парами.
4.4. При утечке хендлов ищем не «место падения», а «место утечки»
Этот момент довольно важен.
Утечка хендлов часто выглядит так.
- строка падения: отказ
CreateEvent; - реальная утечка:
CloseHandleпропущен на пути отказа ещё несколько дней назад.
То есть API, на котором в итоге всё упало, — это выход ущерба, а не обязательно вход причины.
Поэтому порядок расследования такой:
- посмотреть, какой ресурс продолжает расти;
- посмотреть, на какой операционной границе он не возвращается;
- найти место, где разъехались
create/openиclose/dispose; - и лишь в конце прочитать место падения.
В таком порядке заметно проще не заблудиться.
flowchart TB
accTitle: Порядок расследования, который ведёт к месту утечки
accDescr: Смотрим, какой ресурс продолжает расти, на какой границе операции он не возвращается, где разъехалась пара create и close, и только потом читаем место падения. Так меньше шансов заблудиться.
s1["Смотрим, какой ресурс растёт"] --> s2["Смотрим, где он не возвращается"]
s2 --> s3["Ищем разъезд пары create и close"]
s3 --> s4["В конце читаем место падения"]
Рис. 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. Какие логи мы реально усилили
В этом случае мы усилили логи в следующих направлениях.
- Периодический heartbeat
- раз в 1–5 минут выводить
Handle Count/Private Bytes/Thread Count/ReconnectCount
- раз в 1–5 минут выводить
- Граничные логи в рамках сессии камеры
OpenStartCallbackRegisteredAcquisitionStartTimeoutDetectedReconnectStartReconnectDoneCloseStartCloseDone
- Логи жизненного цикла ресурса
Create/Open/RegisterиClose/Dispose/Unregisterдля event / thread / file / timer / токенов регистрации SDK
- Нормализация ошибок
- не ограничиваться одним сообщением исключения, а выводить одновременно
win32Error,HRESULT,sdkError,phase
- не ограничиваться одним сообщением исключения, а выводить одновременно
Важно не менять форму логов между успехом и отказом. Если при аномалии формат становится другим, потом их тяжело сводить.
flowchart TB
accTitle: Четыре усиленных семейства логов
accDescr: Периодический heartbeat, граничные логи сессии камеры, логи жизненного цикла ресурса и нормализация ошибок. Форму при успехе и при отказе не меняют — тогда по логам потом можно дойти до причины.
hb["Периодический heartbeat (значения ресурсов)"] --> trace["Лог, по которому можно дойти до причины"]
bd["Граничные логи сессии"] --> trace
rl["Логи жизненного цикла ресурса"] --> trace
er["Нормализация ошибок"] --> trace
trace -.-> same["Форму при успехе и при отказе не менять"]
Рис. 16: Не раздувать объём логов, а собрать четыре семейства, которые потом можно сопоставить.
5.3. С какой детализацией собирать
Здесь часто соблазняются вариантом «на всякий случай выводить всё на уровне INFO». Но если так сделать, при последующем чтении встаёт стена текста. Это довольно тяжело.
По детализации реалистично примерно такое разделение.
- Периодический мониторинг
Handle Count,Private Bytes,Thread Count,ReconnectCount
- Границы операций
- start / done / fail сессии
- Границы ресурсов
create/open/registerиclose/dispose/unregister
- Подробности при аномалии
- код ошибки, стек, триггер сбора дампа
Подробный лог каждого кадра обычно не нужен. Для проблем после долгой работы полезнее логи, по которым видно, какая ответственность открыла и какая закрыла.
flowchart TB
accTitle: Как разделять детализацию логов
accDescr: Периодический мониторинг — счётчики ресурсов. Границы операций — старт и конец сессии. Границы ресурсов — пары create и close. Подробности глубоко пишут только при аномалии. Всё подряд на INFO даёт нечитаемую стену текста.
lv["Детализация логов"] --> l1["Периодический мониторинг: счётчики"]
lv --> l2["Границы операций и ресурсов"]
lv --> l3["Подробности глубоко только при аномалии"]
all["Всё подряд на INFO"] -.-> wall["Нечитаемая стена логов"]
Рис. 17: Не подробности каждого кадра, а детализация, по которой видно, кто открыл и кто закрыл.
6. Как выбирать, вкратце
- Падает только через несколько дней — недель
- в первую очередь добавьте heartbeat по
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
- помимо
- Одно лишь исключение в момент падения ничего не объясняет
- быстрее сначала привести в порядок 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. Справочные материалы
- GetProcessHandleCount function (processthreadsapi.h)
- Process.HandleCount Property (System.Diagnostics)
- Kernel Objects - Win32 apps
- GDI Objects - Win32 apps
- typeperf - Windows Commands
- Process Explorer / Handle / VMMap на практике — ловим зависания, утечки и «файл используется» по состоянию прямо сейчас
- Часть 2: Инфраструктура тестов нештатных путей Windows на Application Verifier
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Application Verifier: инфраструктура тестов нештатных путей Windows
Разбираем, что такое Application Verifier, и как с Handles, Heaps, Low Resource Simulation и !htrace строить инфраструктуру тестов нештат...
Инцидент не закрывается восстановлением — постмортем для небольшой команды
Если закрыть инцидент после исправления и извинений, тот же сбой повторится. Адаптируем blameless-постмортем для небольшой команды: шабло...
Почему повторная передача TCP останавливает обмен с промышленной камерой и как это диагностировать
Как диагностировать остановку обмена с промышленной камерой на несколько секунд из-за повторной передачи TCP: потеря пакетов, RTO, метки ...
Как читать «использование памяти» в Windows: Working Set, Private Bytes, Commit и файл подкачки
Столбец «Память» в диспетчере задач, Working Set, Private Bytes и Commit — это разные величины. Разбираем связь виртуальной и физической ...
Спящий режим, гибернация и Modern Standby: как не дать долгоживущему приложению остановиться ночью
Разбираем, почему долгоживущее Windows-приложение к утру оказывается остановленным: чем отличаются спящий режим S3, гибернация и Modern S...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Расследование ошибок и долгие сбои
Периодические сбои, диагностика связи, сбои после длительной работы и проверка путей отказа.
Связанные примеры проектов
В этих примерах используется сходный подход к анализу, расстановке приоритетов или переработке.
Как мы связали сбой после длительной работы с утечкой дескрипторов
Кейс о том, как усиление наблюдаемости и журналирования превратило редкий ежемесячный сбой в предметное расследование утечки дескрипторов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Расследование ошибок и причин
Локализация сбоя, который проявляется только после длительной работы, очень хорошо ложится на расследование дефектов и анализ первопричины.
Разработка приложений для Windows
Если нужно пересмотреть устройство Windows-приложения, включая проектирование логов и эксплуатационное наблюдение, это также связано с консультациями по разработке Windows-приложений.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Что такое утечка хендлов?
- Ситуация, когда процесс 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.