Владение памятью: сравнение Unity и сборки мусора .NET

Владение памятью: сравнение Unity и сборки мусора .NET

14 июня 2023 г.

Всем привет, меня зовут Дмитрий Иващенко, я инженер-программист в MY.GAMES. В этой статье мы обсудим различия между сборкой мусора в Unity и .NET.

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

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

Сначала рассмотрим, как работает управление памятью в .NET.

Управление памятью в .NET

Common Language Runtime (CLR) — это среда выполнения для управляемого кода в .NET. Любой высокоуровневый код .NET, написанный на таких языках, как C#, F#, Visual Basic и т. д., компилируется в промежуточный язык (IL-код), который затем выполняется в среде CLR. В дополнение к выполнению кода IL среда CLR также предоставляет несколько других необходимых служб, таких как безопасность типов, границы безопасности и автоматическое управление памятью.

Управляемая куча

При инициализации нового процесса CLR он резервирует непрерывную область адресного пространства для этого процесса — это пространство называется управляемой кучей. Управляемая куча хранит указатель на адрес, по которому в куче будет размещен следующий объект. Первоначально этот указатель установлен на базовый адрес управляемой кучи. Все ссылочные типы (часто также и типы значений) помещаются в управляемую кучу.

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

Выделение памяти из управляемой кучи выполняется быстрее, чем выделение неуправляемой памяти. Например, если вы хотите выделить память в C++, вам нужно будет сделать системный вызов операционной системы, чтобы сделать это. При использовании CLR память уже зарезервирована ОС при запуске приложения.

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

Алгоритм CLR GC

Алгоритм сборки мусора (GC) в CLR основан на нескольких соображениях:

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

На основе этих проверенных временем предположений алгоритм сборщика мусора CLR построен следующим образом. Существует три поколения объектов:

* Поколение 0: все новые объекты входят в это поколение. * Поколение 1: объекты из поколения 0, пережившие одну сборку мусора, перемещаются в это поколение. * Поколение 2: объекты из поколения 1, пережившие вторую сборку мусора, перемещаются в это поколение.

Каждое поколение имеет собственное адресное пространство в памяти и обрабатывается независимо от других. При запуске приложения сборщик мусора помещает все созданные объекты в пространство поколения 0. Когда места для другого объекта становится недостаточно, запускается сборка мусора для поколения 0.

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

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

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

После обработки всех мертвых объектов из поколения 0 сборщик мусора перемещает оставшиеся активные объекты в адресное пространство поколения 1. Став частью поколения 1, объекты реже рассматриваются сборщиком мусора как кандидаты на удаление.

Со временем, если очистка поколения 0 не дает достаточно места для создания новых объектов, выполняется сборка мусора для поколения 1. Граф строится заново, мертвые объекты снова удаляются, а уцелевшие объекты из поколения 1 перемещаются в поколение 2.

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

Некоторые детали намеренно опущены в этом тексте для простоты понимания, но, тем не менее, это позволяет нам увидеть сложность и продуманность организации управления памятью в .NET. Теперь, вооружившись этими знаниями, давайте посмотрим, как это организовано в Unity.

Управление памятью в Unity

Поскольку игровой движок Unity написан на C++, он, очевидно, использует некоторый объем памяти для своей среды выполнения, которая недоступна для пользователей (это называется собственная память). Также важно выделить особый тип памяти, называемый неуправляемой памятью C#, который используется при использовании структур Unity Collections, таких как NativeArray<T> или NativeList<T>. ;. Все остальное используемое пространство памяти является управляемой памятью и использует сборщик мусора для выделения и освобождения памяти.

Из-за отсутствия CLR управление памятью в приложениях Unity осуществляется средой выполнения сценариев (Mono или IL2CPP). Однако следует отметить, что эти среды не так эффективны в управлении памятью, как .NET. Одним из самых неприятных последствий этого является фрагментация.

Мфрагментация памяти

Фрагментация памяти в Unity — это процесс, при котором доступное пространство памяти делится на разбросанные блоки. Фрагментация происходит, когда приложение постоянно выделяет и освобождает память, что приводит к разделению свободного места в памяти на множество непрерывных блоков разного размера. Фрагментация памяти может быть двух типов:

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

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

Сборщик мусора Boehm-Demers-Weiser

Unity использует консервативный сборщик мусора Boehm-Demers-Weiser (BDW), который останавливает выполнение вашей программы и возобновляет нормальное выполнение только после завершения своей работы. Алгоритм работы BDW можно описать следующим образом:

  1. Остановить мир: сборщик мусора приостанавливает выполнение программы для сбора мусора.
  2. Корневое сканирование: сканирует корневые указатели, определяя все активные объекты, напрямую доступные из программного кода.
  3. Трассировка объектов: отслеживание ссылок из корневых объектов для определения всех доступных объектов и создания графа доступных объектов.
  4. Подсчет ссылок: подсчитывается количество ссылок на каждый объект.
  5. Освобождение памяти: сборщик мусора освобождает память, занятую объектами, на которые нет ссылок (мертвые объекты).
  6. Всемирное возобновление: после завершения сборки мусора выполнение программы продолжается.

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

Инкрементная сборка мусора

Начиная с Unity 2019.1, BDW по умолчанию используется в добавочном режиме. Это означает, что сборщик мусора распределяет свою рабочую нагрузку между несколькими фреймами вместо того, чтобы останавливать основной поток ЦП (сборка мусора в мире) для обработки всех объектов в управляемой куче.

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

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

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

Давайте сравним:

| | единство (Бем-Демерс-Вайзер) | .NET GC | |----|----|----| | Алгоритм | Консервативный сборщик мусора | Генерационный сборщик мусора | | Среда выполнения | Моно или IL2CPP | .NET Core, .NET Framework, .NET 5+ | | Сканирование корня | Менее точное сканирование корня | Точное сканирование корня | | Отслеживание объектов | Да | Да | | Подсчет ссылок | Да | Нет | | Сжатие памяти | Нет | Да (кроме крупных объектов) | | поколения | Нет | Да (0, 1 и 2) | | Скорость | Медленнее из-за накладных расходов и отсутствия компактификации | Быстрее благодаря подходу поколений и точному сканированию корня | | Остановить мир | Да | Да (но с меньшим влиянием на производительность из-за добавочной сборки мусора) | | Обработка фрагментации | Склонен к фрагментации (из-за отсутствия уплотнения объекта) | Уменьшенная фрагментация (за счет сжатия объектов) |

Таким образом, без CLR GC в Unity мы получаем механизм, склонный к фрагментации кучи и медленной, громоздкой сборке мусора в пространстве всех объектов, не разделенных на поколения. Рассмотрим последствия из этого, которые стоит учитывать при разработке игр в Unity.

Как избежать ошибок, связанных с ограничениями Unity GC

Из-за ограничений Unity Incremental GC можно выявить следующие болевые точки:

* Вызовы GC дороже, чем в .NET (должен обрабатываться весь граф объектов, а не только его подмножество). * Частое создание и удаление объектов приводит к фрагментации памяти; Промежутки между объектами могут быть заполнены только новыми объектами такого же или меньшего размера. * Частые изменения в отношениях между объектами затрудняют работу с режимом инкрементного GC (циклы GC занимают больше кадров и снижают FPS). * Слишком частые изменения отношений объектов (в каждом кадре) приводят к переходу GC в неинкрементный режим; вместо того, чтобы распределять запуски GC по нескольким кадрам, мы получаем одну большую остановку мира, пока сборка мусора не будет завершена.

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

* Бокс: избегайте передачи переменных типа значения вместо переменных ссылочного типа. Это создает временный объект и потенциальный мусор, который неявно связан с ним, преобразуя типы значений в типы объектов. Простой пример:

<код>csharp день = 15; целый месяц = ​​10; целый год = 2023; Debug.Log(string.Format("Дата: {0}/{1}/{2}", день, месяц, год));

Может показаться, что поскольку день, месяц и год являются типами-значениями и определены внутри функции, не будет никаких аллокаций или мусора. Но если мы посмотрим на реализацию string.Format(), то увидим, что аргументы имеют тип object, а это значит, что будет происходить упаковка значимых типов и они будут помещены в кучу:

<код>csharp /// Заменяет элементы формата в указанной строке строкой /// представление трех указанных объектов. формат общедоступной статической строки (формат строки, объект arg0, объект arg1, объект arg2);

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

* Сопрограммы: хотя оператор yield сам по себе не создает мусора, создание нового объекта WaitForSeconds создает:

```csharp частный IEnumerator BadExample() { пока (правда) { // Создание здесь нового объекта WaitForSeconds вызывает генерацию мусора. yield return new WaitForSeconds (1f); }

частный IEnumerator GoodExample() { // Кэшировать объект WaitForSeconds, чтобы избежать генерации мусора. WaitForSeconds waitForOneSecond = new WaitForSeconds(1f);

while (true)
{
  // Reuse the cached WaitForSeconds object.
  yield return waitForOneSecond;
}

} ```

* Замыкания и анонимные методы. В общем, по возможности старайтесь избегать использования замыканий в C#. Сведите к минимуму использование анонимных методов и ссылок на методы в коде, связанном с производительностью, особенно если код выполняется в каждом кадре. Ссылки на методы в C# являются ссылочными типами и располагаются в куче. Это означает, что когда вы передаете ссылку на метод в качестве аргумента, может произойти временное выделение памяти. Это происходит независимо от того, передается ли анонимный метод или метод уже определен. Более того, когда вы превращаете анонимный метод в замыкание, объем памяти, который необходимо передать методу замыкания, значительно увеличивается.

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

* Функции Unity: обратите внимание, что некоторые функции выделяют память в куче. Сохраняйте ссылки на массивы в кеше, а не размещайте их внутри цикла. Кроме того, используйте функции, которые не генерируют мусор. Например, предпочтительнее использовать GameObject.CompareTag вместо сравнения строк с GameObject.tag (поскольку возврат новой строки создает мусор). Используйте нераспределяющие альтернативы методам API Unity, например Physics.RaycastNonAlloc.

Если вы знаете, что в определенный момент игры процесс сборки мусора не повлияет на игровой процесс (например, на экране загрузки), вы можете инициировать сборку мусора, вызвав System.GC.Collect< /код>. Тем не менее, лучшая практика, к которой нужно стремиться, — это нулевое выделение. Это означает, что вы резервируете всю необходимую вам память в начале игры или при загрузке определенной игровой сцены, а затем повторно используете эту память на протяжении всего игрового цикла. В сочетании с пулами объектов для повторного использования и использованием структур вместо классов эти методы решают большинство проблем с управлением памятью в игре.


Оригинал