Утечки памяти в .NET и как их искать

↑ 2.2 .NET runtime: CLR, память, GC · ← Предыдущая · → Следующая

Обязательно~5 мин чтенияУровень: middle

Зачем это на собесе

Память сервиса растёт и в конце концов приходит OOMKilled. «Как вы искали утечку?» — вопрос на практический опыт. Ждут знания типовых причин (события, статика, кэши, замыкания, неосвобождённые ресурсы) и инструментов (dotnet-counters, dotnet-gcdump, dotnet-dump, профайлеры).

Объяснение

Что такое «утечка» в управляемой среде

GC освобождает объекты, до которых нельзя дойти от корней. Утечка в .NET — это когда объект остаётся достижимым (кто-то держит на него ссылку), хотя логически он больше не нужен. Плюс утечки неуправляемых ресурсов (дескрипторы, нативная память), которые GC не видит.

Типичные причины

  1. События без отписки. Долгоживущий источник держит делегат на короткоживущего подписчика.
_settings.Changed += OnChanged;      // singleton держит this; без -= объект не соберётся
  1. Статические поля и синглтоны с накоплением. static List<T>, static Dictionary, кэш без ограничения размера и срока жизни.
private static readonly Dictionary<Guid, Report> Cache = new();   // растёт бесконечно
  1. Замыкания и захват this. Лямбда в долгоживущем объекте удерживает весь контекст.
  2. Неосвобождённые IDisposable. Соединения, потоки, HttpResponseMessage, CancellationTokenSource, таймеры.
  3. Таймеры и фоновые задачи, которые не остановлены и держат ссылки.
  4. HttpClient в цикле (new HttpClient() на запрос): сокеты и хэндлеры накапливаются.
  5. Scoped-сервис, внедрённый в Singleton (captive dependency): scope-объект живёт вечно.
  6. Большие объекты в LOH и фрагментация (не совсем утечка, но выглядит так).
  7. Нативные утечки: P/Invoke, неуправляемая память, Marshal.AllocHGlobal без Free, библиотеки с нативными кэшами.
  8. ThreadStatic/AsyncLocal данные, переживающие запрос.

Признаки

  • Память растёт линейно при постоянной нагрузке и не возвращается после её снижения.
  • Растёт gen2 size и количество Gen 2 сборок, time in GC.
  • Контейнер перезапускается (OOMKilled, код 137).
  • В Kubernetes: рост container_memory_working_set_bytes без плато.

Как искать

  1. Метрики. dotnet-counters monitor -p <pid> System.Runtime (gc-heap-size, gen-2-size, loh-size, alloc-rate, exception count).
  2. Снимки кучи. Сделать два-три снимка с интервалом и сравнить.
dotnet-gcdump collect -p <pid> -o before.gcdump
# нагрузка, ожидание
dotnet-gcdump collect -p <pid> -o after.gcdump
# открыть в Visual Studio / PerfView, сравнить: какие типы растут
  1. Полный дамп для анализа корней: dotnet-dump collect -p <pid>, затем dotnet-dump analyze (dumpheap -stat, gcroot <адрес> покажет цепочку ссылок от корня до объекта).
  2. Профайлеры: dotMemory, Visual Studio Diagnostic Tools, PerfView, Rider.
  3. Локальное воспроизведение: нагрузочный тест (k6, NBomber) + GC.GetTotalMemory(forceFullCollection: true) до/после.
dotnet-dump analyze core_dump
> dumpheap -stat            # какие типы занимают память
> dumpheap -type Patient    # адреса объектов
> gcroot 0x00007f...        # кто держит объект

Как чинить и предотвращать

  • Отписываться от событий (Dispose, -=), использовать слабые события или IProgress<T>/Channels.
  • Ограничивать кэши: MemoryCache с SizeLimit, AbsoluteExpiration; HybridCache.
  • Освобождать ресурсы через using/await using; IHttpClientFactory.
  • Проверять время жизни зависимостей в DI: ValidateScopes/ValidateOnBuild.
  • Не хранить в статике данные, растущие с числом запросов.
  • Метрики памяти и алерты на рост в мониторинге (Grafana), лимиты контейнера.

Нюансы и подводные камни

  • «Память выросла» не всегда утечка: GC не спешит возвращать память ОС, а Server GC резервирует много. Сравнивайте размер кучи после GC.Collect в тестовой среде, а не RSS.
  • Снимок кучи без нагрузки между замерами бесполезен: нужна разница.
  • Утечки в нативных библиотеках не видны в gcdump: смотрите RSS против размера управляемой кучи.
  • Не вызывайте GC.Collect() в проде, чтобы «скрыть» утечку.
  • Тестируйте утечки долго: медленная утечка проявится через часы.

Практика

  • Воспроизвести утечку через статическое событие и найти её в dotnet-gcdump (сравнить два снимка).
  • Показать через gcroot, кто держит подписчика.
  • Ограничить MemoryCache по размеру и проверить, что память выходит на плато.

Вопросы с ответами

Связанные темы