.NET: как отличить ожидание GC от утечки памяти — практические шаги наблюдения, сравнения и доказательства роста памяти
· Го Комура · .NET, C#, GC, Утечка памяти, Диагностика, dotnet-counters, dotnet-dump, Эксплуатация, Использование существующих ресурсов
1. Что нужно понять в первую очередь
При эксплуатации приложений на .NET иногда наблюдается ситуация, когда потребление памяти медленно, но неуклонно растёт.
Диспетчер задач или top показывает, что память процесса увеличивается.
Потребление памяти контейнером тоже растёт.
На графике мониторинга Working Set или RSS ползёт вправо-вверх.
Глядя на это, сразу хочется подумать: «не утечка ли памяти?» Но в .NET рост памяти процесса и утечка памяти — не одно и то же.
В .NET есть сборка мусора. Память не возвращается ОС в тот самый момент, когда объект становится ненужным. GC работает, ориентируясь на объём выделений, пороговые значения кучи, давление на память, поколения и характер нагрузки.
Из-за этого возникают такие ситуации:
- объект, ставший ненужным, ещё не был собран сборщиком мусора;
- сборка мусора уже прошла, но Working Set процесса не снижается сразу;
- память вырастает один раз — из-за первого обращения, JIT, кэшей, пула соединений — а затем стабилизируется;
- управляемая куча стабильна, но растёт неуправляемая память, число потоков, сокетов или память со стороны библиотеки обработки изображений;
- объект, который действительно должен быть ненужным, продолжает на что-то ссылаться откуда-то ещё.
Эта статья посвящена тому, как распознать последний случай — настоящую утечку. Смотреть нужно не на голое потребление памяти, а на три вещи:
- растёт ли объём памяти, который выживает после сборки мусора;
- какие именно типы растут;
- кто ссылается на эти объекты.
Расследование утечки памяти в .NET — это работа, которая не должна останавливаться на «память растёт», а должна довести дело до «объекты этого типа растут, и на них продолжают ссылаться из этого корня».
Код, встречающийся в статье, опубликован на GitHub в виде готового к сборке и запуску набора примеров (библиотека типичных паттернов утечек, демонстрация разницы между ожиданием GC и выжившими объектами, а также модульные тесты, проверяющие удержание и сборку через WeakReference).
dotnet-gc-or-memory-leak - komurasoft-blog-samples (GitHub)
2. Сначала договоримся, что такое «утечка памяти»
Утечка памяти в .NET — это не только форма из C или C++, когда «забыли освободить выделенную память».
В управляемом коде объекты собирает сборщик мусора. Может ли GC собрать объект, определяется тем, остались ли ещё достижимые ссылки на него.
Иными словами, типичная утечка памяти в .NET выглядит так:
Объект уже не нужен по бизнес-логике, но на него всё ещё ссылаются — из статического поля, кэша, события, Timer, коллекции, времени жизни DI, асинхронного контекста, — так что с точки зрения GC он всё ещё выглядит используемым.
GC умён, но он не знает, нужен ли объект бизнесу. Если на объект есть ссылка, GC считает его живым.
Поэтому в .NET понятнее говорить не «утечка», а «непреднамеренное удержание».
С другой стороны, следующие состояния сами по себе ещё не означают утечку памяти.
| Состояние | Почему это не обязательно утечка |
|---|---|
| Растёт Working Set / RSS | Это память, выделенная процессу операционной системой, и она не совпадает с объёмом живых объектов управляемой кучи |
| Растёт Total Allocated | Это накопленный объём выделений с момента запуска, поэтому он растёт практически при любой работе приложения |
| GC Heap скачкообразно увеличивается | Возможно, необработанные объекты просто ждут следующей сборки мусора |
| Память растёт сразу после запуска | Часто вызвано JIT, загрузкой типов, начальными кэшами, пулами соединений, разворачиванием шаблонов |
| LOH велик | Может быть следствием переиспользования крупных массивов и буферов, фрагментации или стратегии пулинга |
| Память не снижается | Даже после сборки мусора процесс не обязательно сразу возвращает память ОС |
И наоборот: чем больше из перечисленного ниже наблюдается одновременно, тем сильнее подозрение на утечку памяти.
| Наблюдение | Значение |
|---|---|
| При повторении одной и той же операции куча после GC каждый раз растёт | Увеличивается число выживающих объектов |
| Размер Gen 2 или LOH продолжает расти | Остаются долгоживущие объекты или крупные объекты |
| Count / Size одного и того же типа растёт в нескольких дампах подряд | Можно выявить конкретный растущий тип |
gcroot показывает ссылки от static, событий, кэшей или долгоживущих сервисов |
Можно объяснить, почему GC не может собрать объект |
| После остановки нагрузки память не возвращается даже спустя достаточное время или после диагностической сборки мусора | Вероятно, дело не в разовом временном выделении |
3. Разделяем, «какую именно память» мы смотрим
Первая причина путаницы при исследовании памяти — смешение разных метрик памяти. Все они называются «памятью», но означают разное.
| Показатель | Что измеряет | Как читать |
|---|---|---|
| Working Set / RSS | Страницы процесса, находящиеся в физической памяти | Взгляд со стороны ОС; не сама куча GC |
| Private Bytes / Commit | Закоммиченная память, находящаяся в частном владении процесса | Включает также нативную память, стеки, JIT-код, сегменты GC |
| GC Heap Size | Объём объектов в управляемой куче | Отправная точка для наблюдения за памятью, управляемой GC в .NET |
| Total Allocated | Накопленный объём выделений с момента запуска | В целом только растёт; сам по себе не годится для вывода об утечке |
| Gen 0 / Gen 1 / Gen 2 | Куча по поколениям | То, что остаётся в Gen 2, — долгоживущее |
| LOH | Куча для крупных объектов размером от 85 000 байт | Легко растёт из-за крупных массивов, строк, буферов |
| POH | Куча для закреплённых (pinned) объектов | Подсказка для влияния нативного взаимодействия и закрепления памяти |
| Finalization Queue | Объекты, ожидающие финализации | Подсказка для пропущенных вызовов Dispose и затора в финализаторе |
Разбираться во всём сразу и в деталях не нужно. Для начала разложим вопрос на шаги.
Память процесса растёт
↓
Растёт ли также управляемая куча?
↓
Растёт ли объём, выживающий после GC?
↓
Какой тип растёт?
↓
Кто на него ссылается?
Если придерживаться этого порядка, гораздо труднее спутать «видимый рост памяти» с «настоящей утечкой».
4. Схема принятия решений
На практике разбираться удобнее по такой схеме.
1. Определить условия воспроизведения
- Какой API, экран, задача или пакетное задание вызывает рост
- После скольких выполнений память начинает расти
- Что происходит, если остановить нагрузку
2. Посмотреть тенденции через dotnet-counters
- Working Set
- GC Heap
- Gen 2 / LOH
- Total Allocated
- Число сборок мусора
3. Сравнить показатели во времени
- Сразу после запуска
- После прогрева
- Во время нагрузки
- После остановки нагрузки
- После N повторений одной и той же операции
4. Снять дамп минимум дважды
- before
- after
- по возможности — ещё раз после остановки нагрузки
5. Найти растущие типы
- dumpheap -stat
- gcdump report
- Visual Studio / PerfView
6. Проверить источники ссылок
- gcroot
- gchandles
- finalizequeue
7. Сделать вывод
- Ожидание GC
- Нормальный рост кэша
- Утечка управляемой памяти
- Проблема с нативной памятью
- Фрагментация LOH или временное крупное выделение
Важно не делать вывод по одному-единственному значению. Утечка памяти — это «тенденция непрерывного роста», поэтому сравнивать нужно не единичную точку, а показатели во времени при одинаковых условиях.
5. Используемые инструменты
В этой статье в основном используются следующие инструменты.
| Инструмент | Когда применяется |
|---|---|
dotnet-counters |
Наблюдение за тенденциями GC и Working Set у работающего процесса |
dotnet-gcdump |
Быстрое получение статистики по живым управляемым объектам |
dotnet-dump |
Подробный просмотр кучи, отслеживание источников ссылок через dumpheap и gcroot |
| Visual Studio Memory Usage | Когда нужно сравнение через GUI в Windows |
| PerfView | Когда нужен глубокий анализ GC / кучи / трассировки в Windows |
dotnet-trace |
Когда нужно отследить выделения и события GC во времени |
Сначала устанавливаем CLI-инструменты.
dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-dump
dotnet tool install --global dotnet-gcdump
dotnet tool install --global dotnet-trace
Если они уже установлены, обновляем их.
dotnet tool update --global dotnet-counters
dotnet tool update --global dotnet-dump
dotnet tool update --global dotnet-gcdump
dotnet tool update --global dotnet-trace
Находим процесс, который нужно исследовать.
dotnet-counters ps
Далее в примерах ID целевого процесса обозначается как <PID>.
В Linux, macOS и в контейнерных средах диагностические инструменты должны запускаться от имени того же пользователя, что и целевой процесс. Кроме того, в зависимости от окружения на работу может влиять TMPDIR, диагностический порт и пространство имён PID контейнера.
Если работа ведётся в продакшне, не стоит сразу снимать дампы — сначала проверьте нагрузку и её влияние в тестовой среде.
6. Сначала смотрим тенденции через dotnet-counters
В первую очередь смотрят не на подробный дамп, а на тенденцию.
dotnet-counters monitor \
--process-id <PID> \
--refresh-interval 3 \
--counters System.Runtime
Вывод немного отличается в зависимости от версии .NET.
Начиная с .NET 9 счётчики могут отображаться под именами Meter System.Runtime, а в .NET 8 и более ранних версиях — под традиционными именами EventCounter.
В основном смотрим на такие пункты.
| Что смотреть | На что это указывает |
|---|---|
dotnet.process.memory.working_set |
Резидентная память процесса с точки зрения ОС |
dotnet.gc.last_collection.heap.size |
Размер кучи по поколениям после последней сборки мусора |
dotnet.gc.last_collection.memory.committed_size |
Объём памяти, закоммиченной сборщиком мусора |
dotnet.gc.heap.total_allocated |
Накопленный объём выделений с момента запуска |
dotnet.gc.collections |
Число сборок мусора по поколениям |
dotnet.gc.pause.time |
Накопленное время простоя из-за GC |
Можно сразу сузить наблюдение до нужных счётчиков.
dotnet-counters monitor \
--process-id <PID> \
--refresh-interval 3 \
--counters System.Runtime[dotnet.process.memory.working_set,dotnet.gc.last_collection.heap.size,dotnet.gc.last_collection.memory.committed_size,dotnet.gc.heap.total_allocated,dotnet.gc.collections]
Если понадобится вернуться к данным позже, сохраните их в CSV.
dotnet-counters collect \
--process-id <PID> \
--refresh-interval 5 \
--format csv \
--output counters.csv \
--counters System.Runtime
На этом этапе интересно различить следующие ситуации.
6.1 Растёт только Total Allocated
dotnet.gc.heap.total_allocated — накопленное значение. Если приложение обрабатывает запросы, оно выделяет объекты, и даже если выделенные объекты сразу становятся ненужными и собираются сборщиком мусора, накопленный объём выделений всё равно растёт.
Поэтому сам по себе рост Total Allocated ещё не говорит об утечке памяти. Важно смотреть, остаются ли объекты после выделения.
Total Allocated: растёт
GC Heap Size: колеблется, но в целом стабилен
Gen 2 / LOH: не растут непрерывно
В этом случае речь не об утечке, а о приложении с большим объёмом выделений.
Решение здесь не в исправлении утечки, а в сокращении выделений: переиспользование буферов, пересмотр интенсивного использования LINQ, сокращение создания строк, пересмотр логики сериализации и так далее.
6.2 Working Set растёт, а GC Heap стабилен
Бывает, что Working Set или RSS растёт, а GC Heap при этом стабилен. В этом случае речь не обязательно об утечке управляемых объектов.
Возможные причины:
- JIT-скомпилированный код;
- загруженные сборки;
- стеки потоков;
- память нативных библиотек;
- неуправляемая память, например через
Marshal.AllocHGlobal; - нативные буферы обработки изображений, сжатия, шифрования, драйверов БД;
- внутренние буферы сокетов, файловых дескрипторов, SSL, HTTP/2, gRPC;
- ОС просто ещё не освободила физические страницы процесса.
В такой ситуации можно сколько угодно смотреть в dumpheap и не найти виновника.
Ориентир такой:
Working Set / RSS: растёт
GC Heap Size: стабилен
Gen 2 / LOH: стабильны
В этом случае стоит подозревать не утечку управляемой кучи .NET, а нативную память, дескрипторы, число потоков, сокеты и внешние библиотеки.
Не стоит останавливаться только на dotnet-counters — стоит также посмотреть на инструменты ОС, метрики контейнера, число дескрипторов, число потоков, нативную кучу и метрики внешних библиотек.
6.3 GC Heap растёт, но после остановки нагрузки возвращается к норме
Естественно, что GC Heap растёт во время нагрузки.
Много запросов. Много временных объектов. Обработка крупного JSON. Создание временных списков и массивов.
В таких случаях куча растёт до следующей сборки мусора. Когда нагрузка останавливается, происходит сборка мусора, и куча может вернуться к исходному уровню.
Во время нагрузки: GC Heap растёт
После остановки нагрузки: GC Heap снижается или возвращается к устойчивому значению
После повторений: базовый уровень не растёт непрерывно
В этом случае можно сделать вывод: «просто ещё не собрано сборщиком мусора» или «много временных выделений».
Тем не менее, если временных выделений во время нагрузки слишком много, это увеличивает число сборок мусора и время простоя, что становится проблемой производительности. Даже если это не утечка, это всё равно повод для улучшения производительности.
6.4 Gen 2 / LOH после GC продолжают расти
Вот паттерн, на который стоит обратить особое внимание:
Повторяем одну и ту же операцию
↓
Gen 2 растёт
↓
LOH растёт
↓
После остановки нагрузки не возвращается
↓
При следующем измерении растёт ещё больше
Gen 2 — поколение, в котором находятся долгоживущие объекты. LOH — куча, в которой чаще всего оказываются крупные массивы и строки.
Если эти показатели продолжают расти, стоит подозревать утечку, неограниченный кэш, удержание огромных буферов, невыполненную отписку от событий, статические коллекции или удержание объектов долгоживущими сервисами.
На этом этапе переходим к следующему шагу.
7. Как понять, «просто ли это ожидание GC»
Чтобы проверить, «просто ли объект ещё не был собран сборщиком мусора», нужно посмотреть на состояние после того, как у GC было достаточно возможностей отработать.
При этом нельзя беспечно добавлять GC.Collect() в код продакшн-приложения.
GC.Collect() принудительно вызывает сборку мусора. В особенности блокирующая сборка по всем поколениям создаёт простой приложения. При обычной эксплуатации базовое правило — доверять сборку мусора самому GC.
Тем не менее в ходе расследования иногда проверяют в контролируемой тестовой среде, «остаётся ли объект даже после принудительной сборки мусора».
В тестовом консольном приложении или среде воспроизведения такой код позволяет посмотреть на состояние после полной сборки мусора:
static void ForceFullGcForDiagnosticsOnly()
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
Важный момент — никогда не использовать этот код как решение. Он предназначен только для расследования.
Хочется проверить такую последовательность:
До операции
↓
Повторить операцию N раз
↓
Остановить нагрузку
↓
Подождать достаточно долго или спровоцировать полную сборку мусора в тестовой среде
↓
Возвращается ли куча после GC к значению, близкому к тому, что было до операции?
Если возвращается — вероятно, это было ожидание GC или временное выделение. Если не возвращается, и базовый уровень поднимается при каждом повторении одной и той же операции — что-то продолжает существовать. Это «что-то» и нужно искать через дамп.
8. Быстрое сравнение с помощью dotnet-gcdump
Для первого сравнения удобен dotnet-gcdump.
dotnet-gcdump снимает GC dump из работающего процесса .NET и позволяет посмотреть статистику по типам в куче.
dotnet-gcdump collect --process-id <PID> --output before.gcdump
После подачи нагрузки снимаем ещё раз.
dotnet-gcdump collect --process-id <PID> --output after.gcdump
Можно посмотреть простой отчёт прямо в CLI.
dotnet-gcdump report before.gcdump > before-heap.txt
dotnet-gcdump report after.gcdump > after-heap.txt
Смотрим на Count и Size по каждому типу.
Например, если в after сильно выросли такие типы, они становятся объектом расследования:
Size (Bytes) Count Type
============ ===== ====
180,000,000 2,000,000 System.String
120,000,000 1,000,000 MyApp.Models.Customer
90,000,000 25,000 System.Byte[]
Важно не «крупный тип», а «тип, который вырос».
System.String и System.Byte[] во многих приложениях занимают верхние строчки.
Само по себе нахождение наверху ещё не делает тип виновником.
Ориентиры для сравнения:
| before → after | Как читать |
|---|---|
| Count почти не изменился | Скорее всего, этот тип не главный виновник |
| Растут и Count, и Size | Кандидат на подозрение |
Растут типы MyApp.* |
Стоит подозревать удержание на уровне бизнес-логики |
Растёт System.Byte[] |
Стоит подозревать буферы, сериализацию, изображения, сжатие, HTTP, БД |
Растёт System.String |
Стоит подозревать кэши, логи, JSON, ключи словарей, дублирующиеся строки |
Растут Task, Timer, CancellationTokenSource |
Стоит подозревать асинхронную обработку, Timer, невыполненную отмену/отписку |
dotnet-gcdump удобен как отправная точка для сравнения, но при снятии дампа он провоцирует сборку мусора Gen 2. В средах с большой кучей или строгими требованиями к задержке стоит учитывать время простоя и дополнительное потребление памяти.
В Windows файл .gcdump можно открыть и сравнить в Visual Studio или PerfView.
В не-Windows-средах практичнее смотреть статистику по типам через CLI-команду report, а для углублённого разбора источников ссылок переходить к dotnet-dump.
9. Изучаем кучу и источники ссылок через dotnet-dump
Когда «растущие типы» уже видны, следующий шаг — понять, «почему они не собираются».
Для этого снимаем дамп через dotnet-dump и анализируем его командами SOS.
dotnet-dump collect \
--process-id <PID> \
--type Heap \
--output myapp-1.dmp
Через некоторое время снимаем ещё раз.
dotnet-dump collect \
--process-id <PID> \
--type Heap \
--output myapp-2.dmp
Снятие дампа — тяжёлая операция. Особенно дампы типа Full / Heap имеют большой размер и создают нагрузку на процесс и контейнер. При снятии дампа в продакшне нужно учитывать время суток, объём дискового пространства, ограничения памяти контейнера и вероятность попадания в дамп персональных или конфиденциальных данных.
Анализируем полученный дамп.
dotnet-dump analyze myapp-2.dmp
Сначала смотрим общую статистику по куче.
> dumpheap -stat
Вывод — количество и размер по каждому типу.
MT Count TotalSize Class Name
00007f... 120000 3840000 MyApp.Models.Order
00007f... 250000 8000000 System.String
00007f... 10000 40000000 System.Byte[]
Сужаем до конкретного типа.
> dumpheap -stat -type MyApp.Models.Order
Или сужаем по конкретному MethodTable.
> dumpheap -mt <MT>
Когда становится известен адрес экземпляра, изучаем источники ссылок.
> gcroot <OBJECT_ADDRESS>
Это самая важная часть. С помощью gcroot мы выясняем, почему объект остаётся живым.
Допустим, обнаружился такой путь ссылок:
static MyApp.CustomerCache._items
-> System.Collections.Concurrent.ConcurrentDictionary<string, Customer>
-> MyApp.Models.Customer
-> System.String
В этом случае причина, по которой GC не собирает объект, очевидна: Customer удерживается ссылкой из статического кэша, поэтому с точки зрения GC он по-прежнему используется.
Только теперь можно сделать следующие выводы:
- действительно ли этот кэш необходим;
- есть ли у него ограничение по размеру;
- есть ли у него срок действия;
- не устроен ли он так, что ключи растут бесконечно;
- не растёт ли он безгранично из-за использования в качестве ключа тенанта, пользователя, даты, ID запроса и тому подобного.
Главное в расследовании утечки памяти — не останавливаться на dumpheap -stat.
dumpheap -stat показывает, «чего много», а gcroot — «почему оно осталось». Именно второе ведёт к исправлению.
10. Шпаргалка для диагностики
Систематизируем паттерны, часто встречающиеся на практике.
| Наблюдение | Возможная причина | Что смотреть дальше |
|---|---|---|
| Растёт только Total Allocated | Обычные выделения или избыточные выделения | Allocation Rate, число сборок мусора, CPU, dotnet-trace |
| Working Set растёт, а GC Heap стабилен | Нативная память, JIT, стеки, удержание на стороне ОС | Число потоков, число дескрипторов, нативные инструменты, внешние библиотеки |
| GC Heap растёт только во время нагрузки, а после остановки возвращается | Ожидание GC, временные выделения | Gen 2 / LOH после остановки нагрузки, число сборок мусора |
| Gen 2 после GC продолжает расти | Удержание долгоживущих объектов | dumpheap -stat, gcroot |
| LOH продолжает расти | Крупные массивы, буферы, фрагментация, гигантские строки | System.Byte[], System.Char[], LOH, область Free |
Велик System.String |
Кэши строк, JSON, логи, ключи словарей | Найти собственный тип, удерживающий строки |
Велик System.Byte[] |
Буферы, сериализация, изображения, сжатие, сеть | Владеющий тип, невозвращённые буферы ArrayPool, нативное взаимодействие |
Растёт Task |
Незавершающиеся асинхронные операции, очереди ожидания | Ожидание async, отмена, каналы, очереди |
Растёт Timer |
Неосвобождённые Timer | Dispose, снятие регистрации, долгоживущие сервисы |
Растёт CancellationTokenSource |
Неосвобождённые CTS, избыток связанных токенов | Dispose, отвязка, места создания таймаутов |
Остаются EventHandler или делегаты |
Невыполненная отписка от события | Разница в жизненном цикле publisher-а и subscriber-а |
| Растёт Finalization Queue | Невызванный Dispose, затор финализатора |
finalizequeue, поток финализатора |
| Много закреплённых (pinned) handle | Закреплённые буферы, нативное взаимодействие | gchandles, POH, места закрепления |
11. Часто встречающиеся формы утечек
11.1 Статические коллекции
Самая наглядная форма.
public static class CustomerStore
{
private static readonly List<Customer> Customers = new();
public static void Add(Customer customer)
{
Customers.Add(customer);
}
}
В этом коде Customer, добавленный в Customers, остаётся в памяти столько, сколько живёт процесс. Даже если сохранение задумывалось как временное, GC не соберёт ничего, на что ссылается static.
Направление исправления зависит от назначения:
- ввести ограничение по числу элементов;
- задать срок действия;
- использовать механизм кэширования, например
MemoryCache; - явно удалять элементы;
- отказаться от static и перейти на сервис с подходящим временем жизни;
- если цель — персистентность, перенести данные в БД или во внешнее хранилище.
Важно понимать не то, что «static — это плохо», а то, что всё, помещённое в static, становится долгоживущим, и использовать это осознанно.
11.2 Неограниченный кэш
Кэш намеренно расходует память, поэтому рост по замыслу — это не утечка. Но кэш без ограничения размера и срока действия фактически становится утечкой памяти.
public sealed class ReportCache
{
private readonly Dictionary<string, Report> _cache = new();
public Report GetOrCreate(string userId, DateTime date)
{
var key = $"{userId}:{date:O}";
if (_cache.TryGetValue(key, out var report))
{
return report;
}
report = BuildReport(userId, date);
_cache[key] = report;
return report;
}
}
В этом примере, если количество комбинаций userId и date продолжает расти, кэш будет расти вместе с ними.
Особенно опасны случаи, когда в ключ входят такие значения:
- ID запроса;
- текущее время;
- GUID;
- ID сессии;
- строка, полученная из пользовательского ввода без нормализации;
- SQL-запрос или условие поиска, приведённые к строке как есть.
Для любого кэша заранее определяют такие условия:
| Условие | Пример |
|---|---|
| Максимальное число элементов | До 10 000 записей |
| Максимальный размер | До 256 МБ |
| Срок действия | 30 минут с момента последнего обращения |
| Абсолютный срок | 6 часов с момента создания |
| Условия удаления | Удаление тенанта, удаление пользователя, изменение настроек |
| Что мониторить | Число записей, оценочный размер, коэффициент попаданий, число вытеснений |
Правило не «кэшу можно расти», а «нужно заранее решить, до какого предела ему можно расти».
11.3 Невыполненная отписка от события
Событие превращается в утечку, когда долгоживущий publisher продолжает удерживать ссылку на короткоживущего subscriber-а.
public sealed class OrderViewModel
{
private readonly OrderService _service;
public OrderViewModel(OrderService service)
{
_service = service;
_service.OrderChanged += OnOrderChanged;
}
private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
{
// update view model
}
}
Если OrderService — singleton, а OrderViewModel создаётся заново для каждого экрана, событие OrderService продолжает удерживать ссылку на OrderViewModel. Даже после закрытия экрана ViewModel не освобождается, пока не выполнена отписка.
Пример исправления:
public sealed class OrderViewModel : IDisposable
{
private readonly OrderService _service;
public OrderViewModel(OrderService service)
{
_service = service;
_service.OrderChanged += OnOrderChanged;
}
public void Dispose()
{
_service.OrderChanged -= OnOrderChanged;
}
private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
{
// update view model
}
}
В gcroot такая ситуация может проявляться как ссылка через делегат или обработчик события.
Этот паттерн часто встречается в WPF, WinForms, долгоживущих сервисах, брокерах сообщений и агрегаторах событий.
11.4 Неосвобождённый Timer
System.Threading.Timer, PeriodicTimer, а также подписки Reactive Extensions тоже остаются в памяти, если их не освободить.
public sealed class PollingWorker
{
private readonly Timer _timer;
public PollingWorker()
{
_timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
}
private void Poll()
{
// polling
}
}
Если PollingWorker задуман как временный объект, необходим дизайн, при котором Timer освобождается.
public sealed class PollingWorker : IDisposable
{
private readonly Timer _timer;
public PollingWorker()
{
_timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
}
public void Dispose()
{
_timer.Dispose();
}
private void Poll()
{
// polling
}
}
Timer хранит делегат обратного вызова, и от него цепочка ссылок может тянуться до целевого объекта.
11.5 Неосвобождённый IDisposable
Неосвобождённый IDisposable не всегда проявляется как утечка управляемой кучи.
Он может проявиться как проблема с ресурсами: файлами, сокетами, соединениями с БД, нативными дескрипторами, буферами.
public async Task<string> ReadAsync(string path)
{
var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
В этом примере StreamReader закрывает stream, поэтому часто это не становится большой проблемой — но в коде, где владение ресурсом не определено явно, утечки случаются.
Базовое правило — сделать владение явным через using / await using.
public async Task<string> ReadAsync(string path)
{
await using var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
Неосвобождённый Dispose проявляется такими симптомами:
- растёт число дескрипторов;
- накапливаются сокеты;
- файлы не закрываются;
- растёт нативная память;
- растёт Finalization Queue;
- GC Heap стабилен, а память процесса растёт.
В этом случае одного dumpheap недостаточно.
Нужно смотреть также на дескрипторы ОС, сокеты и состояние внешних библиотек.
11.6 AsyncLocal и удержание контекста
AsyncLocal<T> удобен, но если в него помещать что-то крупное, оно может долго оставаться в памяти.
Небольшое значение вроде correlation ID для логов редко становится проблемой. Но если в него помещать информацию о пользователе, тело запроса, крупные DTO или контекст БД, это приводит к непреднамеренному удержанию.
public static class RequestContext
{
public static readonly AsyncLocal<RequestInfo?> Current = new();
}
Поскольку AsyncLocal следует за асинхронным потоком выполнения, его сложнее обнаружить, чем обычное статическое поле.
Стоит хранить в нём небольшие, чётко определённые значения и предусмотреть сброс в null, когда значение больше не нужно.
11.7 Ошибка в жизненном цикле DI
В DI-контейнерах, например в ASP.NET Core, singleton, scoped и transient имеют разное время жизни.
Если долгоживущий singleton хранит данные конкретного запроса, объекты могут оставаться в памяти даже после завершения запроса.
public sealed class AuditBuffer
{
private readonly List<RequestAudit> _items = new();
public void Add(RequestAudit item)
{
_items.Add(item);
}
}
Если это singleton, _items живёт столько же, сколько само приложение.
Если буферизация — часть дизайна, необходимы ограничение, отправка, удаление и обратное давление (backpressure). Если это делается просто «на всякий случай, вдруг понадобится позже», данные стоит выводить в лог или во внешнее хранилище.
12. LOH особенно легко понять неправильно
LOH расшифровывается как Large Object Heap. В .NET крупные объекты размещаются в отдельной куче, отличной от обычных небольших объектов. Типичный пример — крупный массив.
var buffer = new byte[1024 * 1024 * 10]; // 10MB
С LOH часто связаны три проблемы:
- частое создание крупных объектов;
- долгое удержание крупных объектов;
- фрагментация из-за создания и уничтожения крупных объектов.
Сам по себе рост LOH ещё не означает утечку. При дизайне с переиспользованием крупных буферов размер может расти до определённого предела, а затем стабилизироваться, и даже после сборки мусора Working Set не обязательно сразу снижается.
Тем не менее подозрение должны вызывать такие состояния:
System.Byte[]растёт при каждой операции;- растут
System.Char[]или гигантскиеString; - память не возвращается после обработки изображений, PDF, Excel, ZIP, шифрования, сжатия;
- массивы, полученные через
ArrayPool<T>.Rent, не возвращаются обратно; - целиком в память загружаются крупные ответы;
- активно используется
MemoryStream.ToArray().
При использовании ArrayPool<T> всегда возвращайте взятое.
var pool = ArrayPool<byte>.Shared;
var buffer = pool.Rent(1024 * 1024);
try
{
// use buffer
}
finally
{
pool.Return(buffer);
}
Однако возврат в пул не гарантирует, что память процесса сразу снизится. Пул может удерживать память для дальнейшего переиспользования.
И здесь важно смотреть на то, продолжает ли объём расти, есть ли ограничение и действительно ли память переиспользуется.
13. Как читать gcroot
gcroot показывает, откуда идёт ссылка на объект.
Сведём типичные корни в таблицу.
| Корень | Значение |
|---|---|
| static field | Ссылка идёт из статического поля типа |
| local variable / stack | Ссылка идёт из стека выполняющегося потока |
| GC handle | Ссылка идёт через GCHandle, закрепление (pin), делегат, взаимодействие с нативным кодом |
| finalization queue | Объект удерживается в ожидании финализации |
| thread / async state machine | Объект удерживается выполняющейся или ожидающей асинхронной операцией |
При расследовании стоит в первую очередь смотреть на разрыв во времени жизни.
Долгоживущий объект
-> объект, который должен быть короткоживущим
Если видна такая форма — это кандидат на утечку.
Например, подозрительно выглядит вот это:
SingletonService
-> List<RequestContext>
-> RequestContext
-> LargeDto
SingletonService живёт всё время работы приложения.
Если внутри него накапливаются объекты RequestContext, относящиеся к отдельным запросам, дизайн нужно пересмотреть.
С другой стороны, вот такой путь ссылок в зависимости от момента снятия дампа может быть совершенно нормальным:
Thread stack
-> Controller action local variable
-> RequestDto
Пока запрос обрабатывается, вполне естественно, что локальная переменная ещё жива.
Именно поэтому время снятия дампа имеет значение.
Снимайте дампы не только во время нагрузки, но и после остановки нагрузки, после опустошения очередей и после периода простоя — так гораздо легче сделать вывод.
14. «Память снизилась после принудительного GC» ещё не значит «проблема решена»
Во время расследования вы вызвали GC.Collect(), и память снизилась. В этот момент опасно решить: «раз так, будем периодически вызывать GC.Collect()».
Принудительная сборка мусора не устраняет первопричину. Она лишь на месте собирает те объекты, которые ещё не были собраны.
Если проблема в высокой интенсивности выделений, принудительная сборка мусора только увеличивает время простоя и ухудшает производительность. Если это настоящая утечка, объекты с сохранившимися ссылками не будут собраны даже принудительной сборкой мусора.
При расследовании нужно смотреть на такое различие:
| После принудительной сборки мусора | Вывод |
|---|---|
| Резко снизилась, затем базовый уровень стабилен | Основная причина — ожидание GC или временные выделения |
| Немного снизилась, но при повторениях минимум поднимается | Часть объектов выживает. Кандидат на утечку |
| Почти не снизилась | Объект продолжает удерживаться ссылкой, либо основная причина не в куче GC |
| GC Heap снизился, а Working Set — нет | Возможно удержание на уровне ОС / сегментов GC / нативной стороны |
Прежде чем вносить периодический вызов GC.Collect() в продакшн, обязательно нужно выяснить, что именно растёт.
15. Практическая процедура расследования
Дальше сведём всё в реальную последовательность действий для расследования.
15.1 Зафиксировать сценарий воспроизведения
Сначала зафиксируем условия расследования.
Объект: /api/report/export
Операция: 100 выполнений при одинаковых условиях
Интервал измерения: 5 секунд
Время наблюдения: 5 минут прогрева + 10 минут нагрузки + 5 минут простоя
Среда: staging / сборка Release / настройки, приближённые к production
При исследовании памяти нельзя сделать вывод, если каждый раз выполнять разные операции. Нужно зафиксировать, «что именно привело к росту».
15.2 Снять базовый уровень
Базовым уровнем стоит брать состояние после прогрева, а не сразу после запуска.
Причина в том, что сразу после запуска происходит разовый рост из-за таких факторов:
- JIT;
- построение DI-контейнера;
- загрузка конфигурации;
- первое подключение к БД;
- первое TLS- / HTTP-соединение;
- генерация метаданных JSON-сериализатора;
- инициализация Razor / шаблонов;
- инициализация логгера и метрик.
Порядок такой:
1. Запустить приложение
2. Несколько раз обратиться к health-check или к типичным API
3. Подождать 1-5 минут
4. Снять counters и dump в качестве базового уровня
15.3 Собрать counters во время нагрузки
dotnet-counters collect \
--process-id <PID> \
--refresh-interval 5 \
--format csv \
--output report-export-counters.csv \
--counters System.Runtime
Параллельно выполняем операцию для воспроизведения. Важна форма графика.
Форма, близкая к норме:
растёт во время нагрузки
колеблется вверх-вниз из-за GC
возвращается к норме после остановки нагрузки
базовый уровень не растёт непрерывно
Подозрительная форма:
растёт пропорционально числу операций
минимум Gen 2 / LOH поднимается
не возвращается к норме после остановки нагрузки
минимум поднимается ещё выше при следующей нагрузке
15.4 Снять дамп дважды
Снимаем дампы до и после нагрузки.
dotnet-dump collect --process-id <PID> --type Heap --output before.dmp
# подать нагрузку
dotnet-dump collect --process-id <PID> --type Heap --output after.dmp
При наличии запаса времени снимаем дамп ещё и после остановки нагрузки.
# после остановки нагрузки, когда очереди опустели и прошло какое-то время
dotnet-dump collect --process-id <PID> --type Heap --output idle-after.dmp
При сравнении важны не только before и after, но и idle-after.
Даже если во время нагрузки был рост, возврат к норме после простоя может означать, что это не утечка.
15.5 Смотрим на выросшие типы
dotnet-dump analyze after.dmp
> dumpheap -stat
Точно так же смотрим сторону before.
Можно делать это вручную — начните со сравнения самых крупных типов.
Обратите внимание на такие моменты:
- растут ли типы из собственного пространства имён;
- не скрывается ли за
System.Stringсобственный тип; - кто владеет
System.Byte[]; - не растут ли
List<T>илиDictionary<TKey,TValue>; - не растут ли
Taskили машины состояний async; - не растут ли
TimerилиCancellationTokenSource.
15.6 Смотрим источники ссылок
Берём адреса объектов-кандидатов и выполняем gcroot.
> dumpheap -type MyApp.Models.ReportResult
> gcroot <OBJECT_ADDRESS>
По результату gcroot ищем удерживающего родителя.
MyApp.Services.ReportCache
-> Dictionary<string, ReportResult>
-> ReportResult
На этом этапе становится видно, что стоит проверить при код-ревью:
- является ли
ReportCachesingleton-ом; - есть ли у него ограничение;
- удаляются ли из него элементы;
- продолжают ли расти ключи;
- не слишком ли велик
ReportResult; - не стоит ли перенести данные из кэша в БД или в файлы.
16. Когда использовать dotnet-trace
dotnet-dump — это моментальный снимок, удобный для того, чтобы увидеть, «что осталось в итоге». А когда нужно увидеть, «когда и где происходят массовые выделения», используют dotnet-trace.
Например, трассировка с включением событий GC:
dotnet-trace collect \
--process-id <PID> \
--duration 00:00:01:00 \
--clrevents gc+gchandle \
--clreventlevel informational \
--output gc-trace.nettrace
Если нужно ещё и сэмплирование выделений, объём событий увеличивается, поэтому в тестовой среде стоит начинать с коротких интервалов.
dotnet-trace collect \
--process-id <PID> \
--duration 00:00:00:30 \
--clrevents gc+gcsampledobjectallocationhigh \
--clreventlevel informational \
--output allocation-trace.nettrace
Трассировка полезна с другого ракурса, чем дамп.
| Что нужно увидеть | Подходящий инструмент |
|---|---|
| Что осталось | dump / gcdump |
| Кто на это ссылается | dump + gcroot |
| Когда произошли массовые выделения | trace |
| Когда происходили сборки мусора | counters / trace |
| Является ли проблемой время простоя | counters / trace |
При расследовании утечки эффективно сначала посмотреть через dump, «что осталось», а затем, при необходимости, через trace — «где это создаётся».
17. Вывод диагностических метрик прямо в коде
Полноценную диагностику стоит проводить внешними инструментами, но полезно также встроить в приложение простое диагностическое логирование.
Например, можно выводить информацию о GC через административный эндпоинт или периодический лог.
public static class GcDiagnostics
{
public static object Snapshot()
{
var info = GC.GetGCMemoryInfo();
return new
{
TotalMemory = GC.GetTotalMemory(forceFullCollection: false),
HeapSizeBytes = info.HeapSizeBytes,
FragmentedBytes = info.FragmentedBytes,
MemoryLoadBytes = info.MemoryLoadBytes,
HighMemoryLoadThresholdBytes = info.HighMemoryLoadThresholdBytes,
Gen0Collections = GC.CollectionCount(0),
Gen1Collections = GC.CollectionCount(1),
Gen2Collections = GC.CollectionCount(2)
};
}
}
Одной этой информации недостаточно, чтобы установить утечку. Но во время инцидента она облегчает следующие выводы:
- резко ли растёт Gen 2;
- растёт ли HeapSize;
- растёт ли FragmentedBytes;
- велика ли разница между TotalMemory и памятью процесса;
- изменилась ли тенденция после развёртывания.
Если добавляете это в логи приложения, следите за объёмом. Слишком частая тяжёлая диагностика сама становится нагрузкой.
18. Критерий для того, чтобы утверждать «это утечка памяти»
В конце расследования нужно уметь описать ситуацию в такой форме:
Симптом:
После 100 выполнений /api/report/export GC Heap остаётся на 300 МБ выше даже после остановки нагрузки.
Наблюдение:
По данным dotnet-counters, размер кучи Gen 2 рос пропорционально числу операций.
Рос не только Working Set, но и GC Heap.
Сравнение:
При сравнении before.dmp и after.dmp число экземпляров MyApp.Models.ReportResult выросло на 12 000.
Источник ссылок:
gcroot показал ссылку из MyApp.Services.ReportCache._items.
Причина:
ReportCache был singleton-ом с ключом «ID пользователя + текущее время», без удаления, срока действия и ограничения.
Исправление:
Заменили на MemoryCache, настроили ограничение размера и срок действия.
Число записей кэша вывели в метрику.
Если удаётся описать ситуацию именно так, отчёт перестаёт быть просто «память растёт» — он связывает условия воспроизведения, наблюдения, выросший тип, источник ссылок, причину и исправление.
19. Что важно учитывать при расследовании
19.1 Смотреть в сборке Release
Debug-сборка может выглядеть иначе, чем в реальной эксплуатации, из-за оптимизаций, времени жизни локальных переменных и отладочной информации.
Для расследования, приближённого к production, проверяйте на сборке Release, с настройками, близкими к эксплуатационным, и на сопоставимом объёме данных.
19.2 Не делать вывод только по состоянию сразу после запуска
Сразу после запуска память растёт из-за разной инициализации.
Снимите базовый уровень после прогрева и смотрите на рост от него.
19.3 Не обвинять по одному дампу
Тип, находящийся наверху кучи, не обязательно виновник.
System.String и System.Byte[] выглядят крупными во многих приложениях.
Важно, вырос ли тип со временем и кто им владеет.
19.4 В дампах может быть конфиденциальная информация
В дампах памяти могут содержаться данные запросов, учётные данные, строки подключения, персональные данные и бизнес-данные.
Определите правила хранения, выноса за пределы организации, обмена и удаления.
19.5 В контейнерах снятие дампа само по себе становится риском
При жёстких ограничениях памяти контейнера дополнительная память и подкачка страниц во время снятия дампа могут привести к OOM Kill.
Прежде чем снимать дамп в production-контейнере, попробуйте это в staging и проверьте ограничения, объём диска, права доступа и пространство имён PID.
19.6 Утечки бывают и за пределами GC Heap
Расследование в .NET не означает, что всё обязательно проявляется в куче GC.
При таких проблемах память процесса может расти, даже если GC Heap стабилен:
- нативные библиотеки;
- P/Invoke;
- COM;
- обработка изображений;
- библиотеки сжатия;
- шифрование;
- драйверы БД;
- сокеты;
Marshal.AllocHGlobal;NativeMemory.Alloc;- избыточное число потоков.
В этом случае одного dumpheap из dotnet-dump недостаточно.
Нужна диагностика со стороны ОС, метрики внешних библиотек, дескрипторы, потоки, нативная память.
20. Проверка после исправления
После исправления вероятной утечки повторите измерение по той же процедуре.
До исправления:
После 100 выполнений Gen 2 вырос на +300 МБ
ReportResult вырос на +12 000 экземпляров
После исправления:
После 100 выполнений Gen 2 стабилен в пределах +20 МБ
ReportResult после остановки нагрузки возвращается к базовому уровню
Число записей кэша стабильно держится на ограничении в 1 000 записей
При проверке исправления обязательно сравнивайте при одинаковых условиях:
- тот же объём данных;
- то же число повторений;
- та же длительность нагрузки;
- тот же прогрев;
- тот же интервал наблюдения;
- те же инструменты.
Расследование памяти не убедительно, если сравнение «до и после» слабое.
21. Итоги
Когда в .NET растёт память, не стоит сразу считать это утечкой — разбирайтесь в такой последовательности:
- Не делайте вывод только по Working Set / RSS.
- С помощью
dotnet-countersсмотрите на GC Heap, Gen 2, LOH и число сборок мусора. - Сравнивайте показатели во время нагрузки, после остановки нагрузки и во времени.
- С помощью
dotnet-gcdumpилиdotnet-dumpсмотрите на выросшие типы. - С помощью
gcrootсмотрите на источники ссылок. - Проверяйте static, кэши, события, Timer, времена жизни DI, асинхронные контексты.
- Если GC Heap стабилен, подозревайте также нативную память и проблемы на стороне ОС.
Разница между «просто ещё не собрано сборщиком мусора» и «действительно утекает» в конечном счёте определяется ссылками.
Если на ставший ненужным объект нет ссылок, он будет собран, когда сработает GC. Если на объект, который должен быть ненужным, продолжают ссылаться, GC не может его собрать.
Иными словами, цель расследования такова:
Что именно растёт?
Что остаётся живым после каждой сборки мусора?
Кто на это ссылается?
Действительно ли эта ссылка нужна по дизайну?
Когда это станет ясно, вы перестанете зависеть от графиков памяти и сможете превратить проблему в конкретные исправления в коде.
Справочные материалы
- Полный набор примеров кода к этой статье (библиотека, демонстрация, модульные тесты) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/dotnet-gc-or-memory-leak
- .NET: Fundamentals of garbage collection
- .NET: Debug a memory leak
- .NET CLI: dotnet-counters diagnostic tool
- .NET CLI: dotnet-dump diagnostic tool
- .NET CLI: dotnet-gcdump diagnostic tool
- .NET CLI: dotnet-trace diagnostic tool
- .NET: Induced collections
- .NET: Large object heap
- .NET: Workstation and server garbage collection
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Что такое PDB (Program Database) — разбираемся в отладочной информации, символах и Source Link
Разбираем, что такое PDB (Program Database), с практической точки зрения: что в нём есть и чего нет, разницу между Debug и Release, Porta...
Дата, время и часовые пояса в бизнес-приложениях — от ловушек DateTime до принципа хранения в UTC и проектирования тестов
Перенос сервера сдвигает время на 9 часов — разбираем причины подобных сбоев начиная со свойства Kind у DateTime и неявных преобразований...
SQLite в бизнес-приложениях на C# — режим WAL, эксклюзивная блокировка, защита от повреждений и выбор между EF Core и голым ADO.NET
Разбираем практические знания по внедрению SQLite в бизнес-приложение через Microsoft.Data.Sqlite: режим WAL, SQLITE_BUSY и консолидацию ...
Как создавать и эксплуатировать службы Windows — от выбора между планировщиком заданий и службами до превращения BackgroundService в службу Windows
Разбираем, стоит ли превращать резидентную обработку в службу Windows или достаточно планировщика заданий: таблица решений, создание служ...
Как правильно работать с токенами олицетворения в Windows — заимствование прав на уровне потока и безопасный откат
Разбираем токены олицетворения в Windows — токены доступа, первичные и потоковые токены, уровни олицетворения, RevertToSelf и WindowsIden...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Бизнес-приложения, интеграция оборудования и средства связи — от требований до разработки.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Означает ли рост потребления памяти в приложении на .NET, что это утечка памяти?
- Рост памяти процесса и утечка памяти — не одно и то же. В .NET сборщик мусора работает исходя из объёма выделений и пороговых значений кучи, поэтому иногда объекты, ставшие ненужными, просто ещё не собраны сборщиком мусора, а иногда ОС не сразу возвращает память даже после сборки. Смотреть нужно на три вещи: растёт ли объём памяти, который выживает после сборки мусора; какие типы объектов растут; и кто именно продолжает ссылаться на эти объекты.
- Какими инструментами лучше пользоваться для расследования утечек памяти в .NET?
- Сначала с помощью dotnet-counters смотрят на тенденции Working Set, GC Heap, Gen 2/LOH и числа сборок мусора. Затем с помощью dotnet-gcdump снимают GC dump до и после нагрузки и сравнивают Count и Size по типам, чтобы определить, какие типы выросли. Наконец, с помощью dotnet-dump снимают дамп кучи и через dumpheap -stat и gcroot доходят до источника ссылок, объясняющего, «почему объект не собирается». Важно не полагаться на единичное значение, а сравнивать показатели во времени при одинаковых условиях.
- Какие паттерны утечек памяти чаще всего встречаются в .NET?
- Типичные примеры: бесконтрольное добавление элементов в статические коллекции, кэши без ограничения размера и срока действия, невыполненная отписка от события у долгоживущего publisher-а, недостаточное освобождение Timer, невызванный Dispose у IDisposable-объектов, а также ошибки в жизненном цикле DI, когда singleton хранит данные конкретного запроса. Утечки в .NET удобнее понимать не как «забыли освободить», а как «непреднамеренное удержание» — объект больше не нужен, но на него продолжают ссылаться.
- Решает ли проблему периодический вызов GC.Collect()?
- Нет, не решает. Принудительная сборка мусора лишь собирает на месте те объекты, которые ещё не были собраны, но не устраняет первопричину. Если проблема в высокой интенсивности выделений, принудительная сборка мусора только увеличивает время простоя и ухудшает производительность, а если это настоящая утечка, объекты, на которые остаются ссылки, не будут собраны даже принудительной сборкой мусора. В контролируемой тестовой среде иногда проверяют, «остаётся ли объект после принудительной сборки мусора», — но прежде чем вносить это как решение в продакшн, обязательно нужно выяснить, что именно растёт.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.
Публичные ссылки