Сборка мусора и ресурсы
Как .NET управляет памятью и ресурсами: как GC находит мусор, зачем ему поколения, чем Dispose отличается от финализатора, откуда берутся утечки и какой режим GC выбрать. На собеседовании тема проверяет, понимаете ли вы цену аллокаций и время жизни объектов.
Перед этой темой: Типы и память.
Как работает сборщик мусора в .NET?
Заголовок раздела «Как работает сборщик мусора в .NET?»Что это
Сборщик мусора (GC) — часть .NET, которая сама освобождает память объектов. Вызывать free или delete, как в C++, не нужно.
Зачем
Ручное освобождение памяти ведёт к утечкам и к обращению к уже освобождённой памяти. GC убирает обе ошибки.
Как он решает, что удалять
- Корни — локальные переменные выполняющихся методов и статические поля.
- GC идёт по ссылкам от корней и помечает достижимые объекты.
- Непомеченные объекты — мусор, даже если ссылаются друг на друга по кругу.
GC запускается сам, обычно при очередном new, когда закончилось место под новые объекты.
Частая ошибка
Вызывать GC.Collect() «для порядка». Ручной вызов обычно только замедляет программу.
Как устроено
Куча делится на поколения по возрасту объектов: в поколении 0 новые, в поколении 2 долгоживущие. Обычно собирается только поколение 0.
- Аллокация. Новый объект выделяется сдвигом указателя в поколении 0 — почти так же дёшево, как на стеке. Память выдаётся обнулённой.
- Остановка. Перед блокирующей сборкой рантайм останавливает все управляемые потоки, кроме запустившего её.
- Маркировка. GC обходит граф от корней: локальных переменных и регистров потоков, статических полей, GC-хендлов, объектов, ожидающих финализации.
- Освобождение. Мёртвые участки либо убираются уплотнением, либо заносятся в список свободных блоков (sweep).
- Продолжение. Потоки возобновляют работу.
Когда что выбирать
После маркировки GC сам выбирает способ освобождения:
| Уплотнение | Sweep | |
|---|---|---|
| Что делает | сдвигает живые объекты, обновляет ссылки | вносит мёртвые участки в список свободных |
| Плюс | нет фрагментации | быстрее |
| Минус | копирование дороже | в куче остаются дыры |
Ловушки
- Сборку запускает исчерпание бюджета поколения 0 или кучи больших объектов, сигнал ОС о нехватке памяти или
GC.Collect. Пороги рантайм подстраивает на ходу. GC.Collect()в коде приложения вредит: живые объекты продвигаются в старшие поколения, а самонастройка сбивается.
Правило
Не управляйте GC вручную: уменьшайте число аллокаций и время жизни объектов.
Под капотом
- Точный сборщик. Для каждого метода JIT сохраняет GC info — где в каждой точке кода лежат ссылки.
- Ранняя смерть. В оптимизированном коде переменная перестаёт быть корнем после последнего использования. Поэтому при передаче сырого
IntPtrв нативный код нуженGC.KeepAlive. - Debug и Release. В Debug JIT продлевает жизнь локальных переменных до конца метода, и поведение в отладке отличается от прода.
- Safe points. Блокирующая сборка ждёт, пока потоки дойдут до безопасной точки. Поток в нативном коде не останавливается, но вернуться в управляемый код до конца сборки не сможет.
- Card table. Чтобы собрать молодые объекты, не обходя старые, write barrier при записи ссылки в поле помечает участок памяти. Помеченные участки становятся дополнительными корнями.
- Регионы. С .NET 7 куча на 64-битных Windows и Linux состоит из регионов: по умолчанию 4 МБ, для больших и закреплённых объектов — 32 МБ.
Цена
- Маркировка пропорциональна числу живых объектов и ссылок, уплотнение — объёму выживших.
- Мёртвые объекты почти бесплатны: их не обходят и не копируют.
Компромиссы
Бюджет поколения 0 балансирует частоту сборок и память. Больший бюджет — реже сборки и выше пропускная способность, но больше памяти. GC подбирает бюджеты сам, а режимы GC сдвигают этот баланс.
Как проверить в проде
dotnet-counters(имена .NET 9+):dotnet.gc.pause.timeиdotnet.gc.collectionsпо поколениям.- PerfView (GCStats): длительность и причина каждой сборки.
Итог: платим за живые объекты и частоту аллокаций, а не за мусор.
var cache = new List<byte[]> { new byte[1024] };var reachable = new WeakReference(cache[0]);var garbage = MakeGarbage();
GC.Collect(); // только для демонстрации, в коде приложения не нужен
Console.WriteLine(reachable.IsAlive); // True: достижим из локальной переменной cacheConsole.WriteLine(garbage.IsAlive); // False: на массив не осталось сильных ссылокGC.KeepAlive(cache); // cache считается живой до этой строки
static WeakReference MakeGarbage(){ var data = new byte[1024]; // ссылка только в локальной переменной return new WeakReference(data); // слабая ссылка не корень}- Почему циклические ссылки не мешают сборке? — GC проверяет достижимость от корней, а не считает ссылки.
- Может ли объект быть собран, пока выполняется его метод? — Да, в оптимизированном коде, если
thisдальше не используется; помогаетGC.KeepAlive. - Чем уплотнение отличается от sweep? — Уплотнение двигает объекты и правит ссылки, sweep оставляет дыры в списке свободных блоков.
- Почему аллокация в .NET дешёвая? — Это сдвиг указателя в заранее выделенной потоку области, без поиска свободного блока.
- Когда оправдан
GC.Collect? — В тестах, бенчмарках и диагностике; в коде приложения почти никогда.
Зачем нужен IDisposable и using?
Заголовок раздела «Зачем нужен IDisposable и using?»Что это
IDisposable — интерфейс с одним методом Dispose(). Его реализуют классы, которые держат ресурсы ОС: открытый файл, сокет, соединение с БД.
Зачем
GC освобождает только память и делает это когда захочет. Файл или соединение нужно закрыть сразу, иначе их не сможет получить никто другой.
Как пользоваться
using (var reader = new StreamReader(path)) { ... }—Disposeв конце блока.using var reader = new StreamReader(path);— using-объявление (C# 8):Disposeв конце метода или блока.await using var stream = ...;— для типов сIAsyncDisposable, у которых закрытие асинхронное.
Частая ошибка
Забыть using. Файл остаётся заблокированным, а соединение не возвращается в пул, пока GC когда-нибудь не доберётся до объекта.
Как устроено
- Выражение в
usingвычисляется и сохраняется в переменную только для чтения. - Тело выполняется внутри
try. - В
finallyвызываетсяDispose, если переменная неnull.
Несколько объектов в одном using и подряд идущие using-объявления освобождаются в обратном порядке.
Когда что выбирать
| Форма | Когда |
|---|---|
using (...) { } |
ресурс нужен только в части метода |
using var x = ...; |
ресурс нужен до конца метода или блока |
await using |
закрытие делает I/O: Stream, DbConnection |
Ловушки
- Тип только с
IAsyncDisposableнельзя освободить обычнымusing. Поэтому хорошая практика — реализовать оба интерфейса. HttpClientне создают и не освобождают на каждый запрос: соединения закрываются не сразу, и под нагрузкой кончаются порты. Его переиспользуют или берут изIHttpClientFactory.- Зависимости из DI вручную не освобождают — это делает контейнер в конце scope.
Правило
Освобождает владелец — тот, кто создал объект или получил владение. Класс, который владеет полем с IDisposable, сам реализует IDisposable. Dispose идемпотентен, а остальные методы после него бросают ObjectDisposedException — удобно через ObjectDisposedException.ThrowIf (.NET 7+).
Под капотом
Disposeосвобождает ресурс, но не память: объект живёт, пока на него есть ссылки.- Если
Disposeне вызвали, неуправляемый ресурс освободит только финализатор — метод, который GC вызывает перед уничтожением объекта. Это случится когда-нибудь, а в .NET 5+ при завершении процесса финализаторы не вызываются. - Поэтому дескриптор заворачивают в
SafeHandle— готовую обёртку со своим финализатором.DisposeвызываетGC.SuppressFinalize, чтобы отменить уже ненужную финализацию.
Цена
- Неосвобождённые ресурсы видно не по куче, а по дескрипторам, пулу соединений и нативной памяти.
- Transient-сервис с
IDisposable, полученный из корневого DI-провайдера, контейнер держит до собственногоDispose. Это утечка.
Компромиссы
- Порядок.
StreamWriterосвобождают раньше нижележащегоStream, иначе буфер не сбросится. Using-объявления как раз освобождают в обратном порядке. - Владение в API. Если метод принимает
Stream, договоритесь, кто его закрывает. Для этого уStreamReaderиStreamWriterесть параметрleaveOpen. - Готовые экземпляры. DI не освобождает объекты, зарегистрированные готовыми:
AddSingleton(new X()).
Как проверить в проде
- Анализаторы: CA2000 — объект не освобождён до потери ссылки, CA2213 — не освобождено поле-владелец, CA1816 — неверный вызов
GC.SuppressFinalize. - Метрики: число открытых дескрипторов процесса, занятость пула соединений, рост working set при стабильной куче.
Итог: Dispose — про владение ресурсом, и его утечки видны по дескрипторам и пулам, а не по куче.
// using-объявление (C# 8): Dispose в конце метода, даже при исключенииstatic string ReadFirstLine(string path){ using var reader = new StreamReader(path); return reader.ReadLine() ?? "";}
// await using: асинхронная очистка через DisposeAsyncstatic async Task WriteAsync(string path, string text){ await using var stream = new FileStream(path, FileMode.Create); await using var writer = new StreamWriter(stream); await writer.WriteAsync(text);} // сначала writer (сбрасывает буфер), потом stream
// Во что компилятор превращает using (var r = new StreamReader("a.txt")) { ... }var r = new StreamReader("a.txt");try{ Console.WriteLine(r.ReadLine());}finally{ r?.Dispose();}- Что будет, если не вызвать
DisposeуFileStream? — Файл закроет финализаторSafeFileHandle, но неизвестно когда; до этого файл может оставаться заблокированным. - Можно ли вызывать
Disposeдважды? — Да, правильная реализация идемпотентна. - Зачем
IAsyncDisposable, если естьIDisposable? — Чтобы не блокировать поток на I/O при закрытии, например при сбросе буфера. - Нужно ли вызывать
Disposeу сервиса, полученного из DI? — Нет, временем жизни управляет контейнер. - Почему
HttpClientне оборачивают вusingна каждый запрос? — Соединения закрываются не сразу, и под нагрузкой кончаются порты.
Зачем GC поколения и что такое LOH и POH?
Заголовок раздела «Зачем GC поколения и что такое LOH и POH?»Что это
Куча делится на три поколения по возрасту объектов:
- Поколение 0 — новые объекты, собирается чаще всего.
- Поколение 1 — пережившие одну сборку.
- Поколение 2 — долгоживущие: кеши, синглтоны, статические данные.
Объект, переживший сборку, переходит в следующее поколение. Узнать поколение можно через GC.GetGeneration(obj).
Зачем
Большинство объектов живёт недолго. Собрать маленькое поколение 0 быстрее, чем всю кучу.
Отдельные кучи
| Куча | Что в ней |
|---|---|
| LOH (large object heap) | объекты от 85 000 байт; собирается только при полной сборке |
| POH (pinned object heap, .NET 5+) | массивы, которые нельзя двигать, например буферы для нативного кода |
Частая ошибка
Создавать большой временный массив на каждый запрос: каждый попадает в LOH и приближает дорогую полную сборку.
Как устроено
- Сборка поколения N собирает и все младшие. Сборка поколения 2 — полная.
- У каждого поколения есть бюджет аллокаций; когда он исчерпан, запускается сборка. Если выживает много объектов, GC увеличивает бюджет.
- LOH логически часть поколения 2. Порог 85 000 байт можно поднять через
GCLOHThreshold. - LOH обычно не уплотняется, а подметается в список свободных блоков, поэтому фрагментируется.
- POH тоже собирается с поколением 2, и объекты в ней никогда не двигаются. Массив создают через
GC.AllocateArray<T>(length, pinned: true)илиGC.AllocateUninitializedArray<T>(length, pinned: true).
Когда что выбирать
| Задача | Решение |
|---|---|
| Закрепить объект на время одного вызова | fixed: объект остаётся в поколении 0 |
| Долгоживущий буфер для I/O и interop | массив в POH |
| Временный большой буфер | ArrayPool<T>.Shared вместо new |
Ловушки
- Обнуление памяти под большой массив само по себе дорогое.
- Фрагментацию LOH убирает разовое уплотнение:
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce. При жёстком лимите кучи, например в контейнере с лимитом памяти, LOH уплотняется автоматически. - Объекты, которые живут «чуть дольше запроса», доживают до поколения 2 и делают полные сборки частыми.
Правило
Не выделяйте временные большие массивы в горячем коде и не держите объекты дольше, чем нужно.
Под капотом
- Ссылка из поколения 2 в поколение 0 — тоже корень для сборки молодых поколений. При записи ссылки в поле или элемент массива JIT вставляет write barrier, который помечает участок памяти в card table.
- При сборке поколений 0 и 1 (эфемерных) GC просматривает только помеченные участки старших поколений.
- С .NET 7 поколения состоят из регионов: по умолчанию 4 МБ для маленьких объектов и 32 МБ для LOH и POH.
- Закреплённый объект в эфемерном поколении не даёт уплотнить память вокруг себя. POH выносит долгоживущие закреплённые буферы отдельно.
Цена
- Write barrier срабатывает на каждую запись ссылки в поле. Если старые объекты массово ссылаются на новые, как большой кеш со свежими записями, эфемерная сборка просматривает много участков.
- Объекты среднего возраста, умирающие в поколении 2, — главный источник дорогих полных сборок.
- У LOH свой бюджет: временные большие массивы запускают полные сборки.
Компромиссы
- Пулы (
ArrayPool<T>) убирают аллокации, но буферы в них живут в поколении 2, а возврат в пул требует дисциплины. - POH убирает фрагментацию от pinning, но объект в ней живёт до полной сборки. Для коротких буферов
fixedв поколении 0 лучше.
Как проверить в проде
dotnet-counters(имена .NET 9+):dotnet.gc.collectionsпо поколениям,dotnet.gc.last_collection.heap.sizeиdotnet.gc.last_collection.heap.fragmentation.sizeс разбивкой gen0, gen1, gen2, loh, poh.- PerfView (GCStats): причина
AllocLargeзначит, что полные сборки вызывает LOH. - Из кода —
GC.GetGCMemoryInfo().
Итог: дорого то, что доживает до поколения 2 и умирает там, включая временные большие массивы.
using System.Runtime;
var obj = new object();Console.WriteLine(GC.GetGeneration(obj)); // 0: новый объект
GC.Collect(0); // собрать только поколение 0Console.WriteLine(GC.GetGeneration(obj)); // 1: пережил сборку и продвинулсяGC.Collect(); // полная сборкаConsole.WriteLine(GC.GetGeneration(obj)); // 2: дальше продвигаться некуда
var large = new byte[85_000]; // от 85 000 байт — сразу в LOHvar buffer = GC.AllocateArray<byte>(4096, pinned: true); // POH, .NET 5+
// Разово уплотнить LOH при следующей блокирующей полной сборкеGCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;GC.Collect();
Console.WriteLine($"gen0: {GC.CollectionCount(0)}, gen2: {GC.CollectionCount(2)}"); // обычно gen0: 3, gen2: 2GC.KeepAlive(large);GC.KeepAlive(buffer);- Почему порог LOH именно 85 000 байт? — Подобран по результатам тюнинга; настраивается через
GCLOHThreshold, но только в бо́льшую сторону. - Как бороться с фрагментацией LOH? — Пулы (
ArrayPool<T>), разбиение данных на куски меньше порога, разовое уплотнениеCompactOnce. - Чем POH лучше
GCHandle.Alloc(obj, GCHandleType.Pinned)? — Долгоживущие закреплённые буферы не фрагментируют эфемерные поколения. - Что такое write barrier? — Код, который JIT вставляет при записи ссылки в поле, чтобы пометить участок для эфемерных сборок.
- Что значит «полная сборка»? — Сборка поколения 2 вместе со всеми младшими поколениями, LOH и POH.
Что такое финализатор, Dispose pattern и при чём тут SafeHandle?
Заголовок раздела «Что такое финализатор, Dispose pattern и при чём тут SafeHandle?»Что это
Финализатор объявляется как ~ClassName(), и компилятор превращает его в переопределение Object.Finalize. Вызывает его GC, а не вы, и в непредсказуемый момент.
Зачем
Это страховка. Если класс напрямую держит неуправляемый ресурс, например указатель на нативную память, а Dispose забыли, ресурс всё-таки освободится.
Как пользоваться
- Dispose pattern — стандартная схема:
Dispose()и финализатор вызывают общий методDispose(bool disposing). - SafeHandle — готовая обёртка над дескриптором ОС со своим финализатором (
SafeFileHandleи другие). С ней свой финализатор не нужен.
Частая ошибка
Пустой финализатор «на всякий случай». Он только замедляет программу. У структур финализатора быть не может.
Как устроено
- При создании объект с финализатором регистрируется в очереди финализации.
- Когда GC находит объект недостижимым, запись переносится в очередь f-reachable. Объект и всё, на что он ссылается, снова временно достижимы.
- Поток финализации вызывает
Finalize. - Следующая сборка этого поколения освобождает память.
Получается минимум две сборки и продвижение в старшее поколение.
Когда что выбирать
| Класс владеет | Что реализовать |
|---|---|
только управляемыми IDisposable, класс sealed |
простой Dispose() |
| управляемыми ресурсами, класс открыт для наследования | Dispose() и protected virtual Dispose(bool) |
сырым IntPtr |
обернуть его в наследника SafeHandle |
Ловушки
- В
Dispose(false), то есть при вызове из финализатора, нельзя трогать другие управляемые объекты: они могли быть уже финализированы. - Наследник не реализует
IDisposableзаново, а переопределяетDispose(bool)и вызываетbase.Dispose(disposing). Dispose(bool)защищают флагом от повторного вызова.
Правило
Dispose() вызывает Dispose(true) и GC.SuppressFinalize(this), а финализатор, если он есть, — Dispose(false). SafeHandle гарантирует, что ReleaseHandle выполнится ровно один раз.
Под капотом
- Финализируемый объект при создании регистрируется в очереди, поэтому его аллокация дороже.
- В CoreCLR финализаторы выполняет один поток. Исключение в финализаторе завершает процесс.
SafeHandleнаследуетCriticalFinalizerObject: его финализатор выполняется после обычных той же сборки. Поэтому обычный финализатор, например сбрасывающий буфер, ещё может пользоваться дескриптором.- P/Invoke увеличивает счётчик ссылок
SafeHandleна время вызова. Дескриптор не закроется посреди вызова и не будет переиспользован ОС для другого ресурса (recycle attack).
Цена
- Объект и весь его граф переживают лишнюю сборку и уходят в старшее поколение. Тысячи таких объектов в секунду нагружают поколения 1 и 2.
- Финализатор, заблокированный на lock, сети или синхронном I/O, останавливает очередь, и память перестаёт освобождаться.
Компромиссы
- Порядок финализации не определён, а в .NET 5+ финализаторы не вызываются при выходе из процесса. Гарантированную очистку делают через
Dispose,IHostApplicationLifetimeилиAppDomain.ProcessExit. - Класс с сырым
IntPtrбезSafeHandleрискует ранней финализацией: после последнего обращения кthisобъект может быть финализирован во время нативного вызова. Тогда нуженGC.KeepAlive(this).
Как проверить в проде
- SOS-команда
finalizequeueвdotnet-dump analyzeпоказывает, сколько объектов ждёт финализации. - Стек потока финализации в дампе показывает, на чём он завис.
Итог: свой финализатор пишут только в наследнике SafeHandle, остальные классы владеют им через Dispose.
using System.Runtime.InteropServices;
public class LegacyNativeBuffer : IDisposable{ private IntPtr _ptr = Marshal.AllocHGlobal(1024); // неуправляемая память private readonly MemoryStream _log = new(); // управляемый ресурс private bool _disposed;
public void Dispose() { Dispose(disposing: true); GC.SuppressFinalize(this); // финализатор больше не нужен }
protected virtual void Dispose(bool disposing) { if (_disposed) return; if (disposing) _log.Dispose(); // управляемое — только из Dispose() Marshal.FreeHGlobal(_ptr); // неуправляемое — всегда _ptr = IntPtr.Zero; _disposed = true; }
~LegacyNativeBuffer() => Dispose(disposing: false); // страховка, если забыли Dispose}using System.Runtime.InteropServices;using Microsoft.Win32.SafeHandles;
// Финализатор уже есть в SafeHandle; ReleaseHandle вызовется ровно один разsealed class HGlobalHandle : SafeHandleZeroOrMinusOneIsInvalid{ public HGlobalHandle(int size) : base(ownsHandle: true) => SetHandle(Marshal.AllocHGlobal(size));
protected override bool ReleaseHandle() { Marshal.FreeHGlobal(handle); return true; }}
// Владелец: без финализатора и без Dispose(bool)public sealed class NativeBuffer : IDisposable{ private readonly HGlobalHandle _handle = new(1024);
public void Dispose() => _handle.Dispose(); // повторный вызов безопасен}- Почему в
Dispose(false)нельзя трогать управляемые поля? — Они могли быть уже финализированы: порядок финализации не определён. - Зачем
GC.SuppressFinalize, если финализатора нет? — На случай наследника с финализатором; без финализатора вызов ни на что не влияет. - Что будет, если финализатор бросит исключение? — Процесс завершится.
- Вызываются ли финализаторы при завершении приложения? — В .NET Framework да, в .NET 5+ нет.
- Как
SafeHandleзащищает от recycle attack? — На время P/Invoke счётчик ссылок не даёт закрыть дескриптор и отдать его номер другому ресурсу.
Как в управляемом коде возникают утечки памяти и как их искать?
Заголовок раздела «Как в управляемом коде возникают утечки памяти и как их искать?»Что это
Утечка памяти — ненужные объекты, на которые ещё есть ссылка. GC удаляет только недостижимые объекты, поэтому эта память не освобождается.
Откуда берётся
- подписка на событие без отписки: издатель держит ссылку на подписчика;
static-список или словарь, из которого ничего не удаляют;- кеш без ограничения размера и срока жизни;
- таймеры без
Dispose.
Как заметить
- Память процесса растёт и не падает даже в простое.
- В Visual Studio (Diagnostic Tools → Memory Usage) сделайте снимок кучи.
- Повторите действие, сделайте второй снимок и сравните, каких объектов стало больше.
Частая ошибка
Считать, что Dispose убирает объект из памяти. Если на него ссылается событие или коллекция, он останется в куче.
Как устроено
- События. Делегат хранит ссылку на подписчика. Если издатель живёт дольше (синглтон, статическое событие), подписчик не соберётся.
- Статика и кеши.
static DictionaryилиConcurrentDictionaryв роли кеша без вытеснения растёт бесконечно. - Таймеры. Активный таймер зарегистрирован в очереди таймеров рантайма, а его callback держит объект-владелец. Если владелец хранит таймер в поле, оба живут до
Disposeтаймера. - Замыкания. Лямбда держит захваченные переменные и
this. Сохранённая в событии, кеше или статическом поле, она держит весь граф. - DI. Transient-сервис с
IDisposableиз корневого провайдера контейнер хранит до своегоDispose.
Когда что выбирать
| Инструмент | Что даёт |
|---|---|
dotnet-counters |
растёт ли куча после сборок |
dotnet-gcdump |
граф объектов: сравнение по типам, путь до корня |
dotnet-dump |
полный дамп: dumpheap -stat, gcroot |
Ловушки
Disposeне убирает ссылки на объект из коллекций и событий.- Отписка нужна от событий долгоживущего издателя. Цикл ссылок внутри недостижимого графа GC соберёт сам.
Правило
Ищите по шагам:
dotnet-counters monitor -p <pid>— убедиться, что размер кучи после сборок растёт.- Два снимка
dotnet-gcdump collect -p <pid>с интервалом; открыть в Visual Studio или PerfView и сравнить по типам. - Найти, кто держит растущий тип: Paths to Root в Visual Studio или
gcrootвdotnet-dump analyze.
Под капотом
- Общее замыкание. Компилятор создаёт один класс замыкания на область видимости. Если две лямбды в методе захватывают разные переменные, обе переменные лежат в одном объекте, и долгоживущая лямбда держит чужой большой объект.
ExecutionContext. Таймеры и работа в пуле потоков захватывают контекст выполнения, а с ним значенияAsyncLocal<T>. Таймер, созданный внутри запроса, держит данные этого запроса.- Корни.
gcrootзаканчивает цепочку на корне: статическом поле, сильном или закреплённом GC-хендле, очереди финализации.
Цена
dotnet-gcdumpвызывает полную сборку, которая на большой куче надолго останавливает процесс.dotnet-dumpсодержит всю память процесса со значениями полей и весит столько же.
Компромиссы
.gcdump легче и безопаснее для прода, но в нём нет значений полей. Полный дамп нужен, когда важны данные, например какой ключ растёт в кеше.
Как проверить в проде
- Сравнить
dotnet.gc.last_collection.heap.sizeиdotnet.process.memory.working_set(имена .NET 9+). Растёт только working set — утечка нативная. - Нагрузить, дождаться устойчивого состояния, снять дамп; повторить через несколько минут.
- Сравнить
dumpheap -statили diff двух.gcdump: интересен тип, у которого растёт число экземпляров. - Выполнить
gcrootдля экземпляра этого типа и найти корень.
В тестах утечку ловят так: создать объект в отдельном методе, держать на него WeakReference, вызвать GC.Collect() и проверить IsAlive.
Итог: сначала отделить нативную утечку от управляемой, потом найти корень, который держит растущий тип.
public sealed class PriceFeed // синглтон, живёт всё время работы{ public event EventHandler<decimal>? PriceChanged; public void Publish(decimal price) => PriceChanged?.Invoke(this, price);}
public sealed class PriceWidget : IDisposable // создаётся на каждый экран{ private readonly PriceFeed _feed; private readonly byte[] _cache = new byte[100_000]; // утечёт вместе с виджетом
public PriceWidget(PriceFeed feed) { _feed = feed; _feed.PriceChanged += OnPrice; // feed → делегат → this }
private void OnPrice(object? sender, decimal price) => Console.WriteLine(price);
// Без отписки каждый закрытый виджет остаётся в памяти навсегда public void Dispose() => _feed.PriceChanged -= OnPrice;}static void Subscribe(PriceFeed feed){ var report = new byte[10_000_000]; // большой временный объект var name = "EURUSD";
Task.Run(() => Console.WriteLine(report.Length)); // короткая лямбда feed.PriceChanged += (_, p) => Console.WriteLine($"{name}: {p}");
// Обе лямбды делят один класс замыкания: долгоживущий // обработчик события держит и name, и report (10 МБ)}- Почему отписываться важно от событий синглтона, но не от событий собственного поля? — Утекает подписчик долгоживущего издателя; цикл внутри недостижимого графа GC соберёт.
- Чем
dotnet-gcdumpотличается отdotnet-dump? — gcdump — только граф объектов, лёгкий, но с полной сборкой; dump — вся память процесса со значениями. - Как отличить утечку от просто большой кучи Server GC? — Смотреть размер кучи после полных сборок при постоянной нагрузке: при утечке он растёт без плато.
- Что такое слабое событие? — Подписка через
WeakReference, которая не мешает собрать подписчика. - Может ли утечь память при правильном
Dispose? — Да:Disposeне убирает ссылки на объект из коллекций и событий.
Какие режимы GC бывают и как выбрать?
Заголовок раздела «Какие режимы GC бывают и как выбрать?»Что это
- Workstation GC — одна куча, сборка идёт прямо на потоке, который выделял память. По умолчанию для консольных и десктопных приложений.
- Server GC — своя куча и свой поток сборки на каждый процессор. По умолчанию в ASP.NET Core.
- Фоновая сборка (background GC) включена в обоих режимах: долгая сборка поколения 2 идёт параллельно с работой приложения.
Зачем
Server GC собирает быстрее и даёт больше пропускной способности, но расходует больше памяти. Workstation экономнее.
Как пользоваться
Режим задаётся в .csproj: <ServerGarbageCollection>true</ServerGarbageCollection>.
Частая ошибка
Менять режим GC «для ускорения» без замеров. Значения по умолчанию подходят большинству приложений.
Как устроено
| Workstation | Server | |
|---|---|---|
| Кучи | одна | по одной на логический процессор |
| Где идёт сборка | на потоке, вызвавшем её | на выделенных потоках GC (на Windows — THREAD_PRIORITY_HIGHEST) |
| Память | меньше | больше |
- На машине с одним логическим процессором всегда используется Workstation.
- Background GC (
System.GC.Concurrent, по умолчаниюtrue) работает только для поколения 2: выделенный поток маркирует и подметает, пока приложение работает. Фоновая сборка не уплотняет кучу. - Если во время фоновой сборки заполнилось поколение 0, проходит обычная эфемерная сборка с остановкой потоков.
- DATAS появился в .NET 8 как опция, с .NET 9 включён по умолчанию для Server GC. Он стартует с одной кучи, добавляет и убирает кучи по нагрузке и ограничивает бюджет поколения 0 по объёму долгоживущих данных.
Когда что выбирать
| Сценарий | Режим |
|---|---|
| Веб-сервис, контейнеры, переменная нагрузка | Server GC с DATAS (по умолчанию) |
| Выделенная машина, важны старт и пиковая пропускная способность | Server GC без DATAS |
| Десктоп, консольная утилита | Workstation с фоновой сборкой |
| Сотни небольших процессов на одной машине | Workstation без фоновой сборки |
Ловушки
- Настройки читаются один раз при старте: менять переменные окружения у работающего процесса бесполезно.
- Явный
GCHeapCountотключает DATAS.
Правило
Задавайте режим свойствами ServerGarbageCollection и ConcurrentGarbageCollection в .csproj (или System.GC.Server и System.GC.Concurrent в runtimeconfig.json) и меняйте его только по результатам замеров.
Под капотом
- DATAS ориентируется на throughput cost percentage (TCP) — долю потерь на паузы GC и ожидание аллокаций. Цель по умолчанию — 2 % (
GCDTargetTCP). - Верхняя граница бюджета поколения 0 считается от размера приложения. В .NET 10 для неё добавлены настройки
GCDGen0GrowthPercent,GCDGen0GrowthMinFactorиGCDGen0GrowthMaxFactor. - В контейнере GC считает лимит контейнера физической памятью. Без настроек
GCHeapHardLimitравен большему из 20 МБ и 75 % лимита, а LOH при жёстком лимите уплотняется автоматически.
Цена
- С DATAS вначале одна куча, поэтому старт медленнее, а при резком росте нагрузки потоки ждут аллокаций.
- Без DATAS Server GC подбирает бюджеты по пропускной способности, и на машине с большим числом ядер куча раздувается.
- При достижении
GCHeapHardLimitаллокация завершаетсяOutOfMemoryException.
Компромиссы
- DATAS выключают, если критичен старт, недопустима даже небольшая потеря пропускной способности или сборки в основном полные. Способы:
<GarbageCollectionAdaptationMode>0</GarbageCollectionAdaptationMode>,System.GC.DynamicAdaptationMode= 0 илиDOTNET_GCDynamicAdaptationMode=0. GCConserveMemory(0–9) держит кучу меньше ценой более частых сборок.GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatencyподавляет блокирующие полные сборки на время критичной работы, но куча растёт.
Как проверить в проде
- Нагрузочный тест с реальным профилем:
dotnet.gc.pause.time, число сборок, working set, RPS и p99. - PerfView GCStats: доля пауз и бюджет поколения 0 по каждой сборке.
- Для многих .NET 10 — первый LTS с DATAS по умолчанию, поэтому после миграции с .NET 8 сравните метрики. Меняйте по одной настройке за раз.
Итог: режим GC выбирают по метрикам пауз, памяти и пропускной способности, а не по названию.
using System.Runtime;
// Режим из проекта: <ServerGarbageCollection>true</ServerGarbageCollection>Console.WriteLine($"Server GC: {GCSettings.IsServerGC}"); // Server GC: TrueConsole.WriteLine($"Latency mode: {GCSettings.LatencyMode}"); // Interactive, если фоновая сборка включена
var info = GC.GetGCMemoryInfo();Console.WriteLine($"Доступно GC: {info.TotalAvailableMemoryBytes / 1024 / 1024} МБ"); // в контейнере — от лимитаConsole.WriteLine($"Порог высокой нагрузки: {info.HighMemoryLoadThresholdBytes / 1024 / 1024} МБ");
// Фактические настройки GC (.NET 7+): число куч, лимиты, DATAS и т. д.foreach (var (key, value) in GC.GetConfigurationVariables()) Console.WriteLine($"{key} = {value}");- Почему Server GC без DATAS в маленьком контейнере на многоядерной машине раздувает память? — Кучи и бюджеты считаются от числа ядер и пропускной способности, а не от объёма данных.
- Что делает фоновая сборка, если во время неё заполнилось поколение 0? — Приостанавливается, и проходит обычная блокирующая эфемерная сборка.
- Как выключить DATAS и когда это оправдано? —
GCDynamicAdaptationMode=0; когда важны старт и пиковая пропускная способность, а память не в дефиците. - Что произойдёт при достижении
GCHeapHardLimit? — Аллокация завершитсяOutOfMemoryException, даже если у машины есть свободная память. - Когда нужен
SustainedLowLatency? — На время чувствительных к задержкам операций, если памяти достаточно: куча растёт и фрагментируется.