Как диагностировать утечку памяти в управляемой куче с помощью dotnet-gcdump и dotnet-dump
Полный рабочий процесс поиска утечки памяти в управляемой куче .NET 11: подтвердить рост через dotnet-counters, снять два gcdump и сравнить их, затем собрать дамп и с помощью dumpheap, gcroot и objsize в dotnet-dump analyze найти то, что до сих пор удерживает ссылку.
Чтобы диагностировать утечку памяти в управляемой куче .NET, сначала подтвердите реальность роста через dotnet-counters monitor, затем снимите два снимка dotnet-gcdump collect с интервалом в несколько минут, чтобы увидеть, число объектов какого типа растёт, а после этого сделайте dotnet-dump collect и выполните dumpheap -stat, dumpheap -type <Name> и gcroot <address> внутри dotnet-dump analyze, чтобы найти цепочку ссылок, которая удерживает эти объекты живыми. gcdump говорит, что растёт, практически без накладных расходов; дамп говорит, кто это удерживает. Нужны оба, именно в таком порядке. В статье используются dotnet-gcdump и dotnet-dump версии 10.0 на .NET 11 (на момент написания Preview 6, релиз в ноябре 2026), но все приведённые команды стабильны начиная с .NET Core 3.1.
Почему сборка мусора здесь не поможет
Утечка памяти в управляемой куче не является утечкой в смысле C. Ничего не остаётся неосвобождённым. Сборщик мусора работает ровно так, как задумано: он не соберёт объект, достижимый от корня, а ваш код случайно сделал достижимыми несколько сотен тысяч объектов. Корень — это статическое поле, живая локальная переменная или аргумент в стеке какого-то потока, сильный GC-хэндл или очередь финализации. Всё остальное достижимо оттуда транзитивно.
Значит, диагностический вопрос никогда не звучит как “почему не отработала сборка мусора?”. Он звучит как “какая цепочка от корня всё ещё указывает на этот объект?”. Все описанные ниже инструменты существуют ради ответа именно на него. Классические виновники в приложении ASP.NET Core:
- Статическая или singleton-коллекция, которая только растёт:
ConcurrentDictionaryв роли кеша без вытеснения,List<T>с “последними запросами”. - Подписка на событие, которую никогда не отменяют. Издатель удерживает делегат, делегат удерживает подписчика, и если издатель является singleton или статическим полем, каждый подписчик живёт вечно.
- Сервис с областью действия (scoped), захваченный singleton-сервисом, который тянет за собой весь граф объектов этой области. Обычно это сначала проявляется как ObjectDisposedException на уже освобождённом DbContext, потому что такой захват одновременно является ошибкой времени жизни при получении scoped-сервиса из singleton.
Timerили долгоживущая регистрацияCancellationTokenSource, чей обратный вызов замыкает большой граф объектов.
Шаг 0: докажите, что утечка действительно есть
Не собирайте ничего, пока не увидите рост управляемой кучи во времени. Один только рост рабочего набора не является утечкой в управляемой куче: это может быть нативное выделение памяти, фрагментация или просто сборщик мусора, который не возвращает память операционной системе, потому что на него ничего не давит.
Установите инструменты один раз и найдите PID:
# Verified with the .NET 11 SDK, July 2026
dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-gcdump
dotnet tool install --global dotnet-dump
dotnet-counters ps
# 4807 MyApi /srv/myapi/MyApi
Затем наблюдайте за кучей, а не за процессом:
dotnet-counters monitor --refresh-interval 5 --process-id 4807 \
--counters System.Runtime[dotnet.gc.last_collection.heap.size,dotnet.process.memory.working_set]
В .NET 9 и новее System.Runtime является Meter, и имена счётчиков соответствуют показанному выше стилю OpenTelemetry. В .NET 8 и старше dotnet-counters откатывается к прежним EventCounters, и там нужен GC Heap Size (MB).
Значение, которое имеет значение, — это dotnet.gc.last_collection.heap.size в разбивке по поколениям. Два измерения покажут, с чем вы имеете дело:
- gen2 монотонно растёт от сборки к сборке: настоящая утечка в управляемой куче. Объекты доживают до старшего поколения и никогда не умирают. Продолжайте читать эту статью.
- gen0/gen1 активно оборачиваются, gen2 стабилен, рабочий набор большой: это не утечка. Это давление выделения памяти или фрагментация. Вместо этого возьмите dotnet-trace с профилем gc-verbose и найдите горячую точку выделения.
- размер кучи стабилен, а рабочий набор растёт: утечка нативная. gcdump и SOS не покажут ничего полезного. Смотрите на нативное взаимодействие, время жизни
SafeHandleили на LOH, который фиксируется, но не освобождается.
Минимальное воспроизведение с утечкой
Вот наименьший сервис на ASP.NET Core, который течёт так, что оба инструмента способны это найти. Это singleton, который подписывается на событие другого singleton и никогда не отписывается:
// .NET 11, C# 14
public sealed class TelemetryBus
{
public event EventHandler<string>? MetricRecorded;
public void Record(string metric) => MetricRecorded?.Invoke(this, metric);
}
public sealed class ReportSession
{
private readonly byte[] _buffer = new byte[64 * 1024];
private readonly List<string> _log = [];
public ReportSession(TelemetryBus bus)
{
// Nothing ever removes this handler, so `bus` roots every ReportSession
// ever created, and each one roots 64 KB plus a growing List<string>.
bus.MetricRecorded += OnMetric;
}
private void OnMetric(object? sender, string metric) => _log.Add(metric);
}
app.MapPost("/reports", (TelemetryBus bus) =>
{
_ = new ReportSession(bus); // per-request, never released
return Results.Accepted();
});
TelemetryBus — это singleton, поэтому его список вызовов укоренён на всё время жизни процесса. Каждый ReportSession достижим от этого делегата, а значит, достижим и каждый byte[64*1024]. Нагрузите /reports, и куча gen2 будет расти бесконечно.
Полная процедура
- Подтвердите рост управляемой кучи командой
dotnet-counters monitor --counters System.Runtime[dotnet.gc.last_collection.heap.size], глядя именно на gen2. - Снимите базовый gcdump:
dotnet-gcdump collect --process-id <PID> --output baseline.gcdump. - Дайте приложению поработать под нагрузкой достаточно долго, чтобы утечка стала однозначной, обычно от пяти до пятнадцати минут.
- Снимите второй gcdump:
dotnet-gcdump collect --process-id <PID> --output after.gcdump, затем сравните число объектов по типам в обоих файлах и найдите растущий тип. - Соберите полный дамп командой
dotnet-dump collect --process-id <PID> --type Heap --output leak.dmp, когда уже знаете, что ищете. - Откройте его через
dotnet-dump analyze leak.dmpи подтвердите тип с помощьюdumpheap -statилиdumpheap -type <TypeName> -stat. - Возьмите адрес одного экземпляра из
dumpheap -type <TypeName>и выполнитеgcroot <address>, чтобы вывести цепочку ссылок от корня до этого объекта. - Чините цепочку, а не объект. Последний переход перед вашим типом в выводе
gcroot— это и есть то, что удерживает ссылку.
Шаги 2—4: gcdump, дешёвый первый взгляд
dotnet-gcdump не пишет дамп процесса. Он вызывает сборку gen2, включает события выживания объектов кучи и восстанавливает граф объектов из потока EventPipe. Результат — файл .gcdump с типами, количествами, размерами и рёбрами графа, но без значений полей и без стеков потоков. Обычно это несколько мегабайт там, где полный дамп того же процесса занял бы сотни.
dotnet-gcdump collect --process-id 4807 --output baseline.gcdump
# Writing gcdump to './baseline.gcdump'...
# Finished writing 5763432 bytes.
# ... let it run under load ...
dotnet-gcdump collect --process-id 4807 --output after.gcdump
Графический интерфейс для сравнения не нужен. Команда report печатает таблицу статистики кучи прямо в stdout, что работает и в Linux, где открыть файл .gcdump нечем:
dotnet-gcdump report ./after.gcdump
# Size (Bytes) Count Type
# ============== ===== ====
# 1,603,588,000 22,000,000 System.String
# 201,096,000 2,010,000 System.Byte[]
# 25,000,000 250,000 MyApi.Reports.ReportSession
Запустите report для обоих файлов и сравните числа. В Windows можно также открыть оба файла .gcdump одновременно в Visual Studio и получить настоящее сравнение бок о бок со столбцом разницы, что стоит потраченного времени, если Windows-машина под рукой. PerfView тоже их читает. Открыть .gcdump в Linux или macOS сейчас нельзя, поэтому там dotnet-gcdump report — единственный вариант.
report также принимает --process-id напрямую: он соберёт и напечатает всё за один раз, если файл вам не нужен:
dotnet-gcdump report --process-id 4807
К концу этого шага у вас должно быть имя типа. Больше gcdump вам ничего не должен.
Шаги 5—7: dotnet-dump, где находится корень
gcdump не может сказать, какое поле какого объекта удерживает ссылку, и не показывает стеки потоков. Для этого нужны настоящий дамп и SOS.
dotnet-dump collect --process-id 4807 --type Heap --output leak.dmp
По умолчанию --type равен Full, что включает отображённые образы модулей и обычно намного больше, чем нужно. Heap даёт списки модулей, списки потоков, все стеки, информацию об исключениях и хэндлах и всю память, кроме отображённых образов, а этого достаточно для всего описанного процесса. Mini используйте только для разбора аварийных завершений: управляемой кучи он не содержит.
Затем откройте интерактивную оболочку SOS:
dotnet-dump analyze leak.dmp
Начните со статистики. Добавьте -live, чтобы использовалась фаза маркировки сборщика мусора и исключались объекты, которые уже стали мусором, но ещё не убраны. Это убирает много шума:
> dumpheap -stat -live
Statistics:
MT Count TotalSize Class Name
00007f6c1dc014c0 467 416464 System.Byte[]
00007f6c20a67498 250000 16000000 MyApi.Reports.ReportSession
00007f6c1dc00f90 206770 19494060 System.String
Полезные варианты той же команды:
dumpheap -stat -bycountсортирует по числу экземпляров, а не по суммарному размеру, и вытаскивает наружу утечки вида “миллион крошечных объектов”, которые байтовые итоги скрывают.dumpheap -type MyApi.Reports -statфильтрует по подстроке имени типа, так что таблицу можно сузить до одного пространства имён и отбросить шум фреймворка.dumpheap -gen loh -statограничивает вывод кучей больших объектов. Принимаетgen0,gen1,gen2,loh,pohиfoh.dumpheap -min 100000 -statигнорирует всё меньше 100 000 байт.
Теперь возьмите конкретный адрес и найдите его корень:
> dumpheap -type MyApi.Reports.ReportSession
Address MT Size
00007f6ad09421f8 00007f6c20a67498 32
...
> gcroot 00007f6ad09421f8
HandleTable:
00007F6C98BB15F8 (pinned handle)
-> 00007F6BDFFFF038 System.Object[]
-> 00007F69D0033570 MyApi.Telemetry.TelemetryBus
-> 00007F69D0033588 System.EventHandler`1[[System.String, System.Private.CoreLib]]
-> 00007F69D00335A0 System.Object[]
-> 00007F6AD0942258 MyApi.Reports.ReportSession
Found 1 root.
Читайте цепочку снизу вверх. Утекающий объект внизу, корень наверху. Переход непосредственно над вашим типом и есть виновник, и здесь он очевиден: многоадресный делегат EventHandler<string>, чей список вызовов (System.Object[]) удерживает все сессии. Это напрямую отображается на строку bus.MetricRecorded += OnMetric без парного -=.
По умолчанию gcroot печатает только уникальные корни. Передайте -all, если нужны все пути, и -nostacks, чтобы ограничить поиск хэндлами и достижимыми объектами, когда сканирование стека даёт ложные срабатывания из-за устаревших регистров.
Ещё две команды, о которых стоит знать на этом этапе. objsize <address> сообщает удерживаемый размер объекта вместе со всем, что он держит транзитивно: именно так “эта штука занимает 32 байта” превращается в “эта штука держит живыми 68 КБ”. А dumpobj <address> печатает раскладку по полям, чтобы вы могли подтвердить, какое поле удерживающего объекта указывает на вас:
> dumpobj 00007F69D0033570
Name: MyApi.Telemetry.TelemetryBus
MethodTable: 00007f6c20a67498
Size: 24(0x18) bytes
Fields:
MT Field Offset Type VT Attr Value Name
00007f6c1dc00f90 4000001 8 ...EventHandler`1 0 instance 00007F69D0033588 MetricRecorded
Подводные камни, которые стоят людям целого дня
gcdump запускает полную блокирующую сборку gen2. Именно так он обходит кучу. На процессе с большой кучей это может надолго приостановить среду выполнения. Не запускайте его в плотном цикле против чувствительного к задержкам продакшен-инстанса и ожидайте заметный всплеск паузы в метриках, когда всё-таки запустите.
gcdump может тихо не справиться с огромной кучей. Буфер событий принадлежит целевому приложению и может вырасти до 256 МБ. Если куча достаточно велика и события начинают теряться, вы получите System.ApplicationException: ETL file shows the start of a heap dump but not its completion либо .gcdump, молча содержащий только часть кучи. В этом случае пропустите gcdump и сразу переходите к dotnet-dump collect.
Обоим инструментам нужны тот же пользователь и тот же TMPDIR. В Linux и macOS --process-id и --name работают через сокет домена Unix, который среда выполнения создаёт внутри TMPDIR. Если инструмент запущен от другого пользователя или с другим TMPDIR, команда просто отвалится по тайм-ауту через 30 секунд без какой-либо полезной ошибки. Запускайте от того же пользователя, что и целевой процесс, либо от root.
В контейнерах нужен ptrace. dotnet-dump collect требует прав ptrace, которые обычно выдаются через --cap-add=SYS_PTRACE. Отдельно стоит помнить: сбор дампа кучи или полного дампа заставляет операционную систему подгрузить в память много виртуального адресного пространства целевого процесса, и это может вытолкнуть контейнер с ограничением памяти за лимит cgroup, после чего его убьёт OOM-killer прямо во время сбора. Поднимите или временно снимите лимит, если платформа это позволяет.
Строки Free — это не объекты. Большое количество Free в dumpheap -stat означает фрагментацию, а не утечку. Это пространство между живыми объектами, которое сборщик мусора не уплотнил, обычно в LOH. Другая проблема, другое решение (пулы, ArrayPool<T> или GCSettings.LargeObjectHeapCompactionMode).
Утечка в форме кеша может быть ошибкой конфигурации, а не кода. Если растущий тип — это ваш DTO, лежащий внутри IMemoryCache, то “утечка” обычно означает отсутствие ограничения размера или политики истечения, а не сбежавшую ссылку. Это решение относится к сравнению HybridCache, IMemoryCache и IDistributedCache, а не к отладчику.
Проверьте очередь финализации, прежде чем винить свой код. Команда finalizequeue в оболочке анализа выводит объекты, зарегистрированные для финализации. Забитая очередь означает, что финализируемые объекты продвигаются в gen2 и удерживаются лишний цикл сборки, а на графике это выглядит ровно как медленная утечка. Решение там почти всегда — детерминированное освобождение, ради которого и существует реализация IAsyncDisposable и конструкция await using.
Асинхронные конечные автоматы прячут собственные корни. Если растущие типы — это сгенерированные компилятором структуры вида <SomeMethod>d__12, используйте dumpasync -roots вместо gcroot. Она понимает цепочки продолжений и покажет, какая ожидающая задача удерживает автомат, тогда как сырой обход gcroot представит это как нечитаемую груду объектов Task и Action.
Что делать с ответом
Как только gcroot назвал удерживающий объект, исправление становится обычным кодом. Отпишитесь от события в Dispose. Задайте кешу ограничение размера и время истечения. Перестаньте захватывать scoped-сервис в singleton и вместо этого создавайте область на единицу работы внутри BackgroundService. Затем повторите шаги 1—4: прогон под нагрузкой, два gcdump и подтверждение, что число объектов этого типа не растёт. Утечка считается исправленной только тогда, когда это доказывает второй gcdump.
Источники: справочник по dotnet-gcdump, справочник по dotnet-dump, руководство по отладке утечки памяти, расширение отладки SOS и справочник по dotnet-counters.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.