В этой статье я хотел бы рассказать о различиях между сборкой мусора в Unity (IL2CPP) и .NET. Несмотря на то что IL2CPP существует более 10 лет, я до сих пор встречаю недоумение, когда беседа заходит на тему Garbage Collector (GC) касательно Unity. Считать реализацию GC в .NET, ровно как и в JVM, единственным существующим решением — не верно. А тем более принимать такую реализации как "по умолчанию" используемую в Unity — есть заблуждение. Надеюсь эта статья будет полезной, даст верное понимание и устранит заблуждения. Ну а в конце статьи затронем грядущие серьезные изменения в Unity.

Немного теории

Как известно, одним из ключевых преимуществ языка C# является автоматическое управление памятью, которое реализовано в среде Common Language Runtime (CLR).

Так как тема Garbage Collector'а довольно обширная, а по части его реализации имеется множество исследований и опубликованной информации. Для тех кто слабо понимает, что это такое — представьте себе GC как алгоритм, который создает ориентированный граф из ссылок на объекты. Если объект Child используется объектом Parent (через указатель), то граф выглядит следующим образом:

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

Конечно, у большинства объектов будет тот или иной родительский элемент, поэтому сборщику мусора требуется знать, какие Parent-объекты являются наиболее важными, т.е. объектами, которые на самом деле используются вашей программой. В терминологии GC они называются «корнями». Вот пример Parent-объектов с Root и без него:

В этом случае объект Parent 2 не имеет корня, поэтому сборщик мусора может повторно использовать занимаемую им память, а также память его дочернего элемента Child 2. В случае с объектом Parent 1, который имеет Root, и дочерний элемент Child 1, сборщик мусора не может повторно использовать их память, т.к. программа все еще использует эти объекты.

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

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

От общей концепции предлагаю перейти и рассмотреть реализацию GC в .NET.

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

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

Помимо выполнения IL-кода, CLR также предоставляет несколько других необходимых служб, таких как безопасность типов, границы безопасности и автоматическое управление памятью. В среде CLR сборщик мусора (GC) выполняет функцию автоматического диспетчера памяти. Сборщик мусора управляет выделением и освобождением памяти для приложения.

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

После того как среда CLR инициализирует сборщик мусора, она выделяет сегмент памяти для хранения объектов и управления ими. Эта память называется управляемой кучей (managed heap), в отличие от собственной кучи в операционной системе.

Для каждого управляемого процесса существует управляемая куча. Все потоки процесса выделяют память для объектов в одной куче.

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

Выделение памяти в управляемой кучи происходит быстрее, чем выделение неуправляемой памяти. Например, если вы хотите выделить память в C++, для этого вам нужно будет сделать системный вызов операционной системы. В случае с CLR память уже заранее зарезервирована у ОС при запуске приложения. Для работы приложения CLR инициализирует 2 сегмента виртуального адресного пространства — Small object heap (SOH) для объектов до 85 КБ и Large object heap (LOH) для объектов свыше 85 КБ, а в некоторых случаях массивы и связанные списки (linked list), не достигшие данного размера.

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

Алгоритм Garbage Collection (GC) в CLR

Основа алгоритма сборки мусора в CLR базируется на следующем:

  • Новые объекты имеют более короткий срок службы, а старые — более длительный.

  • Уплотнять память для небольшой части управляемой кучи быстрее, чем для всей управляемой кучи сразу.

  • Новые объекты обычно связаны друг с другом и доступны приложению примерно в одно и то же время.

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

  • Поколение 0: все новые объекты переходят в это поколение.

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

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

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

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

  • Ссылки на глобальные объекты (хотя в C# они не разрешены, но CIL-код позволяет размещать глобальные объекты).

  • Ссылки на любые статические объекты и статические поля.

  • Ссылки на локальные переменные и параметры метода.

  • Ссылки на объекты, хранящиеся в стеке потоков.

  • Ссылки на объекты, хранящиеся в регистрах процессора.

  • Ссылки на объект, ожидающий финализации.

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

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

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

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

System.OutOfMemoryException

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

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

Теперь предлагаю взглянуть на то, как этот процесс организован в Unity.

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

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

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

Фрагментация памяти

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

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

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

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

Boehm-Demers-Weiser GC

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

  • Остановка: сборщик мусора приостанавливает выполнение программы при сборке мусора.

  • Сканирование корня: сканирует корневые указатели, определяя все живые объекты, доступные напрямую из программного кода.

  • Трассировка объектов: она следует за ссылками из корневых объектов для определения всех доступных объектов, создавая граф доступных объектов.

  • Подсчет ссылок: подсчитывает количество ссылок на каждый объект.

  • Освобождение памяти: сборщик мусора освобождает память, занятую объектами, не имеющими ссылок (dead objects).

  • Возобновление: после завершения сборки мусора выполнение программы возобновляется.

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

Алгоритм инкрементальной сборки мусора

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

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

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

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

Давайте сравним BDW и .NET GC:

Boehm-Demers-Weiser GC

.NET GC

Алгоритм

Консервативный

Поколенческий

Окружение

IL2CPP

.NET Core, .NET Framework

Сканирование Root

Менее точное сканирование

Точное сканирование

Трассировка объектов

Да

Да

Подсчет ссылок

Да

Нет

Уплотнение

Нет

Да (кроме больших объектов)

Поколения

Нет

Да (3 поколения)

Скорость

Медленнее из-за накладных расходов и отсутствия уплотнения.

Быстрее благодаря поколенческому подходу и точному корневому сканированию.

Остановка на время сборки

Да

Да (но с меньшим влиянием на производительность из-за инкрементальной сборки мусора)

Фрагментация

Склонен к фрагментации (из-за отсутствия уплотнения)

Уменьшенная фрагментация (благодаря наличию уплотнения)

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

Как избежать узкие места Unity GC

У инкрементального GC в Unity можно выделить несколько узких мест:

  • Вызовы GC обходятся дороже, чем в .NET (нужно обрабатывать весь граф объектов, а не только его подмножество).

  • Частое создание и удаление объектов приводит к фрагментации памяти. Пространства между объектами могут быть заполнены только новыми объектами равного или меньшего размера.

  • Частые изменения во взаимоотношениях объектов затрудняют работу в режиме инкрементального GC (циклы GC занимают больше кадров и снижают FPS).

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

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

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

    int day = 27;
    int month = 04;
    int year = 2024;
    Debug.Log(string.Format("Date: {0}/{1}/{2}", day, month, year));

  • На первый взгляд может показаться, что, поскольку переменные day, month и year являются типами значений и определены внутри функции, не будет никаких дополнительных выделений памяти в куче и мусора. Но если мы взглянем на реализацию string.Format(), то увидим, что аргументы относятся к ссылочному типу object, а это значит, что произойдет упаковка типов значений и они будут помещены в кучу: public static string Format(string format, object arg0, object arg1, object arg2);

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

  • Корутины: хотя сам оператор yield не создает мусора, создание нового объекта WaitForSeconds может привести к генерации мусора.

    // Плохой пример
    private IEnumerator BadExample(){
    while (true) {
    // Создание нового объекта WaitForSeconds каждый раз - приводит к генерации мусора.
    yield return new WaitForSeconds(1f);
    }
    }

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

    while (true) {
    // Повторное использование кэшированного объекта WaitForSeconds.
    yield return waitForOneSecond;
    }
    }

  • Замыкания и анонимные методы. Следует избегать замыканий в C# и свести к минимуму использование анонимных методов и ссылок на методы в коде, чувствительном к производительности (в циклах), и особенно в коде, который выполняется для каждого кадра.

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

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

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

    // Хороший пример использования анонимного метода для сортировки списка.
    // Этот метод сортировки не создает мусор
    List listOfNumbers = getListOfRandomNumbers();

    listOfNumbers.Sort( (x, y) =>
    (int)x.CompareTo((int)(y/2))
    );

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

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

    У некоторых API Unity есть альтернативные версии, которые не вызывают выделения памяти. Вы должны использовать их, когда это возможно. В следующей таблице показан небольшой набор распространенных API-интерфейсов с выделением памяти и их нераспределяющих альтернатив. Например: Physics.RaycastNonAlloc, Animator.parameterCount, Animator.GetParameter, Renderer.GetSharedMaterials

Вывод

Сборщики мусора используемые в Unity и .NET существенно отличаются друг от друга. Реализация BDW GC уступает решению .NET, а потому чтобы избежать возможных проблем с проектом в процессе исполнения, стоит особо щепетильно отнестись к узким местам.

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

В настоящее время компания Unity ведет работу по интеграции .NET CoreCLR, включая высокопроизводительный, более продвинутый и более эффективный сборщик мусора (GC), в свой движок. За этим можно следить у них на форуме.

Так что с уверенностью можно сказать, что всех разработчиков на Unity ждут приятные изменения.

Комментарии (4)


  1. mynameco
    28.04.2024 23:30
    +1

    У меня мечта. Многие выделения, часто в рамках одного метода. Тот же боксинг. Вот если бы компилятор мог доказывать время жизни для таких обьектов, а clr поддерживала такие хитрые обьекты. То можно было бы и на стеке их создавать. Это бы ускорило все.


    1. kolebynov
      28.04.2024 23:30
      +2

      В репе .NET есть дизайн док на эту тему, но когда это будет реализовано неизвестно.


    1. semens
      28.04.2024 23:30
      +3

      Читал у них про это где-то в issue гитхаба. Насколько я понял были эксперименты и они не оказали какого-то должного эффекта на производительность.

      По быстрому поискал и нашёл такой комментарий:

      We have spent quite a lot of time on automatically detecting good candidates for stack allocation in the JIT. https://github.com/dotnet/coreclr/issues/20253
      is the uber issue for it, it has link to the design doc. The code with
      the prototype is in master. It is disabled because of it did not show
      enough benefits.


    1. max_mustermann
      28.04.2024 23:30
      +2

      Еще немного и вы rust изобретёте ))