.NET Tutorial
Middle6 мин чтенияВопрос 3 из 10

Memory leaks в .NET — типичные причины, как находить?

Что такое утечка в управляемом мире

static / Singleton
(GC root)

static Dictionary
_cache

UserSession #1
(давно вышел)

UserSession #2
(давно вышел)

UserSession #3 ...

byte[] 2 МБ

GC видит путь от корня —
собрать нельзя

static / Singleton
(GC root)

static Dictionary
_cache

UserSession #1
(давно вышел)

UserSession #2
(давно вышел)

UserSession #3 ...

byte[] 2 МБ

GC видит путь от корня —
собрать нельзя

Утечка: ненужный объект достижим от корня

GC собирает только недостижимые объекты. Любая «забытая» ссылка из долгоживущего места продлевает жизнь объекту и всему, на что он ссылается.

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

1. События

C#
public sealed class PriceWidget
{
    public PriceWidget(PriceFeed feed) => feed.PriceChanged += OnPriceChanged;   // feed — синглтон
    private void OnPriceChanged(object? s, decimal p) { }
    // нет отписки → каждый созданный виджет живёт вечно
}

Решение: IDisposable + -=, слабые события, IObservable с IDisposable-подпиской.

2. Кэши без ограничений

C#
public sealed class ReportCache                    // Singleton
{
    private readonly ConcurrentDictionary<string, Report> _cache = new();
    public Report Get(string key) => _cache.GetOrAdd(key, Build);   // ключ содержит дату/параметры → бесконечный рост
}

Решение: IMemoryCache с SizeLimit и TTL, HybridCache, LRU-кэш, ограничение числа ключей.

3. Captive dependency

Scoped DbContext внутри синглтона: Change Tracker копит все загруженные сущности всё время работы приложения.

4. Таймеры

C#
public class Poller
{
    private readonly Timer _timer;
    public Poller() => _timer = new Timer(_ => Poll(), null, 0, 1000);   // таймер держит this через делегат
    // без Dispose таймер (и Poller) живут, пока таймер активен
}

5. HttpClient и соединения

C#
// Утечка сокетов/хэндлеров и исчерпание портов (TIME_WAIT)
public async Task<string> Get(string url) { using var client = new HttpClient(); return await client.GetStringAsync(url); }
// Решение: IHttpClientFactory / один статический HttpClient с PooledConnectionLifetime

6. CancellationTokenSource и регистрации

C#
var cts = CancellationTokenSource.CreateLinkedTokenSource(appStoppingToken);   // регистрация в долгоживущем токене
// без cts.Dispose() регистрация остаётся в appStoppingToken навсегда

7. Прочее

  • IOptionsMonitor.OnChange(...) — возвращает IDisposable, который нужно хранить и освобождать.
  • Неограниченные Channel.CreateUnbounded / ConcurrentQueue, когда потребитель медленнее производителя.
  • AsyncLocal<T> с большими объектами, «утекающими» в фоновые задачи через ExecutionContext.
  • Статические списки «для отладки», логгеры-накопители в памяти.
  • Динамическая генерация типов/сборок (Reflection.Emit, XmlSerializer с нестандартным конструктором) без выгрузки.
  • Нативная память: изображения (System.Drawing, SkiaSharp), Marshal.AllocHGlobal, сжатие — видна не в GC Heap, а в RSS процесса.

Как обнаружить

Норма

Пила: рост → сборка →
возврат к базовому уровню

Утечка

Пила растёт вверх:
после каждой Gen 2 память
не возвращается к прежнему уровню

Норма

Пила: рост → сборка →
возврат к базовому уровню

Утечка

Пила растёт вверх:
после каждой Gen 2 память
не возвращается к прежнему уровню

Признаки утечки на графике
  • Метрики: dotnet.gc.heap.total_allocated, GC Heap Size, process.memory.working_set, число Gen 2 сборок; графики за часы/дни.
  • Kubernetes: рестарты с OOMKilled, рост памяти пода до лимита.
  • Нагрузочный тест на длительное время (soak test) с постоянной нагрузкой — память должна стабилизироваться.

Как найти причину

Shell
# 1. Снимок кучи в «нормальном» состоянии
dotnet-gcdump collect -p <pid> -o before.gcdump

# 2. Дать поработать под нагрузкой (утечка должна вырасти)

# 3. Второй снимок
dotnet-gcdump collect -p <pid> -o after.gcdump

# 4. Открыть оба в Visual Studio / PerfView → сравнить (Diff): какие типы выросли по количеству и размеру
dotnet-dump analyze — ручной анализ
> dumpheap -stat
      MT    Count    TotalSize Class Name
...
7f1a2b3c   184 321    58 982 720 Shop.Web.PriceWidget          ← подозрительно много
7f1a2b40   184 321   386 074 112 System.Byte[]

> dumpheap -mt 7f1a2b3c -short | head -3
00007f0c10a2b3c8
> gcroot 00007f0c10a2b3c8
HandleTable:
    ... (static) Shop.Pricing.PriceFeed._instance
    -> Shop.Pricing.PriceFeed
    -> System.EventHandler`1[[System.Decimal]]          ← делегат события
    -> System.Object[]  (invocation list)
    -> Shop.Web.PriceWidget                               ← вот кто держит

Путь до корня (gcroot, «Paths to root» в VS/dotMemory) — главный ответ на вопрос «почему не собирается».

GC Heap

только RSS / native

Память растёт

Растёт GC Heap
или только RSS?

Два gcdump с интервалом → Diff по типам

Выбрать растущий тип → Paths to root / gcroot

Найти «лишнюю» ссылку:
событие, static, кэш, синглтон, таймер

Исправить и повторить soak-тест

Неуправляемая память:
Dispose нативных ресурсов, dotnet-dump + native-инструменты,
фрагментация LOH

GC Heap

только RSS / native

Память растёт

Растёт GC Heap
или только RSS?

Два gcdump с интервалом → Diff по типам

Выбрать растущий тип → Paths to root / gcroot

Найти «лишнюю» ссылку:
событие, static, кэш, синглтон, таймер

Исправить и повторить soak-тест

Неуправляемая память:
Dispose нативных ресурсов, dotnet-dump + native-инструменты,
фрагментация LOH

Алгоритм поиска утечки

Профилактика

  • Всё IDisposable — в using; свои классы с подписками — IDisposable.
  • Кэши — только с ограничением размера и временем жизни.
  • ValidateScopes/ValidateOnBuild — ловить captive dependencies.
  • IHttpClientFactory вместо new HttpClient().
  • Долгие нагрузочные тесты перед релизом, алерты на рост памяти.
  • Код-ревью: подписки, static-коллекции, Timer, CancellationTokenSource.

Вопросы на засыпку

Может ли утечь память, если объект не достижим?

Управляемая — нет, GC её соберёт. Но объект мог держать неуправляемые ресурсы: без Dispose они освободятся только финализатором (если он есть) — позже и не гарантированно быстро. Утечка неуправляемой памяти/дескрипторов возможна при отсутствии финализатора или SafeHandle.

Почему после пика нагрузки процесс не отдаёт память ОС?

Это не обязательно утечка: GC может сохранять выделенные сегменты для будущих аллокаций (особенно Server GC). Показатель утечки — рост живых объектов после полных сборок. В контейнерах помогает GCHeapHardLimit/DATAS, GCConserveMemory.

Чем gcdump отличается от полного дампа?

dotnet-gcdump содержит только граф управляемой кучи (типы, размеры, ссылки) — маленький, безопасный (без содержимого строк и секретов в полном объёме), снимается быстро. Полный дамп (dotnet-dump) содержит всю память процесса — стеки потоков, значения полей, нативную память; большой и может содержать секреты.

Как утечь память через AsyncLocal?

AsyncLocal значения копируются в ExecutionContext всех асинхронных продолжений и задач, запущенных из этого контекста (включая долгоживущие фоновые задачи и таймеры). Большой объект в AsyncLocal может жить, пока жива любая такая задача. Очищайте значения и не запускайте долгоживущие задачи с «грязным» контекстом (ExecutionContext.SuppressFlow).

Итог

Утечка в .NET — забытая ссылка из долгоживущего места (события, static, кэши, синглтоны, таймеры) или неосвобождённый ресурс. Находите её сравнением снимков кучи и путём до корня, предотвращайте дисциплиной Dispose, ограниченными кэшами, проверкой скоупов DI и длительными нагрузочными тестами.