Перейти к содержимому

Сборка мусора и ресурсы

Как .NET управляет памятью и ресурсами: как GC находит мусор, зачем ему поколения, чем Dispose отличается от финализатора, откуда берутся утечки и какой режим GC выбрать. На собеседовании тема проверяет, понимаете ли вы цену аллокаций и время жизни объектов.

Перед этой темой: Типы и память.

Junior+
Ответ

Что это

Сборщик мусора (GC) — часть .NET, которая сама освобождает память объектов. Вызывать free или delete, как в C++, не нужно.

Зачем

Ручное освобождение памяти ведёт к утечкам и к обращению к уже освобождённой памяти. GC убирает обе ошибки.

Как он решает, что удалять

  1. Корни — локальные переменные выполняющихся методов и статические поля.
  2. GC идёт по ссылкам от корней и помечает достижимые объекты.
  3. Непомеченные объекты — мусор, даже если ссылаются друг на друга по кругу.

GC запускается сам, обычно при очередном new, когда закончилось место под новые объекты.

Частая ошибка

Вызывать GC.Collect() «для порядка». Ручной вызов обычно только замедляет программу.

Как устроено

Куча делится на поколения по возрасту объектов: в поколении 0 новые, в поколении 2 долгоживущие. Обычно собирается только поколение 0.

  1. Аллокация. Новый объект выделяется сдвигом указателя в поколении 0 — почти так же дёшево, как на стеке. Память выдаётся обнулённой.
  2. Остановка. Перед блокирующей сборкой рантайм останавливает все управляемые потоки, кроме запустившего её.
  3. Маркировка. GC обходит граф от корней: локальных переменных и регистров потоков, статических полей, GC-хендлов, объектов, ожидающих финализации.
  4. Освобождение. Мёртвые участки либо убираются уплотнением, либо заносятся в список свободных блоков (sweep).
  5. Продолжение. Потоки возобновляют работу.

Когда что выбирать

После маркировки 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: достижим из локальной переменной cache
Console.WriteLine(garbage.IsAlive); // False: на массив не осталось сильных ссылок
GC.KeepAlive(cache); // cache считается живой до этой строки
static WeakReference MakeGarbage()
{
var data = new byte[1024]; // ссылка только в локальной переменной
return new WeakReference(data); // слабая ссылка не корень
}
Junior+
Ответ

Что это

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 когда-нибудь не доберётся до объекта.

Как устроено

  1. Выражение в using вычисляется и сохраняется в переменную только для чтения.
  2. Тело выполняется внутри try.
  3. В 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: асинхронная очистка через DisposeAsync
static 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();
}
Middle+
Ответ

Что это

Куча делится на три поколения по возрасту объектов:

  • Поколение 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); // собрать только поколение 0
Console.WriteLine(GC.GetGeneration(obj)); // 1: пережил сборку и продвинулся
GC.Collect(); // полная сборка
Console.WriteLine(GC.GetGeneration(obj)); // 2: дальше продвигаться некуда
var large = new byte[85_000]; // от 85 000 байт — сразу в LOH
var 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: 2
GC.KeepAlive(large);
GC.KeepAlive(buffer);
Middle+

Что такое финализатор, Dispose pattern и при чём тут SafeHandle?

Заголовок раздела «Что такое финализатор, Dispose pattern и при чём тут SafeHandle?»
Ответ

Что это

Финализатор объявляется как ~ClassName(), и компилятор превращает его в переопределение Object.Finalize. Вызывает его GC, а не вы, и в непредсказуемый момент.

Зачем

Это страховка. Если класс напрямую держит неуправляемый ресурс, например указатель на нативную память, а Dispose забыли, ресурс всё-таки освободится.

Как пользоваться

  • Dispose pattern — стандартная схема: Dispose() и финализатор вызывают общий метод Dispose(bool disposing).
  • SafeHandle — готовая обёртка над дескриптором ОС со своим финализатором (SafeFileHandle и другие). С ней свой финализатор не нужен.

Частая ошибка

Пустой финализатор «на всякий случай». Он только замедляет программу. У структур финализатора быть не может.

Как устроено

  1. При создании объект с финализатором регистрируется в очереди финализации.
  2. Когда GC находит объект недостижимым, запись переносится в очередь f-reachable. Объект и всё, на что он ссылается, снова временно достижимы.
  3. Поток финализации вызывает Finalize.
  4. Следующая сборка этого поколения освобождает память.

Получается минимум две сборки и продвижение в старшее поколение.

Когда что выбирать

Класс владеет Что реализовать
только управляемыми 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.

Пример: классический Dispose pattern
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
}
Пример: SafeHandle вместо своего финализатора
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(); // повторный вызов безопасен
}
Middle+

Как в управляемом коде возникают утечки памяти и как их искать?

Заголовок раздела «Как в управляемом коде возникают утечки памяти и как их искать?»
Ответ

Что это

Утечка памяти — ненужные объекты, на которые ещё есть ссылка. GC удаляет только недостижимые объекты, поэтому эта память не освобождается.

Откуда берётся

  • подписка на событие без отписки: издатель держит ссылку на подписчика;
  • static-список или словарь, из которого ничего не удаляют;
  • кеш без ограничения размера и срока жизни;
  • таймеры без Dispose.

Как заметить

  1. Память процесса растёт и не падает даже в простое.
  2. В Visual Studio (Diagnostic Tools → Memory Usage) сделайте снимок кучи.
  3. Повторите действие, сделайте второй снимок и сравните, каких объектов стало больше.

Частая ошибка

Считать, что Dispose убирает объект из памяти. Если на него ссылается событие или коллекция, он останется в куче.

Как устроено

  • События. Делегат хранит ссылку на подписчика. Если издатель живёт дольше (синглтон, статическое событие), подписчик не соберётся.
  • Статика и кеши. static Dictionary или ConcurrentDictionary в роли кеша без вытеснения растёт бесконечно.
  • Таймеры. Активный таймер зарегистрирован в очереди таймеров рантайма, а его callback держит объект-владелец. Если владелец хранит таймер в поле, оба живут до Dispose таймера.
  • Замыкания. Лямбда держит захваченные переменные и this. Сохранённая в событии, кеше или статическом поле, она держит весь граф.
  • DI. Transient-сервис с IDisposable из корневого провайдера контейнер хранит до своего Dispose.

Когда что выбирать

Инструмент Что даёт
dotnet-counters растёт ли куча после сборок
dotnet-gcdump граф объектов: сравнение по типам, путь до корня
dotnet-dump полный дамп: dumpheap -stat, gcroot

Ловушки

  • Dispose не убирает ссылки на объект из коллекций и событий.
  • Отписка нужна от событий долгоживущего издателя. Цикл ссылок внутри недостижимого графа GC соберёт сам.

Правило

Ищите по шагам:

  1. dotnet-counters monitor -p <pid> — убедиться, что размер кучи после сборок растёт.
  2. Два снимка dotnet-gcdump collect -p <pid> с интервалом; открыть в Visual Studio или PerfView и сравнить по типам.
  3. Найти, кто держит растущий тип: Paths to Root в Visual Studio или gcroot в dotnet-dump analyze.

Под капотом

  • Общее замыкание. Компилятор создаёт один класс замыкания на область видимости. Если две лямбды в методе захватывают разные переменные, обе переменные лежат в одном объекте, и долгоживущая лямбда держит чужой большой объект.
  • ExecutionContext. Таймеры и работа в пуле потоков захватывают контекст выполнения, а с ним значения AsyncLocal<T>. Таймер, созданный внутри запроса, держит данные этого запроса.
  • Корни. gcroot заканчивает цепочку на корне: статическом поле, сильном или закреплённом GC-хендле, очереди финализации.

Цена

  • dotnet-gcdump вызывает полную сборку, которая на большой куче надолго останавливает процесс.
  • dotnet-dump содержит всю память процесса со значениями полей и весит столько же.

Компромиссы

.gcdump легче и безопаснее для прода, но в нём нет значений полей. Полный дамп нужен, когда важны данные, например какой ключ растёт в кеше.

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

  1. Сравнить dotnet.gc.last_collection.heap.size и dotnet.process.memory.working_set (имена .NET 9+). Растёт только working set — утечка нативная.
  2. Нагрузить, дождаться устойчивого состояния, снять дамп; повторить через несколько минут.
  3. Сравнить dumpheap -stat или diff двух .gcdump: интересен тип, у которого растёт число экземпляров.
  4. Выполнить 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;
}
Ловушка: общее замыкание · Senior
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 МБ)
}
Senior+
Ответ

Что это

  • 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: True
Console.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}");