RE:NODE

Приложения11 мин чтения

Память и сборка мусора в .NET внутри контейнеров

Почему .NET-приложение использует столько памяти: Server и Workstation GC, DATAS, лимиты контейнера, GCHeapHardLimit, чтение настоящих цифр и поиск утечки.

0 прочтений

Потребление памяти .NET-приложением - это в основном решение сборщика мусора, а не вашего кода. Приложения ASP.NET Core по умолчанию работают с Server GC, который меняет память на пропускную способность: держит отдельную кучу на каждый CPU и собирает реже; консольные приложения и worker services работают с Workstation GC, который собирает чаще и остаётся компактнее. Внутри контейнера runtime читает лимит памяти и по умолчанию ограничивает управляемую кучу 75% от него, а начиная с .NET 9 Server GC подстраивает число куч под фактический размер приложения (DATAS). Так что небольшой API, который «использует 400 МБ» на тарифе с 4 ГБ, - это часто просто GC, которому дали место и который им пользуется, а то же приложение на тарифе с 1 ГБ будет работать на гораздо меньшем объёме.

В этой статье объясняется, что GC делает с выделенной ему памятью, какие настройки это меняют, как читать цифры, которые действительно важны, и как отличить утечку от кучи, которая просто большая.

Куда уходит память .NET-процесса#

График памяти показывает весь процесс. Управляемая куча - объекты, которые выделяет ваш код, - самая большая часть, но не единственная:

  • Куча GC, разделённая на поколения. Новые объекты попадают в поколение 0; выжившие продвигаются в 1, а затем в 2. Объекты размером 85 000 байт и больше сразу попадают в кучу больших объектов (LOH), которая собирается вместе с поколением 2 и по умолчанию не уплотняется. У закреплённых (pinned) объектов с .NET 5 своя куча.
  • Выделенное, но пустое место в куче. После сборки GC оставляет себе память, которая, как он ожидает, понадобится снова, а не возвращает её операционной системе сразу. Это главная причина, по которой график не опускается после всплеска трафика.
  • Код, скомпилированный JIT, и данные runtime: метаданные типов, таблицы методов, скомпилированный код каждого выполнявшегося метода. Большое приложение с множеством зависимостей несёт десятки мегабайт этого независимо от трафика.
  • Нативная память: всё, что выделяется вне GC, - драйверы баз данных с нативными частями, библиотеки для работы с изображениями, буферы сжатия, SQLite.
  • Стеки потоков, по одному на поток. Обычно в сумме немного, если только что-то не создаёт сотни потоков.

Отсюда следуют два разных сбоя. Когда управляемая куча достигает своего лимита, GC выбрасывает OutOfMemoryException внутри вашего приложения. Когда весь процесс достигает лимита контейнера, ядро останавливает его снаружи, без исключения и без последней строки в логе. В RE:NODE контейнер, достигший лимита памяти, останавливается и перезапускается начисто, а не уходит в подкачку, так что второй случай проявляется как перезапуск. Сторона ядра описана в статье swap в Linux и OOM killer.

Workstation и Server GC#

В .NET два варианта GC, и какой из них вы получите, зависит от типа проекта, а не от сервера:

Workstation GCServer GC
По умолчанию дляКонсольных приложений, worker servicesПроектов ASP.NET Core (Web SDK)
КучиОднаПо одной на логический CPU (подстраивается DATAS)
Потоки GCСобирает в потоке, вызвавшем сборкуВыделенный поток на каждую кучу
Бюджет поколения 0МаленькийБольшой
Использование памятиНижеВыше
Пропускная способностьНиже под нагрузкойВыше под нагрузкой

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

Более крупные бюджеты Server GC означают, что он дольше выделяет память между сборками и всё это время держит больше памяти. На машине с 16 ядрами и без лимита процесс с Server GC может занимать несколько сотен мегабайт, почти ничего не делая. Отсюда и фольклор о том, что ASP.NET Core прожорлив до памяти. Runtime оптимизирует пропускную способность, потому что ему сказали, что он может себе это позволить.

Два правила о значениях по умолчанию, которые важны на небольших тарифах:

  1. Один CPU означает Workstation GC. Если runtime видит один процессор, он использует Workstation GC, что бы ни было указано в настройке. Тариф с половиной vCPU обычно выглядит как один процессор (см. ниже), так что небольшое приложение ASP.NET Core на самом маленьком тарифе уже работает с экономным сборщиком.
  2. Можно выбрать явно. В файле проекта или через переменную окружения:
xml
<PropertyGroup>  <ServerGarbageCollection>false</ServerGarbageCollection>  <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection></PropertyGroup>
env
DOTNET_gcServer=0

Переключение веб-приложения на Workstation GC на тарифе с двумя ядрами заметно снижает потребление памяти и немного стоит пропускной способности при высокой частоте запросов. Для приложения, которое обслуживает несколько запросов в секунду, разницы в пропускной способности вы не увидите; разницу в памяти - увидите.

DATAS: Server GC, который сам подбирает размер#

Dynamic Adaptation To Application Sizes (DATAS) меняет Server GC с «по одной куче на ядро, всегда» на «столько куч, сколько оправдывает нагрузка этого приложения». Он начинает с одной кучи и добавляет новые по мере роста давления выделений, а бюджет поколения 0 подстраивает под размер живых данных, а не под число ядер.

В .NET 8 он включался по желанию, а начиная с .NET 9 включён по умолчанию вместе с Server GC. Если вы на .NET 9 или 10 и приложение использует Server GC, он у вас уже есть. Практический эффект в том, что тихое приложение ASP.NET Core на машине с множеством ядер больше не резервирует память так, будто работает под полной нагрузкой.

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

env
DOTNET_GCDynamicAdaptationMode=0

Эквивалент в MSBuild - <GarbageCollectionAdaptationMode>0</GarbageCollectionAdaptationMode>. В .NET 8 задайте 1, чтобы его включить. Для небольших и средних приложений на скромных тарифах оставьте DATAS включённым.

Что runtime видит внутри контейнера#

Runtime читает лимиты cgroup контейнера, как cgroup v1, так и v2, и подстраивает две вещи.

Лимит памяти. Когда у контейнера есть лимит памяти, GC задаёт жёсткий лимит управляемой кучи в 75% от него (или 20 МБ, смотря что больше). Оставшаяся четверть остаётся для нативной памяти, кода, стеков и всего остального, что нужно процессу. На тарифе с 1 ГБ управляемая куча может вырасти примерно до 768 МБ; на 4 ГБ - примерно до 3 ГБ.

Число CPU. Environment.ProcessorCount отражает квоту CPU контейнера, округлённую вверх до целого. Лимит в половину CPU выглядит как 1; полтора - как 2. От этого числа зависит, сколько куч может создать Server GC, как пул потоков выбирает свой размер и сколько всего runtime делает параллельно. В RE:NODE лимит CPU - это жёсткое ограничение до купленной доли, так что «2 процессора» на тарифе с 1,5 vCPU - верное число, но щедрое представление о времени: процесс получает 150% времени одного ядра на все эти потоки, не больше. Число можно переопределить через DOTNET_PROCESSOR_COUNT, если значение по умолчанию приводит к слишком большому числу куч или потоков.

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

Настройки, которые меняют потребление памяти#

Настройкаruntimeconfig / MSBuildПеременная окруженияЭффект
Server GCServerGarbageCollectionDOTNET_gcServer0 - workstation, 1 - server
Конкурентный GCConcurrentGarbageCollectionDOTNET_gcConcurrentФоновые сборки поколения 2
Жёсткий лимит кучиSystem.GC.HeapHardLimitDOTNET_GCHeapHardLimitАбсолютный предел управляемой кучи
Лимит кучи в процентахSystem.GC.HeapHardLimitPercentDOTNET_GCHeapHardLimitPercentПредел как доля лимита
Экономия памятиSystem.GC.ConserveMemoryDOTNET_GCConserveMemory0-9; больше уплотняет, чтобы сэкономить память
DATASGarbageCollectionAdaptationModeDOTNET_GCDynamicAdaptationMode1 - включён, 0 - выключен

Когда трогать лимит кучи: если ваше приложение использует значительный объём нативной памяти - библиотеку обработки изображений, большой кэш SQLite, встроенный движок, - запаса по умолчанию в 25% может не хватить, и процесс будет убит лимитом контейнера раньше, чем GC вообще почувствует давление. Снижение управляемого лимита до 60% даёт нативному коду больше места и заставляет GC собирать активнее и раньше. Обратный случай, повышение до 85% и выше, безопасен только для приложений почти без нативных выделений.

GCConserveMemory - для приложений с сильной фрагментацией LOH: большие буферы, которые многократно выделяются и освобождаются, оставляют дыры, которые GC не уплотняет. Значение около 5 заставляет его уплотнять LOH при высокой фрагментации ценой некоторого расхода CPU.

Как читать цифры, которые имеют значение#

Рабочий набор процесса (цифра на графике) говорит, упрётесь ли вы в лимит. Он не говорит почему. Для этого спросите GC:

csharp
app.MapGet("/debug/memory", () =>{    var info = GC.GetGCMemoryInfo();    return new    {        heapMB = info.HeapSizeBytes / 1_048_576,        committedMB = info.TotalCommittedBytes / 1_048_576,        fragmentedMB = info.FragmentedBytes / 1_048_576,        limitMB = info.TotalAvailableMemoryBytes / 1_048_576,        workingSetMB = Environment.WorkingSet / 1_048_576,        gen0 = GC.CollectionCount(0),        gen1 = GC.CollectionCount(1),        gen2 = GC.CollectionCount(2),        serverGc = System.Runtime.GCSettings.IsServerGC,        cpus = Environment.ProcessorCount    };});

Спрячьте его за аутентификацией или удалите после использования. Как это читать:

  • limitMB - память, которая, по мнению GC, у него есть. Если там вся память хоста, а не ваш тариф, runtime не обнаружил лимит контейнера, и ничего из этой статьи не применимо, пока он его не обнаружит.
  • heapMB в сравнении с workingSetMB показывает, управляемый ли это рост (ваши объекты) или нативный (что-то вне GC).
  • committedMB намного выше heapMB - это память, которую GC держит для повторного использования. Это не утечка.
  • Быстро растущий gen2 при стабильной нагрузке означает, что объекты живут достаточно долго, чтобы их продвинули, а потом умирают, - часто это кэш без ограничения размера или объекты запроса, захваченные чем-то долгоживущим.

Для более глубокого анализа диагностические инструменты dotnet-counters и dotnet-gcdump читают те же данные вживую и снимают снимок кучи, который можно открыть в Visual Studio или PerfView. Запускать их нужно там же, где работает процесс, - на VDS это просто, в остальных местах зависит от окружения. Начиная с .NET 9, runtime также публикует эти данные как встроенные метрики на meter System.Runtime, так что экспортер OpenTelemetry может отправлять их туда, где вы храните метрики. График в панели разобран в статье как читать график нагрузки сервера.

Утечки и то, что на них похоже#

Настоящая утечка - это память, которая неограниченно растёт при стабильной нагрузке и никогда не собирается, потому что на неё всё ещё что-то ссылается. В .NET обычные виновники такие:

  • Неограниченные кэши. Статический Dictionary или ConcurrentDictionary, который только пополняется. IMemoryCache не ограничен, если не задать SizeLimit и не указать Size для каждой записи.
  • Обработчики событий на долгоживущих объектах. Подписка короткоживущего объекта на событие singleton-объекта навсегда удерживает короткоживущий объект, если он не отписывается.
  • Disposable-объекты из корневого провайдера. Transient IDisposable, полученный из корневого провайдера сервисов приложения, а не из scope, отслеживается до завершения приложения. Получайте его из scope, особенно в фоновых сервисах.
  • Долгоживущий `DbContext`. Трекер изменений EF Core хранит каждую загруженную сущность. Контекст, который держат живым в singleton или фоновом цикле, растёт, пока его не освободят. Используйте scope на единицу работы и AsNoTracking() для чтения.
  • Буферизованные большие запросы и ответы. Чтение всей загрузки в MemoryStream кладёт её в LOH; несколько одновременных загрузок по 50 МБ - это несколько сотен мегабайт. Вместо этого пишите потоком на диск или в объектное хранилище.

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

Как подобрать тариф для .NET-приложения#

Это рабочие цифры для framework-dependent приложения на .NET 8-10 при умеренной нагрузке. Измерьте своё; зависимости меняют их сильнее всего остального.

ПриложениеТипичный рабочий наборС какого тарифа начать
Бот для Discord или worker service60-150 МБ1 ГБ
Minimal API без ORM50-120 МБ1 ГБ
API с EF Core, небольшой трафик120-300 МБ1-2 ГБ
Сайт на Razor Pages или MVC150-400 МБ2 ГБ
Blazor Server, десятки пользователей300 МБ плюс состояние на пользователя2-4 ГБ

Добавьте запас на сборку, если компилируете на сервере: SDK и компилятору нужно больше памяти, чем большинству небольших приложений во время работы. Перенос сборки в CI описан в статье деплой приложения ASP.NET Core с GitHub, а почему Blazor Server масштабируется по пользователям, а не по запросам, объясняет статья Blazor Server или WebAssembly.

Переходите на тариф побольше, когда нижний уровень графика памяти, а не пик, выше примерно 70% лимита, или когда приложение перезапускается на лимите. В RE:NODE перезапуск на лимите памяти учитывается наблюдателем за сбоями - три за час выводят предупреждение и открывают тикет, - так что утечка недолго остаётся незамеченной. Переход на тариф выше меняет лимит на существующем сервере, а не пересоздаёт его.

FAQ#

Почему моё приложение ASP.NET Core использует больше памяти на большом тарифе?

Потому что GC сообщают, что у него больше памяти и больше CPU, и он подбирает бюджеты соответственно. Приложение использует больше не потому, что ему это нужно; оно собирает реже, потому что может. При меньшем лимите оно будет собирать чаще и оставаться компактнее.

Стоит ли вызывать GC.Collect(), чтобы освободить память?

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

Workstation GC медленнее для веб-приложения?

При тяжёлой конкурентной нагрузке - да. Для приложения на одном-двух CPU с умеренным трафиком разницу трудно измерить, а экономия памяти реальна. В контейнере с одним CPU вы и так уже работаете на Workstation GC.

Что происходит, когда управляемая куча достигает жёсткого лимита?

GC собирает изо всех сил и, если этого недостаточно, выбрасывает OutOfMemoryException на неудавшемся выделении. Если весь процесс первым упирается в лимит контейнера, ядро останавливает его вообще без исключения.

Использует ли Native AOT меньше памяти?

В простое - да, потому что нет JIT и меньше метаданных runtime. Под нагрузкой GC ведёт себя так же. Чего Native AOT стоит в плане совместимости, описано в статье параметры dotnet publish.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000