Учебное описание сборщика мусора .NET короткое и успокоительное: короткоживущие объекты освобождаются в поколении 0, выжившие поднимаются выше, редкие долгожители оседают в поколении 2, а потому о памяти можно не думать. Долгоживущий сервис на продакшене рассказывает другую историю. Потребление памяти ползёт вверх неделями, хотя профайлер не находит ни одной утечки. Редкие паузы дорастают до сотен миллисекунд и попадают в графики p99. Контейнер со строгим лимитом памяти периодически уходит в перезапуск. Ничто из этого не нарушает документацию, просто она описывает механизм, а не поведение под реальной нагрузкой. В тексте ниже собраны главные расхождения модели с жизнью: подвижные бюджеты поколений, неуплотняемая куча больших объектов и аппетит серверного режима по ядрам.

Формальное устройство поколений сборщика мусора из документации

Модель обещает три поколения. Объект рождается в поколении 0, компактном и дешёвом в сборе: когда бюджет поколения исчерпан, сборщик проходит по нему, выжившие копируются в поколение 1, а мусор просто забывается. Поколение 1 работает буфером между мгновенными и вечными объектами. Поколение 2 требует полной блокирующей сборки, самой дорогой, потому что обходит и уплотняет огромные объёмы. Рядом лежит куча больших объектов LOH: массив или строка от 85 000 байт попадает туда сразу, минуя молодые поколения, и по умолчанию не уплотняется вовсе. Сборки поколений 0 и 1 всегда останавливают все управляемые потоки, зато длятся единицы миллисекунд. Поколение 2 умеет собираться в фоновом режиме: фоновая сборка появилась ещё в .NET Framework 4.0 и позволяет потокам работать, пока сборщик сканирует старые объекты. Запуск сборки тоже описан формально. Поколения 0 и 1 живут в эфемерном сегменте, непрерывном куске памяти фиксированного размера. Когда аллокации исчерпывают отведённый объём, сборщик останавливает потоки, размечает живые объекты и уплотняет сегмент снизу вверх, попутно поднимая выживших. Бюджет привязан к размеру сегмента и подстраивается, поэтому два одинаковых сервиса на двух машинах показывают разные частоты сборок.

Проверить механику можно в пять строк:

var buffer = new byte[1000];
Console.WriteLine(GC.GetGeneration(buffer));
GC.Collect();
Console.WriteLine(GC.GetGeneration(buffer));

После одной полной сборки буфер числится в поколении 2, хотя ничего вечного в нём нет. Это первая трещина между моделью и жизнью: любой объект, переживший неудачный момент, например задержавшийся ответ внешнего API, мгновенно становится долгожителем со всеми издержками старших поколений.

Реальная судьба поколений после недели работы под нагрузкой

В учебных примерах выживаемость поколения 0 близка к нулю, поэтому цикл коротких сборок крутится сам собой. В сервисе с кешем, долгими сессиями и подписками на события картина иная. Часть объектов переживает каждый сбор и оседает в поколениях 1 и 2 надолго: записи кеша, делегаты подписок, отложенные задачи, синглтоны конфигурации. Аллокации не прекращаются, и сборщик подстраивается. Бюджеты поколений не константы, а подвижные величины: чем больше памяти свободно, тем щедрее бюджет, чтобы реже останавливать потоки. Отсюда типичная кривая недели: потребление похоже на лестницу, ступени растут после каждой волны нагрузки, а когда нагрузка спадает, память не возвращается. Утечки нет, есть адаптация. Документация права, память освободится. Она просто не обещает, когда именно.

Таймер, который держит замыкание на большой граф объектов, продлевает жизнь всему графу до собственного освобождения. Обработчик, подписанный на событие долгоживущего источника и не отписанный, переживает все мыслимые сборки: источник держит делегат, делегат держит объект, объект держит всё остальное. Арифметика молодого поколения тоже перестаёт сходиться: при скорости аллокаций в 300 мегабайт в секунду и бюджете поколения 0 в 50 мегабайт сборщик запускает шесть молодых сборок в секунду, и каждая копирует выживших.

Второй сюрприз приносят финализаторы. Объект с финализатором переживает минимум одну лишнюю сборку: сначала он попадает в очередь финализации, и только потом, отдельным проходом, освобождается по-настоящему. Если финализаторы медленные или их накопилось много, освобождение отстаёт от аллокаций на секунды. Третий сюрприз прячется в паузах: полные сборки случаются считаные разы в сутки, но каждая длится десятки и сотни миллисекунд, потому что куча успела вырасти до десятков гигабайт. В графике p99 это редкие, но жирные пики, которые клиенты замечают первыми.

Большая куча объектов и фрагментация которой не видно в диспетчере задач

Порог в 85 000 байт мал. В него попадает один большой JSON ответ, один буфер чтения из сети, один массив строк из маппера. Если сервис выделяет буфер на мегабайт на каждый запрос и сразу освобождает, LOH превращается в решето: новый массив не влезает ни в одну дыру между живыми объектами, и сборщик расширяет сегменты. Диспетчер задач показывает выделенную память, и там всё выглядит сносно до момента, когда расширение сегмента упирается в лимит контейнера. Уплотнение по требованию существует:

GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();

Оно копирует содержимое большой кучи и обновляет ссылки, стоит дорого и вызывается вручную, а не по расписанию, поэтому годится как разовая мера, а не стиль жизни. С .NET 5 появился и другой путь, закреплённая куча POH: аллокации через GC.AllocateArray с флагом pinned уходят в отдельное место и не портят компактность обычных поколений.

var pinned = GC.AllocateArray<byte>(1_000_000, pinned: true);

У больших аллокаций есть эффект, о котором документация упоминает мимоходом: память под объекты LOH учитывается в бюджете молодых поколений, поэтому серия крупных буферов провоцирует сборки чаще, чем подсказывает интуиция. Сериализация крупного ответа, конкатенация длинных строк и чтение файла целиком делают одно и то же: арендуют место в большой куче, отрабатывают, оставляют дыры. Код, который на маленьких тестах работает безупречно, на больших объёмах входа обнаруживает себя ростом частоты сборок.

Серверный сборщик ест память пропорционально числу ядер

У сборщика есть два режима, и умолчания у них разные. Workstation GC ведёт одну кучу и один поток сборки. Server GC создаёт отдельную кучу и отдельный поток на каждое логическое ядро и собирает их параллельно: пропускная способность растёт, а нижняя планка потребления памяти растёт тоже, на многоядерной машине сразу в разы. Для веб приложений ASP.NET Core шаблон Web SDK включает Server GC по умолчанию, поэтому сервис на 32 ядрах, обслуживающий скромный поток запросов, держит гигабайты под кучи, хотя workstation справилась бы компактнее. Проверить режим можно строкой:

Console.WriteLine(GCSettings.IsServerGC);

Тот же параметр читается в runtimeconfig.json, где рядом живут свойства System.GC.Server и System.GC.Concurrent. Мелким сервисам и утилитам обычно хватает workstation, большие параллельные нагрузки требуют server, а выбор проверяют измерением. В .NET 8 поведение сгладил режим DATAS: серверный сборщик приспосабливает число куч к нагрузке и включается по умолчанию. Приложения на ранних версиях .NET Core продолжают платить полной ценой.

Настройка режимов в файле проекта и переменных окружения контейнера

Режим сборщика задаётся в файле проекта одной строкой:

<PropertyGroup>
  <ServerGarbageCollection>false</ServerGarbageCollection>
  <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>

В контейнере удобнее переменные окружения, они меняют поведение без пересборки образа:

export DOTNET_gcServer=0
export DOTNET_GCHeapHardLimitPercent=30
export DOTNET_GCRetainVM=0

Когда контейнеру назначен лимит памяти, сборщик по умолчанию ограничивает кучу 75 процентами лимита. Команды узнают об этом после первого перезапуска по памяти: планка оставляет мало запаса на стеки, буферы ввода вывода и нативные зависимости. Переменная RetainVM отвечает за возврат освобождённых страниц операционной системе: нулевое значение возвращает сразу, единица придерживает на случай новых аллокаций. Для участков, чувствительных к паузам, есть управление латентностью:

GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;

Режим запрещает блокирующие сборки поколения 2 и разрешает фоновые, паузы остаются короткими, а память растёт активнее. Это честный компромисс, который документация описывает словами, а продакшен проверяет графиками. Для коротких критических участков существует и жёсткий инструмент, регион без сборки:

if (GC.TryStartNoGCRegion(64 * 1024 * 1024))
{
    try
    {
        CriticalPath();
    }
    finally
    {
        GC.EndNoGCRegion();
    }
}

Вызов резервирует память под аллокации и запрещает сборки внутри региона. Если лимит исчерпан, среда бросает исключение, размер резерва подбирают по фактическому профилю участка.

Приёмы снижения давления на сборщик от ArrayPool до Span

Главный рычаг не настройка, а аллокации: каждая выделенная страница рано или поздно превращается в работу сборщика. Буферы запросов разумно арендовать из общего пула:

var buffer = ArrayPool<byte>.Shared.Rent(64 * 1024);
try
{
    var read = await stream.ReadAsync(buffer);
}
finally
{
    ArrayPool<byte>.Shared.Return(buffer);
}

Кеш стоит ограничивать не только числом записей, но и весом, иначе он размажется по поколению 2 и будет вечно удерживать сегменты:

services.AddMemoryCache(o => o.SizeLimit = 500_000_000);

_cache.Set(key, payload, new MemoryCacheEntryOptions
{
    Size = payload.Length,
    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});

Горячие пути чистят от лишних копий: Span и Memory читают срезы без новых массивов, string.Create собирает строки сразу в финальном буфере, а stackalloc уносит маленькие буферы в стек, мимо кучи.

Span<char> scratch = stackalloc char[64];

Крупные ответы выгодно стримить, а не собирать целиком: PipeReader и PipeWriter читают и пишут поток по кускам, не выстраивая в памяти весь массив байт. Чтение файла или ответа внешнего API порциями по 32 килобайта держит аллокации в молодых поколениях и не касается большой кучи.

Короткая проверка на код ревью выглядит так:

  1. выделяется ли на пути запроса массив больше 85 000 байт;
  2. возвращается ли арендованный буфер в блоке finally;
  3. ограничен ли кеш по весу, а не только по числу записей;
  4. отписывается ли обработчик от долгоживущего источника событий.

Наблюдение за сборщиком утилитами dotnet и кодом метрик

Понимание поведения начинается с чисел. Счётчики среды показывают частоту сборок по поколениям, скорость аллокаций и долю времени в сборке:

dotnet-counters monitor --process-id 7312 --counters System.Runtime[alloc-rate,gen0-gc-count,gen1-gc-count,gen2-gc-count,loh-size,time-in-gc]

Здесь же виден главный симптом старения: аллокации не растут, а время в сборке ползёт вверх. Снимок кучи в конкретный момент собирает утилита dotnet-gcdump, подробный дамп разбирает dotnet-dump с расширением SOS, где команда dumpheap -stat ранжирует типы по занимаемому месту. Собственные метрики собираются в коде без сторонних агентов:

var gen0 = GC.CollectionCount(0);
var gen1 = GC.CollectionCount(1);
var gen2 = GC.CollectionCount(2);
var info = GC.GetGCMemoryInfo();
var heapBytes = info.HeapSizeBytes;
var fragmentedBytes = info.FragmentedBytes;

Структура GCMemoryInfo дополнительно отдаёт длительности последних пауз и их долю от общего времени, так что дашборд пауз собирается в несколько строк. Для Windows остаётся мощный инструмент, утилита PerfView: она показывает не только типы, но и цепочки ссылок, удерживающих подозрительные объекты.

Учебная модель поколений не обманывает, она просто описывает правила игры, а не саму партию. Правила говорят: выжившие поднимаются, большие объекты лежат отдельно, серверный режим быстрее. Партия добавляет: бюджеты растут, фрагментация копится, куча больших объектов не уплотняется сама, а каждое ядро добавляет аппетита. Долгоживущий сервис прощает небрежные аллокации в первый день и предъявляет счёт на второй неделе. Зрелые команды читают описание механизма со счётчиками открытой вкладкой: модель объясняет, что происходит, а графики показывают, чем это кончится.