Рубрики
Программирование

Как работает сборщик мусора в .NET Core: архитектура, поколения и оптимизация производительности

«В .NET память чистится сама» — опасное заблуждение, которое рано или поздно приводит к трехчасовой отладке посреди ночи перед деплоем. Сборщик мусора в .NET Core действительно автоматизирует рутину, но у этой автоматизации есть своя цена и свои строгие правила. Незнание того, как объекты попадают в Gen 2, почему массивы любят фрагментировать кучу и как новые механизмы вроде DATAS меняют поведение рантайма, может стоить вашему проекту десятков процентов потерянной производительности. Так что давайте разберём архитектуру современного GC и научимся дружить свой код со сборщиком мусора.

Введение

В экосистеме C# управление памятью скрыто за высоким уровнем абстракции. Разработчик оперирует практически одинаково как значимыми типами, так и ссылочными, создает объекты через оператор new и делегирует вопросы их жизненного цикла среде исполнения. Это дает невероятную скорость разработки и защищает от классических ошибок низкоуровневых языков вроде висячих указателей или двойного освобождения памяти. Однако автоматизация не означает отсутствие стоимости.

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

  • Контейнеризация: Docker-контейнеры имеют жесткие лимиты по RAM и CPU. Поведение GC внутри контейнера сильно отличается от поведения на физической машине.
  • Облачные вычисления: В бессерверных архитектурах (Serverless, Azure Functions/AWS Lambda) время холодного старта и плотность размещения инстансов напрямую зависят от прожорливости приложения к памяти.
  • Highload-сервисы: При обработке тысяч запросов в секунду частота аллокаций возрастает экспоненциально. Если каждый запрос выделяет хотя бы несколько килобайт временных объектов на куче, сборщик мусора начинает работать непрерывно, съедая драгоценные такты процессора.

Когда ваше API внезапно перестает укладываться в SLA, а графики мониторинга показывают аномальные паузы без видимой нагрузки на бизнес-логику, причина часто кроется именно в Garbage Collector, а точнее в том, что вы не учли особенности его работы.

Понимание работы сборщика мусора необходимо для решения трех практических задач:

  1. Диагностика производительности. Умение читать метрики GC позволяет отличить проблему медленного алгоритма от проблемы избыточных аллокаций. Например, если вы видите частую смену поколений Gen 2 (Full GC), значит, ваше приложение генерирует слишком много долгоживущего мусора (например, пересоздает пул строк или массивов вместо переиспользования).
  2. Проектирование архитектуры. Знание того, как работают поколения, влияет на выбор структур данных. Вы начнете задумываться: стоит ли использовать class (ссылочный тип, который всегда попадает в кучу и давит на GC) или можно обойтись struct (тип значения, который может жить на стеке)? Стоит ли передавать огромные DTO-объекты между слоями или лучше ограничиться интерфейсами и ленивой загрузкой?
  3. Оптимизация затрат (FinOps). Эффективное использование памяти позволяет запускать сервис на меньших вычислительных мощностях. Снижение среднего потребления RAM со 8 ГБ до 4 ГБ в Kubernetes-кластере из сотен подов экономит существенный бюджет облачного провайдера.
Типичные заблуждения о сборке мусора в .NET Core:
  • «Если нет утечек памяти, GC настроен идеально«. Неверно. Приложение может потреблять гигабайты памяти абсолютно легально, но вызывать паузы сборки каждые две секунды, убивая пропускную способность.
  • «Структуры (structs) живут на стеке и бесплатны для GC«. Только отчасти. Стек действительно управляется простым смещением указателя, но структуры попадают в кучу, когда являются полями класса, элементами массива или закрывают внешний контекст в лямбда-выражениях.
  • «Оператор new в C# дорогой«. Сам вызов аллокатора в CLR невероятно быстр (часто сводится к простому инкременту указателя текущей кучи). Дорогим является последующий цикл жизни объекта, его выживание после нескольких сборок и финальная очистка.
  • «Если что, я просто вызову GC и он сразу и заранее всё уберёт«. У нас, конечно, есть GC.Collect, но если внимательно читать документацию, то там написано что вызов этого метода не гарантирует немедленное выполнение, так как GC очень сложный и тяжёлый механизм и CLR может отложить выполенние уборки.

Так что давайте снимем крышку капота с современного GC в .NET Core. Вместо абстрактной теории мы разберем конкретные механизмы:

  • Как рантайм решает, что пора запустить сборку (пороги аллокаций против мониторинга свободной памяти хоста).
  • Чем алгоритм пометки (Mark-and-Sweep) отличается от алгоритма копирования и почему .NET использует гибридный подход.
  • Как появление фонового сборщика (Background GC) изменило правила игры для бэкенд-серверов, позволив почти полностью избавиться от пауз Stop-The-World при очистке Gen 2.
  • И главное — как писать код, который минимизирует давление на эту систему, используя современные возможности языка (типы ref struct, Span<T>, пулы ArrayPool<T> и новую кучу Frozen Object Heap).

К концу этого материала вы перестанете воспринимать сборщик мусора как неизбежное зло или магическую вещь и научитесь рассматривать его как мощный инструмент, правильное взаимодействие с которым отличает стабильно работающий highload-сервис от системы, разваливающейся под нагрузкой.

Базовые концепции: управляемая куча, стек и корни

Чтобы понять, как работает сборщик мусора, нужно сначала разобраться в анатомии памяти .NET-приложения. В отличие от языков вроде C++, где память — это просто непрерывный массив байтов, за которыми программист следит вручную, в .NET Core существует управляемая куча (Managed Heap). Это выделенный регион виртуальной памяти, которым единолично владеет CLR (Common Language Runtime).

Как устроена управляемая куча

Когда вы пишете var user = new User(), происходит следующее:

  1. Рантайм вычисляет необходимый размер объекта.
  2. Указатель кучи (heap pointer, часто обозначаемый как NextObjPtr) сдвигается вперед на этот размер.
  3. Память под объект обнуляется (это гарантирует, что статические поля изначально будут равны null или 0).
  4. Возвращается ссылка на начало этого блока памяти.

Этот процесс аллокации невероятно быстр — по сути, это инкремент указателя. Именно поэтому создание миллионов мелких объектов в секунду само по себе не тормозит CPU. Проблемы начинаются позже, когда эта куча заканчивается.

И всё было бы просто, но так не интересно, по этому у нас есть два вида памяти — это стеки потоков и куча. С ними связано много разных аспектов и потому важно различать эти две области. Во-первых, у каждого потока есть свой собственный стек, а куча это память общая для всего процесса. Во-вторых, стек и куча хранят разные типы данных. Стек потока хранит переменные значимых типов (примитивных, таких как int, double и тому подобные, enum и struct) и ссылки на объекты. Сами же объекты (и «упакованные» объекты значимых типов) всегда живут в куче (за редким исключением, но поскольку этого можно добиться только явным образом, то опустим этот момент). Если метод возвращает ссылку на объект, созданный внутри него, этот объект остается в куче живым, даже если стек метода уже «размотался» при выходе из функции. Тоже верно и для «упакованных» объектов, но о них поговорим чуть позже.

Но главное для нас это процес очистки. Со стеком всё просто — он устроен как LIFO‑структура (Last In, First Out) и привязан к вызовам методов. Его «очистка» — это просто сдвиг указателя стека:

  • При выходе из метода указатель стека просто возвращается на старое значение. Всё, что было в этом блоке, перестаёт считаться валидным. Никакой перебор, маркировка или освобождение памяти не нужны.
  • При входе в метод выделяется блок под локальные переменные и параметры (просто указатель сдвигается вперёд).

Именно поэтому в C# локальные значимые типы (int, struct) и ссылки на объекты (но не сами объекты) обычно живут на стеке и «удаляются» мгновенно при выходе из области видимости.

Но с кучей всё несколько иначе, так как она предназначена для объектов с недетерминированным временем жизни:

  • Объект может быть создан в одном методе, а использоваться в десятке других;
  • На него могут быть ссылки из статических полей, коллекций, замыканий, асинхронных state machines и т. д.;
  • Время «смерти» объекта не привязано к выходу из метода.

Поэтому нельзя просто «откатить указатель»: нужно понять, достижим ли объект ещё где‑нибудь. Соответственно для каждого объекта нам надо проверить является ли он достижимым и если нет, то сначала удалить его, а затем уплотнение кучи, чтобы не усложнить создание новых объектов. Это дорого по времени и требует пауз (или сложных барьеров), но без этого нельзя: иначе будут утечки или преждевременное удаление объектов, на которые ещё есть ссылки. И вот тут есть несколько подходов, которые объединяет Garbage Collector.

Алгоритм Чейни / Semi-Space

Первый метод это алгоритм Чейни. Суть в том, чтобы находить в памяти объекты, на которые уже нет ссылок из кода программы («мусор»), и освобождать занимаемую ими память. При этом алгоритм приостанавливает работу программы на время сборки, а кучу делят на две равные половины. Процесс сборки происходит в несколько этапов:

  1. Остановка мира (stop-the-world): Все пользовательские потоки приостанавливаются, чтобы граф объектов не менялся во время обхода.
  2. Обмен ролями: Если до этого рабочим было пространство А, а назначением — Б, то теперь они меняются местами. Прежнее «To-space» становится чистым листом.
  3. Поиск корней: Сборщик берет список корней (стек, статические поля).
  4. Копирование:
    • Для каждого корня, указывающего на объект в старом «From-space», алгоритм проверяет: был ли этот объект уже скопирован?
    • Обычно для этого используется техника индикаторного указателя (forwarding pointer). При первом копировании объекта в новое место старый адрес перезаписывается ссылкой на новый адрес.
    • Объект копируется в «To-space» по адресу, куда указывает NextObjectPtr.
    • Корень обновляется, чтобы указывать на новую копию.
    • Указатель NextObjectPtr сдвигается вперед на размер скопированного объекта.
  5. Чейнинг (переподключение ссылок): Внутри скопированного объекта могут быть ссылки на другие объекты, которые еще лежат в «From-space». Сборщик обходит только что созданные копии в «To-space». Если он находит ссылку, которая все еще ведет в «From-space», он копирует и тот целевой объект тоже (используя индикаторный указатель, чтобы не плодить дубликаты одного и того же объекта).
  6. Завершение: Когда все корни и их дочерние связи пройдены, весь «From-space» объявляется свободным. Теперь это абсолютно чистый, непрерывный кусок памяти

И хотя этот алгоритм в чистом виде не используется в чистом виде (ведь тогда пришлось бы выделять в два раза больше памяти), но он необходим для более полного понимания работы. А вот то, что напрямую используется, так это алгоритм Mark-and-Sweep.

Алгоритм поиска живых объектов: Mark-and-Sweep

Здесь сборщик мусора не удаляет объекты из кучи сразу после того, как они вышли из области видимости в коде. Суть в том, чтобы определить, какие объекты в памяти больше не нужны программе, и освободить занятую ими память. Его работа состоит из двух основных фаз:

  1. Фаза маркировки (Mark): Все объекты в куче помечаются как недостижимые и после этого GC начинает построение графа достижимых объектов. Он начинает свой путь от так называемых «корней» и идет по ссылкам от одного объекта к другому. Все объекты, до которых дотянулся алгоритм пометки, считаются живыми. В итоге мы получаем список объектов которые можно разделить на две группы: помечены они как достижимые или же нет.
  2. Фаза очистки (Sweep): GC проходит по всей куче. Те объекты, которые не были помечены как достижимые, признаются мусором. Их память либо освобождается, либо компактируется (сдвигается), чтобы избежать фрагментации.

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

Что такое «корни» (GC Roots)?

Корни — это точки входа, через которые приложение может достучаться до объекта. Если от корней нельзя проложить цепочку ссылок к вашему объекту, он считается мертвым.

Основные типы корней в .NET Core:

  • Стек потоков (stack roots): аргументы методов и локальные переменные. Пример: Внутри метода Process() есть строка User u = GetCurrent(). Пока выполнение кода находится внутри этого метода, переменная u является корнем, удерживающим объект User в памяти.
  • Статические поля (static references): объекты, на которые ссылаются статические переменные классов. Они живут ровно столько, сколько живет домен приложения (AppDomain) или сам процесс. Любое кеширование через static — это гарантированное попадание объекта в Gen 2 (о поколениях мы поговорим чуть позже).
  • Таблицы дескрипторов (handle tables / GCHandle): мосты между управляемой (.NET) и неуправляемой памятью (C-библиотеки, WinAPI). Когда вы передаете указатель на управляемый объект нативному коду, CLR закрепляет его в памяти с помощью GCHandle, создавая искусственный корень. Сборка мусора не тронет этот объект, пока нативный код не закончит работу. Если забыть вызвать Free() для GCHandle, объект останется закреплённым навсегда (до конца жизни процесса), даже если на него нет других ссылок, что может приводить к утечкам.
  • Очередь финализации (finalization queue): объекты, у которых определен деструктор (финализатор, ~ClassName()). Даже если на объект больше никто не ссылается, он становится корнем для себя самого до тех пор, пока не будет выполнен его метод Finalize().
  • Объекты помещённые во Frozen Object Heap (FOH): это относительно новое понятие (появилось в .NET 8). Объекты в FOH сами по себе являются долгоживущими структурами данных, но важно понимать: сборщик мусора никогда не сканирует FOH во время обычных сборок, в отличии от других видов кучи, включая POH (Pinned Object Heap). Эти данные вынесены за пределы основного графа маркировки для экономии ресурсов.

Цепочки ссылок и циклические зависимости

Одной из главных причин появления автоматических сборщиков мусора стала проблема циклических ссылок. В старом COM-моделировании (на базе подсчета ссылок) если Объект А ссылался на Б, а Б ссылался на А, они оставались в памяти вечно, даже если остальная программа потеряла к ним доступ.

В .NET Core счетчик ссылок не используется. Для алгоритма Mark-and-Sweep неважно, сколько ссылок указывает на объект. Важно только одно: можно ли добраться до него из любого активного корня. Если Объект А и Объект Б ссылаются друг на друга, но ни один живой поток, статическое поле или хендл не указывает на А или Б — оба они будут уничтожены.

Слабые ссылки (WeakReference)

Иногда приложению нужны данные, которые полезно держать в памяти, но удалять первыми при нехватке RAM (например, кэш изображений). Обычная ссылка жестко держит объект, а значит сборщик не может очистить память, но здесь нам на помощь приходит WeakReference<T>. Такой объект остается доступным для использования, но не считается корнем. Если начнется сборка мусора, GC очистит память под слабую ссылку одним из первых, превратив ее значение в null.

В C# выделяют два типа слабых ссылок:

  • Короткие (short weak references) — не отслеживают воскрешение объекта при финализации. Это поведение по умолчанию.
  • Длинные (long weak references) — сохраняют ссылку даже после вызова метода Finalize, то есть отслеживают воскрешение объекта.

По умолчанию, создавая объект со слабой ссылкой и используя конструктор WeakReference<T>(T target), мы создаём именно короткую ссылку. Для создания же длинной ссылки нужно явно указать тип ссылки вызвав перегрузку конструктора вида WeakReference<T>(T target, bool trackResurrection). Если trackResurrection = true, создаётся длинная слабая ссылка.

Важно помнить что использование слабых ссылок требует особой внимательности. Так даже если метод IsAlive вернул true, объект может быть собран сразу после проверки. Поэтому всегда сначала присваивайте значение Target, а затем проверяйте его на null. В целом слабые ссылки — инструмент для конкретных сценариев, а не универсальное решение всех проблем с памятью. Иногда лучше явно управлять жизненным циклом объектов.

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

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

Чем длиннее эта цепочка и чем чаще она пересекает границы поколений кучи, тем дороже обходится владение этим объектом.

Поколения объектов (Generations)

Если бы сборщик мусора каждый раз при нехватке памяти сканировал абсолютно все объекты в управляемой куче, приложение проводило бы большую часть времени в паузах (Stop-The-World). Чтобы избежать этого, CLR использует гипотезу о поколениях (Generational Hypothesis), подтвержденную статистикой миллионов приложений:

  1. Большинство объектов умирают молодыми. Это временные DTO, локальные переменные циклов, строки конкатенации.
  2. Мало объектов живут долго. Это синглтоны, глобальные кэши данных, конфигурации приложения.

Основываясь на этом, GC делит управляемую кучу на три изолированные области — поколения.

Gen 0: Эфемерное поколение (молодые объекты)

Это место рождения почти всех новых объектов. Куча для Gen 0 совсем небольшая (обычно несколько мегабайт).

Когда указатель аллокации достигает конца сегмента Gen 0 (а значит память для этого поколения закончилась), запускается сборка. Происходит постоянно, сотни раз в секунду под нагрузкой (в терминах .NET это называется ephemeral collection), но при этом на данном этапе не оказывает серьёзного влияния на производительность, поскольку большинство объектов здесь недостяжимы и фаза маркировки заканчивается очень быстро. Выжившие же объекты перемещаются в первое (следующее) поколение, но из-за небольшого числа таких объектов накладные расходы также малы. После перемещения «выживших» объектов, ссылки на них обновляются, а вся память Gen 0 помещается как свободная.

Gen 1: Буферное поколение

Gen 1 служит демпфером или фильтром между короткоживущим мусором и долгоживущими данными. Его задача — поймать объекты, которые чуть-чуть не успели умереть в Gen 0. Объект попадает сюда из Gen 0 после успешной сборки. Просесс сборки Gen 1 отличается от Gen 0 лишь тем, что если объект переживает и сборку Gen 1, то тогда он отправляется в «тяжелое» третье поколение Gen 2. Чаще всего размер Gen 1 превышает размер Gen 0, но вызывается реже и расходы на выполнение ниже чем на очистку Gen 2.

Тем не менее, частые вызовы очистки Gen 1 служат индикатором того, что ваше приложение генерирует слишком много среднесрочного мусора (например, вы создаете тяжелые списки внутри циклов вместо переиспользования буферов).

Механизм выживания и продвижения (Survival and Promotion)

Когда GC собирает Gen 0 или Gen 1, он проверяет каждый живой объект.

  1. Если объект жив, его возраст увеличивается.
  2. После выживания (или если объект изначально был большим и попал в LOH), он получает «прописку» в следующем поколении.
  3. Как только объект попал в Gen 2, выйти оттуда он уже не может. Даже если он станет нужен всего на одну минуту жизни запроса, он будет считаться «стариком» до самого завершения процесса и будет удалён только при полной сборке.

Работа сборщика мусора устроена так из-за того, что если первые два поколения небольшие и по сути представляют из себя отдалённую вариацию на тему алгоритма Чейни (хотя и не являются его реализацией в чистом виде), то последнее поколение Gen 2 обрабатывается иначе.

Gen 2: Долгоживущие объекты

Наконец мы дошли до последнего поколения Gen 2. Сюда попадают объекты, доказавшие свою живучесть. Это данные сессий, кэширующие структуры (Dictionary, ConcurrentBag), статические коллекции, дерево компонентов UI в десктопных приложениях и так далее.

Именно для Gen 2 и выполняется Mark-and-Sweep. Это самая дорогая операция, которую выполняет CLR. Она может занимать от десятков миллисекунд до секунд на сильно нагруженных сервисах с десятками гигабайт RAM. Пауза в 500 мс для высоконагруженного веб-сервиса означает таймаут сотен входящих запросов. Именно поэтому попадание объекта в Gen 2 по ошибке (так называемый premature promotion) считается серьезной проблемой производительности. Так же стоит помнить, что сборка Gen 2 часто сопровождается полной очисткой всей кучи (включая LOH — Large Object Heap), хотя и не всегда.

Large Object Heap (LOH) — куча больших объектов

Технически LOH не является отдельным поколением, но неразрывно связана с ними. Любой массив или строка размером более 85 000 байт (правило порога TLA — Threshold for Large Objects) выделяется сразу в LOH.

Из-за своего размера такие объекты выравниваются по границам страниц ОС. Аллокатор не может просто сдвинуть указатель; ему нужно найти достаточно большой свободный непрерывный блок памяти.

В отличие от обычных поколений, LOH исторически не сжималась (не компактифицировалась) по умолчанию, так как сдвигать блоки по 100 МБ — крайне дорого. Со временем память там превращалась в «швейцарский сыр». CLR мог запустить агрессивную компактификацию LOH только при острой нехватке памяти. Объекты в LOH всегда считаются частью Gen 2. Если вы создали огромный временный буфер для чтения файла, он попал прямиком в Gen 2/LOH. Его быстрая смерть никак не поможет освободить эту память без запуска тяжелой Full GC.

Тем не менее, в .Net 5 появилась возможность включить автоматическую компактификацию LOH через настройки (<HeapCompactionMode>), а также изоляция закрепленных объектов в Pinned Object Heap (POH), чтобы они не мешали этой компактификации.

Практический вывод для разработчика:

Главная цель оптимизации кода под GC — сделать так, чтобы ваши объекты умирали молодыми, никогда не достигая Gen 2 или же помещались в FOH.

  • Используйте пулы объектов (ArrayPool<T>) для переиспользования больших буферов.
  • Старайтесь избегать создания множества временных объектов среднего размера (от 1 КБ до 85 КБ), которые забивают Gen 1 и провоцируют преждевременное продвижение мусора в Gen 2.
  • Помните про Frozen Object Heap (FOH): если вам нужно хранить неизменяемый справочник из тысяч элементов, помещение его в FOH полностью снимет нагрузку по его сканированию с Gen 2, так как замороженные объекты выпадают из стандартного цикла Mark-and-Sweep.

Гибридная стратегия работы

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

  • Для старых объектов мы выбираем стабильность адресов и экономию памяти, принимая сложность поиска свободных блоков и периодически запуская тяжелую компрессию.
  • Для молодых объектов мы выбираем скорость выделения, жертвуя расходами на лишнюю операцию копирования (которой почти нечего копировать).

Режимы работы GC

Но даже гибридный подход всё ещё не даст нужной производительности, особенно если учесть что POH и FOH появились не сразу. Поэтому сборщик мусора в .NET Core адаптируется под тип нагрузки и аппаратное обеспечение через разные режимы работы. Выбор режима определяет, сколько потоков будет задействовано для сборки, как будут распределяться кучи по ядрам и какой приоритет получит процесс у операционной системы.

Существует два основных физических режима (Workstation и Server), каждый из которых может работать с использованием разных алгоритмов планирования пауз.

Workstation GC (<ServerGarbageCollection>false</ServerGarbageCollection>)

Этот режим исторически был включен по умолчанию во всех приложениях (включая старые версии ASP.NET). В современных версиях (.NET 6+ Console apps) он также остается дефолтным, если явно не указано иное.

  • Для кого: десктопные приложения (WPF, WinForms), простые консольные утилиты, однопроцессорные сервисы.
  • Архитектура: используется только одна общая управляемая куча на весь процесс. Все потоки вашего приложения делят одну и ту же кучу.
  • Поведение при сборке:
    • Поскольку куча одна, все пользовательские потоки должны остановиться (Stop-The-World) на время маркировки объектов, чтобы никто случайно не изменил граф ссылок во время обхода.
    • Чтобы интерфейс или логика не «фризились», используется фоновая сборка (Background GC). Во время очистки Gen 2 основной поток может кратковременно возобновить работу, пока фоновые потоки дочищают старую память.
  • Главный плюс: минимальное потребление памяти (всего один набор сегментов кучи) и быстрый холодный старт.
  • Главный минус: узкое горлышко. На многоядерных серверах этот режим становится бутылочным горлышком, так как аллокации со всех потоков упираются в блокировки одной кучи.
Server GC (<ServerGarbageCollection>true</ServerGarbageCollection>)

Это золотой стандарт для бэкенд-сервисов, микросервисов и высоконагруженных API. Начиная с .NET 6, шаблоны dotnet new web автоматически включают Server GC.

  • Для кого: Серверные приложения, развернутые на многоядерных машинах (облака, Kubernetes, физические серверы).
  • Архитектура: Для каждого логического ядра процессора выделяется отдельная куча (Heap) и отдельный поток сборщика. Если у вас 8-ядерный CPU, рантайм создаст 8 параллельных куч.
  • Поведение при сборке:
    • Сборки Gen 0 и Gen 1 происходят параллельно на всех ядрах одновременно. Это дает колоссальный прирост пропускной способности (throughput).
    • Потоки разных куч могут обмениваться объектами друг с другом (объект, созданный в куче потока №1, может быть передан потоку №3), но делают это через специальные очереди, минимизируя блокировки.
  • Главный плюс: Максимальная скорость выделения памяти и очистки. Отсутствие глобальных блокировок на этапе создания объектов.
  • Минусы: Высокое потребление оперативной памяти (каждая куча резервирует свои сегменты) и более долгий холодный старт (CLR нужно породить множество потоков перед началом выполнения кода).

Параллельная и фоновая сборка

Внутри обоих режимов важно то, как именно GC справляется с очисткой долгоживущего мусора (Gen 2 / LOH):

  1. Concurrent GC (для Workstation): пока работает фоновая сборка, пользовательский код может выполняться. Однако, если ваш код во время этой паузы попытается изменить ссылку на объект, который уже был проверен сборщиком, сработает защитный механизм — приложение будет кратковременно остановлено для корректировки графа. Это обеспечивает хорошую отзывчивость UI ценой небольшой потери общей производительности.
  2. Background GC (BGC) (для Server): более совершенный механизм. Ключевая идея заключается в том, что пока идёт фоновая сборка мусора для поколения 2 (в отдельном выделенном потоке), среда выполнения параллельно может запускать сборку поколений 0 и 1. Это позволяет не блокировать приложение надолго и сохранять его отзывчивость. Когда приложение выделяет достаточно объектов в поколении 0, среда CLR инициирует сборку мусора переднего плана (foreground collection) для этих поколений. В этот момент все управляемые потоки приостанавливаются. Выделенный поток фоновой сборки регулярно проверяет «безопасные точки» (checkpoints), чтобы понять, не возникла ли такая приостановка. Если да, то фоновый поток останавливается, и выполняется сборка переднего плана. После её завершения фоновый поток и пользовательские потоки возобновляют работу. Достигается это за счёт того что BGC умеет откладывать сжатие памяти до следующего цикла, если оно занимает слишком много времени, отдавая приоритет скорости возврата управления приложению.

Dynamic adaptation to application sizes (DATAS)

Жесткий выбор между Workstation и Server часто бывал избыточен. Например, контейнеру в Kubernetes выделено всего 2 ядра — классический Server GC там неэффективен, а Workstation не справится с нагрузкой. Для этого в .Net 6 был добавлен ещё один режим — режим динамической адаптации (Dynamic adaptation to application sizes).

Режим DATAS включает эвристику:

  • CLR постоянно следит за частотой аллокаций и доступной памятью хоста.
  • Если нагрузка растет, GC динамически увеличивает размеры сегментов куч, чтобы собирать мусор реже.
  • Если нагрузка падает или хост сообщает о нехватке RAM (через cgroups), GC уменьшает размеры куч, провоцируя более частые, но короткие сборки, чтобы освободить память. Это избавляет от необходимости вручную подбирать параметры <GCHeapHardLimit> для большинства сценариев.

Режимы экономии памяти (Memory Load Throttling)

Если операционная система сигнализирует о критической нехватке памяти (High Memory Pressure), GC переходит в агрессивный режим экономии памяти добавленный в .Net 8:

  • Увеличивается частота полных сборок;
  • Включается принудительная компактификация даже тех областей, которые обычно не сжимаются;
  • Приоритет отдаётся освобождению страниц памяти обратно ОС, даже если это стоит дополнительных циклов CPU.

Как выбрать и настроить?

Выбор задается в файле .runtimeconfig.json:

{
  "runtimeOptions": {
    "configProperties": {
      "System.GC.Server": true,
      "System.GC.Concurrent": true,
      "System.GC.DynamicAdaptationMode": 1
    }
  }
}

Или переменными окружения (удобно для Docker):

  • DOTNET_gcServer=1 — включить Server GC.
  • DOTNET_GCDynamicAdaptationMode=1 — включить адаптивный режим.

Практический совет: всегда включайте Server GC для любых HTTP-сервера, очередей обработки и сервисов данных. Оставьте Workstation GC только для десктопного ПО или легковесных агентов мониторинга, где экономия RAM важнее пиковой пропускной способности.

Новые виды управляемой кучи

Изначально у нас было по сути только SOH, разделённая на поколения, и LOH. В целом, для обеспечения корректной работы этого достаточно, но со временем возникало всё больше и больше проблем при росте нагрузки. Для того чтобы улучшить работу сервисов под нагрузкой при переходе от .Net Framework к .Net Core не только устранялись ошибки реализации и устаревшие подходы в стандартной библиотеки, но изменялась работа GC. И помимо изменения внутренних механизмов, таких как изменение схемы размещаения объектов в памяти или алгоритма сжатия, были добавлены новые виды кучи.

Pinned Object Heap (POH) — куча закрепленных объектов

До появления выделенной в .Net 5 POH, о которой мы упминали выше, CLR использовал механизм «закрепления» (pinning) прямо внутри обычных поколений кучи. Если вашему коду или нативной библиотеке нужно было передать указатель на managed-объект во внешний мир (например, через fixed в C# или при передаче буфера в WinAPI), этот объект помечался как pinned (закреплённый).

Сборщик мусора не имеет права перемещать закрепленные объекты, так как нативный код хранит их старый адрес. Это создает серьезную проблему для производительности GC.

Проблема фрагментации и компактификации

Главная фишка современного сборщика мусора — это компактификация. После очистки Gen 0 и Gen 1 GC сдвигает выжившие объекты вплотную друг к другу, чтобы избежать фрагментации памяти и ускорить будущие аллокации простым инкрементом указателя. Но давайте представим следующую ситуацию:

  1. В куче лежат три объекта: [A] [B] [C].
  2. Объект B передается в нативный сокет (SocketAsyncEventArgs) и становится pinned.
  3. Происходит сборка мусора. Объекты A и C мертвы, а B жив.
  4. Рантайм очень хочет сдвинуть C на место A, чтобы освободить большой непрерывный блок. Но он не может этого сделать, потому что B приколочен гвоздями к своему адресу.

В итоге между объектами образуются дыры — фрагменты. Если таких pinned-объектов много (что типично для высоконагруженных серверов с активным вводом-выводом), управляемая куча превращается в лоскутное одеяло. Аллокатор больше не может просто двигать указатель вперед; ему приходится искать подходящий по размеру свободный фрагмент, что замедляет создание каждого нового объекта.

Pinned Object Heap решает эту проблему архитектурно.

Как работает POH

Начиная с .NET 5, все новые закрепляемые объекты выделяются сразу в отдельную область памяти — Pinned Object Heap. POH располагается за пределами основной цепочки поколений (Gen 0, 1, 2). Закрепленный объект живет там от рождения до смерти. Такая изоляция поволяет с одной стороны сохранить сжатие обычной кучи и при этом избежать проблем с её фрагментацией. Хотя самой в куче POH тоже нет никакого сжатия памяти и объекты создаются последовательно, но после освобождения оставляют дыры. Однако эти дыры теперь локализованы строго внутри POH и не мешают сборке и компактификации основных поколений. Пока ваш огромный буфер ждет завершения асинхронной записи на диск в POH, Gen 0 продолжает молниеносно чиститься и сжиматься, словно этой задержки и нет. В результате это значительно повышает быстродействие системы.

Важно отметить, что раньше вы могли закрепить объект только на время выполнения блока кода (fixed), после чего GC мог его переместить. Объекты в POH считаются закрепленными навсегда (до момента сборки самого объекта).

Что попадает в POH?

CLR использует POH автоматически для системных нужд, критичных к адресации:

  • Буферы асинхронного ввода-вывода (IO Thread Pool);
  • Внутренние структуры System.Threading.Channels;
  • Данные, передаваемые в неблокирующие сетевые операции.

Кроме того, разработчик также может явно поместить объект в POH программно:

// Пример явного закрепления через GCHandle (теперь направляет в POH)
var buffer = new byte[1024];
GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
    IntPtr pointer = handle.AddrOfPinnedObject();
    // Передаем pointer в нативный метод
}
finally
{
    handle.Free(); // Объект освобождается из пина, но остается жить в POH до следующей сборки
}

Примечание: Сами разработчики Microsoft рекомендуют избегать ручного использования GCHandleType.Pinned. Современный API (Span, Memory, ArrayPool, Channels) спроектирован так, чтобы использовать POH под капотом максимально эффективно и кратковременно.

Мониторинг состояния POH

Для диагностики используйте счетчики производительности (Performance Counters) или команду dotnet-counters:

dotnet-counters monitor --process-id <PID> System.Runtime

Обратите внимание на метрику gc-poh-size. Постоянный рост размера POH без уменьшения — верный признак того, что ваше приложение удерживает слишком много закрепленных буферов, либо нативные вызовы происходят чаще, чем завершается жизненный цикл этих буферов. Также можно анализировать дампы памяти командой dotnet-gcdump, где POH будет виден как отдельная категория кучи.

Тонкости работы и взаимодействие с Frozen Object Heap (FOH)

Другим новым видом кучи является Frozen Object Heap (FOH) — это специальная область памяти в среде выполнения .NET (появилась в версии .Net 8), которая предназначена для хранения неизменяемых (immutable) объектов. Такие объекты живут всё время работы приложения и не ссылаются на другие объекты, не входящие в FOH. И хотя обе кучи находятся вне стандартной иерархии поколений, они служат разным целям:

  • POH — это компромисс ради совместимости с внешним миром. Там живут изменяемые данные, которые нельзя двигать здесь и сейчас. Это вынужденная мера, которая несет накладные расходы (фрагментация).
  • FOH — это оптимизация для неизменяемых данных. Там живут объекты, которые вообще никогда не будут двигаться и сканироваться. Поскольку эти объекты никогда не будут удалены, сборщик мусора может не проверять их во время сборки мусора, что снижает накладные расходы.

Объект не может одновременно находиться и в POH, и в FOH. Логика проста: если вам нужна абсолютная заморозка адреса для максимальной скорости чтения (FOH), вы берете на себя обязательство никогда не менять содержимое объекта. Если же вам нужен изменяемый буфер для обмена данными с ОС (POH), вы платите ценой фрагментации и отсутствия сжатия.

Практический вывод для разработчика: Если ваше приложение активно общается с базами данных, брокерами сообщений или файловой системой напрямую через нативные библиотеки, наличие POH спасает производительность основного приложения. Однако злоупотребление ручным закреплением объектов всё еще вредно. Старайтесь использовать высокоуровневые абстракции (.NET IO, Span<T>, Channels), которые позволяют CLR самому управлять временем жизни закрепленного буфера, минимизируя время его пребывания в POH.

Практика: как писать код, дружелюбный к GC

Оптимизация под сборщик мусора — это не про преждевременную микрооптимизацию каждого байта. Это дисциплина написания кода, который минимизирует «давление» (pressure) на CLR и помогает ему эффективно выполнять свою работу. Главная цель — сократить частоту попаданий объектов в Gen 2 и Large Object Heap.

1. Снижение давления аллокаций (Allocation Pressure)

Самый эффективный способ помочь GC — создавать меньше мусора.

  • Используйте Span<T> и ReadOnlySpan<char>: вместо создания промежуточных строк при парсинге или обработке данных используйте спаны. Они работают со срезами памяти без копирования.
    • Плохо: var name = line.Substring(0, 10).Trim().ToUpper(); (создает три временные строки).
    • Хорошо: Читайте данные напрямую в буфер и обрабатывайте через методы расширения для ReadOnlySpan<char>.
  • Объединяйте операции: библиотеки сериализации (например, System.Text.Json) позволяют записывать данные прямо в поток (PipeWriter), минуя создание гигантских JSON-строк в памяти.
  • Интернирование строк (String Interning): для часто повторяющихся коротких значений (статусы заказов, коды ошибок) CLR автоматически интернирует строки. Однако для больших объемов уникальных данных это вредно, так как интернированные строки попадают в вечноживущий Gen 2. Используйте только для настоящих констант.
  • Осторожнее с LINQ: методы вроде .Select(), .Where(), .ToList() внутри горячих циклов создают делегаты и перечислители в куче. И хотя это может быть более читабельно, в performance-критичном коде заменяйте их простыми for/foreach циклами.

2. Пул объектов вместо постоянного создания

Если объект большой (ближе к порогу LOH в 85 КБ) или создается очень часто, его нужно переиспользовать.

  • ArrayPool<T>.Shared: Ваш лучший друг при работе с сетью и файлами. Вместо new byte[8192] берите массив из пула и возвращайте его обратно.
// Пример работы с сокетами/PipeReader var pool = ArrayPool<byte>.Shared; 
byte[] buffer = pool.Rent(minimumLength: 4096); 
try
{     
    // Чтение данных в buffer
}
finally{    
    pool.Return(buffer);
}

Важно: Не храните ссылку на арендованный массив дольше, чем живет операция, и не изменяйте его размер. Массивы из пула могут быть больше запрошенного размера.

  • Собственные ObjectPool<T>: Для сложных объектов (например, HttpRequestMessage или специфических DTO) можно использовать пул от Microsoft Extensions (Microsoft.Extensions.ObjectPool). Это спасает объекты от попадания в Gen 2.

3. Работа с закрепленной памятью (Pinned Objects)

Как мы выяснили ранее, pinned-память фрагментирует кучу. Старайтесь минимизировать время жизни закрепленных указателей.

  • Избегайте fixed в бизнес-логике: Ключевое слово fixed заставляет компилятор просить GC закрепить объект. Если вы пишете высоконагруженный сервер, старайтесь не передавать сырые указатели во внешний мир вручную.
  • Используйте нативные абстракции .NET: Современные API ввода-вывода (System.IO.Pipelines, SocketAsyncEventArgs, RandomAccess.ReadAsync) сами управляют Pinned Object Heap. Вы работаете с Memory<byte> или Span<byte>, а CLR сам берет буфер из внутреннего пула, закрепляет его на время системного вызова и корректно отпускает. Вам не нужно вызывать GCHandle.Alloc самостоятельно.

4. Осторожно с финализаторами (~ClassName)

Наличие финализатора в классе — это тяжелая ноша для производительности:

  1. Объект с финализатором при создании сразу попадает в отдельную очередь (Finalization Queue).
  2. При сборке Gen 0/1 он не умирает, а переносится в Gen 2, чтобы дождаться отдельного потока-финализатора.
  3. Память освобождается только после того, как финализатор отработает (следующая итерация сборки Gen 2). Вы фактически удваиваете стоимость жизни объекта и гарантированно забиваете долгоживущую кучу.

Реализовывайте интерфейс IDisposable. Вызывайте освобождение ресурсов детерминированно (через using). Если вам все же нужен финализатор для страховки, сделайте его защищенным методом Dispose(bool disposing) и подавите финализацию вызовом GC.SuppressFinalize(this) после ручной очистки.

public class DisposableResourceHolder : IDisposable
{
    private SafeHandle resource;

    public DisposableResourceHolder()
    {
        this.resource = ...
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (disposing)
        {
            if (resource != null) resource.Dispose();
        }
    }
   
    ~DisposableResourceHolder()
    {
        Dispose(false);
    }
}

5. Value Types (struct) vs Reference Types (class)

Не пытайтесь превратить всё в структуры ради спасения от GC. Stack allocation работает только для локальных переменных.

  • Если struct является полем класса, элементом массива или закрывает лямбду (closure) — она лежит в куче и давит на GC точно так же, как класс.
  • Большие структуры (более нескольких десятков байт), передаваемые по значению (void Method(MyBigStruct s)), копируются целиком при каждом вызове, сжигая CPU. Передавайте их через in (readonly ref) или ref.

6. Профилирование перед оптимизацией

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

  • Инструменты Visual Studio / Rider: Вкладка Diagnostic Tools -> Memory Usage. Позволяет делать снимки кучи (snapshots) и смотреть, какие типы занимают больше всего места.
  • PerfView: Индустриальный стандарт для Windows/.NET. Собирает ETW-трассировки и показывает каждый аллокационный стек вызовов.
  • dotnet-trace / dotnet-counters: Для Linux-серверов.
dotnet-counters monitor -p <pid> System.Runtime 

Выполнение этой команды покажет [gen0s, gen1s, gen2s, timeInGc]. Если % Time in GC превышает 20-30%, то у вас серьезная проблема с аллокациями.

  • Конфигурация логирования: Включите EventPipe события GC в продакшене (DOTNET_EnableEventPipe=1), чтобы собирать метрики пауз прямо в Prometheus/Grafana.

Чек-лист аудита горячего пути (Hot Path):

  1. Нет ли здесь скрытого боксинга (упаковки value type в reference type)?
  2. Не создаются ли тут словари или списки внутри цикла?
  3. Можно ли заменить этот Task на ValueTask?
  4. Используем ли мы ArrayPool для буферов этого HTTP-запроса?
  5. Не попали ли наши большие кэширующие коллекции в Gen 2? Можем ли мы сделать их замороженными (FOH)?

7. Не вмешиваться напрямую в работу GC без чёткого понимания того что это необходимо

Несмотря на то, что у нас есть класс System.GC это не значит, что нужно им пользоваться как можно чаще. Более того, некоторые методы могут и вовсе вводить в заблуждение. К примеру, GC.Collect() несмотря на свою семнатику не вызывает немедленную сборку. Он лишь сигнализирует сборщику, что приложение заинтересовано в очистке. Сам сборщик может проигнорировать этот сигнал или выполнить сборку позже, когда это будет более эффективно. Вот некоторые случаи, когда уборка может быть отложена:

  • Использование GCCollectionMode.Optimized;
  • Если приложение работает в режиме низкой задержки (например, GCSettings.LatencyMode = GCLatencyMode.LowLatency);
  • Если в момент вызова уже выполняется сборка мусора (Stop‑The‑World) или совсем недавно завершилась крупная сборка;
  • При жёстких ограничениях памяти (Heap Hard Limit);
  • В некоторых средах выполнения и контейнерах.

Кроме того, даже если GC.Collect выполнился, вы можете не увидеть снижения потребления памяти процессом (RSS/working set). Это не значит, что вызов «проигнорирован»:

  • крупные области памяти (особенно из Large Object Heap) могут оставаться занятыми, пока ОС сама не решит их забрать;
  • .NET часто удерживает память для последующего использования (чтобы не запрашивать её заново у ОС);
  • освобождение памяти ОС происходит асинхронно и не обязательно сразу после сборки.

Другим механизмом, который позволяет напрямую взаимодействовать со сборщиком мусора это механизм Memory Pressure. Для работы с ним класс System.GC предоставляет два парных метода:

// Информирует время выполнения большого выделения неуправляемой памяти
public static void AddMemoryPressure(long bytesAllocated);

// Сообщает CLR, что неуправляемая память была освобождена 
public static void RemoveMemoryPressure(long bytesRemoved);

Параметр bytesAllocated — объём неуправляемой памяти, который вы выделили и который привязан к жизненному циклу управляемого объекта.

Важно: вызовы должны быть сбалансированы. На каждый Add должен быть соответствующий Remove с тем же объёмом.

GC в .NET управляет только управляемой памятью (объекты в куче, поколения 0/1/2, LOH и т. д.). Если ваш управляемый объект держит большой блок неуправляемой памяти (например, нативный буфер, текстуру, аудиоданные), GC об этом не знает. В результате он может редко запускать сборку для поколения 2, хотя реально приложение «давит» на память.

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

Когда вы вызываете AddMemoryPressure, рантайм:

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

Это не меняет алгоритм сжатия или стратегию поколений, но влияет на частоту и момент запуска сборок.

Стоит отметить ряд важных нюансов, которые связаны с использованием AddMemoryPressure на практике:

  • Только для больших объёмов. Microsoft рекомендует использовать эти методы, когда речь идёт о достаточно больших выделениях (обычно порядка сотен килобайт и выше). Для мелких блоков накладные расходы и неточность учёта могут перевесить пользу.
  • Не заменяет IDisposable. Это не способ освободить память, а подсказка для GC, чтобы он лучше планировал сборки. Освобождение неуправляемых ресурсов всё равно делается через Marshal.FreeHGlobal, SafeHandle и т. п.
  • Точность важна. Если вы добавите слишком много давления, GC будет собирать слишком часто и снижать пропускную способность. Если слишком мало — GC будет собирать редко, и приложение рискует получить OOM или работать с высоким RSS.
  • Не нужно при использовании SafeHandle.SafeHandle уже интегрирован с GC и сам управляет давлением на память и финализацией.

GC как партнер, а не противник

Сборщик мусора в .NET Core — это высокоинтеллектуальная система управления памятью, которая проделывает колоссальную работу за кулисами вашего приложения. Как мы увидели, он прошел путь от простого помечающего алгоритма до сложной многопоточной архитектуры с разделением на поколения, фоновой очисткой долгоживущих данных и специализированными кучами для закрепленных (POH) и замороженных (FOH) объектов.

Главный вывод из этой стаитьи прост:

в большинстве случаев современному приложению достаточно настроек по умолчанию

Включение Server GC для бэкенд-сервисов и использование современных типов Span<T>, ArrayPool<T> решают 80% потенциальных проблем с производительностью еще на этапе проектирования. Механизм динамической адаптации (DATAS) позволяет CLR самому подстраиваться под доступные ресурсы контейнера, избавляя разработчика от ручного подбора размеров сегментов кучи.

Чек-лист здорового отношения к памяти в .NET:

  1. Пишите чистый код. Алгоритмическая сложность почти всегда важнее экономии нескольких байт аллокации. Оптимизация циклов даст больше пользы, чем борьба со строковыми интернированиями.
  2. Избегайте преждевременного пулинга. Пул объектов нужен там, где объекты большие или создаются миллионы раз в секунду. Для обычных DTO стоимость аренды из пула может превысить стоимость быстрой сборки Gen 0.
  3. Уважайте границы поколений. Старайтесь, чтобы временные данные умирали в Gen 0. Не храните ссылки на контекст запроса дольше, чем живет сам запрос, иначе вы будете платить ценой Full GC за каждый HTTP-вызов.
  4. Доверяйте инструментам, а не интуиции. Если приложение тормозит, первым делом посмотрите метрики % Time in GC и частоту сборок (gen0s/sec). Высокие показатели здесь — верный признак избыточных аллокаций, а не медленной базы данных.
  5. Используйте правильную конфигурацию. Убедитесь, что ваши контейнеры имеют адекватные лимиты памяти (memory limit), а переменные окружения вроде DOTNET_GCHeapHardLimit установлены корректно, если вы работаете в средах с жестким оверсабскрипшном ресурсов.

Глубокое понимание механики поколений, а также LOH, POH и Frozen Object Heap перестает быть академическим знанием и становится прикладным инструментом именно тогда, когда ваше приложение упирается в потолок производительности (Highload). В этих сценариях умение прочитать трассировку PerfView, понять причину фрагментации Large Object Heap или правильно применить Frozen Object Heap для статичных справочников отличает инженера, поддерживающего систему, от архитектора, способного её масштабировать.

Понимание работы GC делает вас более зрелым C#-разработчиком. Вы перестаете воспринимать память как бесконечный ресурс и начинаете видеть жизненный цикл каждого созданного объекта. Это меняет подход к написанию кода: вместо того чтобы «обманывать» сборщик мусора, вы учитесь писать архитектуру, которая использует его сильные стороны — эффективное управление короткоживущими объектами и автоматическую защиту от утечек.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *