.NET: как отличить ожидание GC от утечки памяти — практические шаги наблюдения, сравнения и доказательства роста памяти

· · .NET, C#, GC, Утечка памяти, Диагностика, dotnet-counters, dotnet-dump, Эксплуатация, Использование существующих ресурсов

1. Что нужно понять в первую очередь

При эксплуатации приложений на .NET иногда наблюдается ситуация, когда потребление памяти медленно, но неуклонно растёт.

Диспетчер задач или top показывает, что память процесса увеличивается. Потребление памяти контейнером тоже растёт. На графике мониторинга Working Set или RSS ползёт вправо-вверх.

Глядя на это, сразу хочется подумать: «не утечка ли памяти?» Но в .NET рост памяти процесса и утечка памяти — не одно и то же.

В .NET есть сборка мусора. Память не возвращается ОС в тот самый момент, когда объект становится ненужным. GC работает, ориентируясь на объём выделений, пороговые значения кучи, давление на память, поколения и характер нагрузки.

Из-за этого возникают такие ситуации:

  • объект, ставший ненужным, ещё не был собран сборщиком мусора;
  • сборка мусора уже прошла, но Working Set процесса не снижается сразу;
  • память вырастает один раз — из-за первого обращения, JIT, кэшей, пула соединений — а затем стабилизируется;
  • управляемая куча стабильна, но растёт неуправляемая память, число потоков, сокетов или память со стороны библиотеки обработки изображений;
  • объект, который действительно должен быть ненужным, продолжает на что-то ссылаться откуда-то ещё.

Эта статья посвящена тому, как распознать последний случай — настоящую утечку. Смотреть нужно не на голое потребление памяти, а на три вещи:

  1. растёт ли объём памяти, который выживает после сборки мусора;
  2. какие именно типы растут;
  3. кто ссылается на эти объекты.

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

  1. частое создание крупных объектов;
  2. долгое удержание крупных объектов;
  3. фрагментация из-за создания и уничтожения крупных объектов.

Сам по себе рост 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

На этом этапе становится видно, что стоит проверить при код-ревью:

  • является ли ReportCache singleton-ом;
  • есть ли у него ограничение;
  • удаляются ли из него элементы;
  • продолжают ли расти ключи;
  • не слишком ли велик 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 растёт память, не стоит сразу считать это утечкой — разбирайтесь в такой последовательности:

  1. Не делайте вывод только по Working Set / RSS.
  2. С помощью dotnet-counters смотрите на GC Heap, Gen 2, LOH и число сборок мусора.
  3. Сравнивайте показатели во время нагрузки, после остановки нагрузки и во времени.
  4. С помощью dotnet-gcdump или dotnet-dump смотрите на выросшие типы.
  5. С помощью gcroot смотрите на источники ссылок.
  6. Проверяйте static, кэши, события, Timer, времена жизни DI, асинхронные контексты.
  7. Если GC Heap стабилен, подозревайте также нативную память и проблемы на стороне ОС.

Разница между «просто ещё не собрано сборщиком мусора» и «действительно утекает» в конечном счёте определяется ссылками.

Если на ставший ненужным объект нет ссылок, он будет собран, когда сработает GC. Если на объект, который должен быть ненужным, продолжают ссылаться, GC не может его собрать.

Иными словами, цель расследования такова:

Что именно растёт?
Что остаётся живым после каждой сборки мусора?
Кто на это ссылается?
Действительно ли эта ссылка нужна по дизайну?

Когда это станет ясно, вы перестанете зависеть от графиков памяти и сможете превратить проблему в конкретные исправления в коде.

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

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

Как создавать и эксплуатировать службы Windows — от выбора между планировщиком заданий и службами до превращения BackgroundService в службу 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

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

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