Утечки памяти в .NET и как их искать
↑ 2.2 .NET runtime: CLR, память, GC · ← Предыдущая · → Следующая
Зачем это на собесе
Память сервиса растёт и в конце концов приходит OOMKilled. «Как вы искали утечку?» — вопрос на практический опыт. Ждут знания типовых причин (события, статика, кэши, замыкания, неосвобождённые ресурсы) и инструментов (dotnet-counters, dotnet-gcdump, dotnet-dump, профайлеры).
Объяснение
Что такое «утечка» в управляемой среде
GC освобождает объекты, до которых нельзя дойти от корней. Утечка в .NET — это когда объект остаётся достижимым (кто-то держит на него ссылку), хотя логически он больше не нужен. Плюс утечки неуправляемых ресурсов (дескрипторы, нативная память), которые GC не видит.
Типичные причины
- События без отписки. Долгоживущий источник держит делегат на короткоживущего подписчика.
_settings.Changed += OnChanged; // singleton держит this; без -= объект не соберётся- Статические поля и синглтоны с накоплением.
static List<T>,static Dictionary, кэш без ограничения размера и срока жизни.
private static readonly Dictionary<Guid, Report> Cache = new(); // растёт бесконечно- Замыкания и захват
this. Лямбда в долгоживущем объекте удерживает весь контекст. - Неосвобождённые
IDisposable. Соединения, потоки,HttpResponseMessage,CancellationTokenSource, таймеры. - Таймеры и фоновые задачи, которые не остановлены и держат ссылки.
HttpClientв цикле (new HttpClient()на запрос): сокеты и хэндлеры накапливаются.- Scoped-сервис, внедрённый в Singleton (captive dependency): scope-объект живёт вечно.
- Большие объекты в LOH и фрагментация (не совсем утечка, но выглядит так).
- Нативные утечки: P/Invoke, неуправляемая память,
Marshal.AllocHGlobalбезFree, библиотеки с нативными кэшами. - ThreadStatic/AsyncLocal данные, переживающие запрос.
Признаки
- Память растёт линейно при постоянной нагрузке и не возвращается после её снижения.
- Растёт
gen2 sizeи количество Gen 2 сборок,time in GC. - Контейнер перезапускается (OOMKilled, код 137).
- В Kubernetes: рост
container_memory_working_set_bytesбез плато.
Как искать
- Метрики.
dotnet-counters monitor -p <pid> System.Runtime(gc-heap-size, gen-2-size, loh-size, alloc-rate, exception count). - Снимки кучи. Сделать два-три снимка с интервалом и сравнить.
dotnet-gcdump collect -p <pid> -o before.gcdump
# нагрузка, ожидание
dotnet-gcdump collect -p <pid> -o after.gcdump
# открыть в Visual Studio / PerfView, сравнить: какие типы растут- Полный дамп для анализа корней:
dotnet-dump collect -p <pid>, затемdotnet-dump analyze(dumpheap -stat,gcroot <адрес>покажет цепочку ссылок от корня до объекта). - Профайлеры: dotMemory, Visual Studio Diagnostic Tools, PerfView, Rider.
- Локальное воспроизведение: нагрузочный тест (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по размеру и проверить, что память выходит на плато.
Вопросы с ответами
Как может быть утечка памяти, если в .NET есть GC?
GC освобождает только недостижимые объекты. Если на объект остаётся ссылка (событие, статическое поле, кэш, замыкание), он не будет собран. Кроме того, GC не управляет неуправляемыми ресурсами.
Назовите частые причины утечек.
Подписки на события без отписки, статические коллекции и неограниченные кэши, неосвобождённые IDisposable, захват
thisв долгоживущих лямбдах, scoped-сервисы в синглтонах, нативные ресурсы.
Как вы будете искать утечку в продакшене?
Смотрю метрики (
dotnet-counters, рост gen2/LOH), снимаю дваdotnet-gcdumpс нагрузкой между ними и сравниваю типы, при необходимости снимаю полный дамп и черезgcrootнахожу корень, который держит объекты.
Что показывает
gcroot?Цепочку ссылок от корня GC (статическое поле, стек, handle) до конкретного объекта — то есть почему объект не собирается.
Как отличить утечку от нормального поведения GC?
После снижения нагрузки и полной сборки размер управляемой кучи должен падать. Если он остаётся высоким и растёт от цикла к циклу — утечка. Рост RSS при стабильной куче может быть нативной утечкой или поведением Server GC.
Связанные темы
- Предыдущая: IDisposable, using, финализаторы, IAsyncDisposable · Следующая: Пулы объектов: ArrayPool, ObjectPool, RecyclableMemoryStream
- События и Observer: [[be-2-1-2-sobytiya-i-pattern-observer|События и паттерн Observer в C#]]
- Сборщик мусора: Сборщик мусора: поколения, LOH, режимы GC
- Время жизни в DI: Время жизни: Singleton, Scoped, Transient
- Профилирование: Профилирование: dotnet-counters, dotnet-trace, dotnet-dump, PerfView