Введение

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

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

Эта статья более релеватна к Unreal Engine 4.27 однако много всего работает так же и на версии UE 5.0 и на более низких версиях. Однако, стоит отметить, что, с версии 4.0 до 4.27 сборка мусора претерпела весьма значительные изменения и стала сильно лучше.

Зачем нужна сборка мусора?

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

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

  • Утечки памяти — ситуация при которой на выделенную память, не осталось ссылок, но она еще не освобождена и до сих пор принадлежит процессу. Тем самым она просто занимает место в ОЗУ.

Есть так же ряд более узких проблем, например:

  • Повторное удаление памяти, которая уже была удалена ранее

  • Невозможность определить “жив” ли объект на который ссылается указатель или нет

  • Падение с ошибкой Out of memory

Чтобы решать эти проблемы в некоторых ООП языках, средах или фреймворках существует механизм сборки мусора — Garbage collection или просто GC. Сборка мусора — это один из процессов управления использованием оперативной памяти в приложении, который призван решить описанные выше проблемы и освободить разработчика от ручного управления памятью:

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

  • И сигнализирует остальной программе о удалении объектов сбрасывая ссылки на них. Это решает проблему висячих ссылок.

Общий механизм сборки мусора

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

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

Достижимость объекта

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

Для определения достижимости объекта (в Unreal Engine тоже) используется два алгоритма:

  • Алгоритм подсчета ссылок

  • Алгоритм выставления флагов

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

Алгоритм выставления флагов решает эту проблему и вводит некоторые новые понятия:

  • Жесткая и слабая ссылка — Наличие жестких ссылок на объект влияет на его достижимость, наличие слабых ссылок - нет;

  • Флаг достижимости — если флаг установлен - объект является достижимым, если нет - его можно удалить;

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

В общем смысле объект достижим, если на него есть жесткая ссылка из другого достижимого объекта или корня.

Подсчет ссылок в данном случае работает рекурсивно и просматривает все объекты до которых можно пройти по жестким ссылкам от корня и помечает их как достижимые. Все остальные - недостижимы, с учетом исключений конкретной среды. Исключения для Unreal Engine будут рассмотрены далее.

Удаление объектов сборщиком мусора

После того, как программа знает какие объекты можно удалять, а какие нет, можно начинать удалять объекты

  1. В первую очередь объекты требуется финализировать.

  2. Далее нужно оповестить систему о том, что объектов больше нет, сбросив на них все ссылки.

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

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

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

Сборка мусора происходит раз в какое-то время, это может быть относительно долгим процессом. Как говорится в одной песне: «Ща пацаны, гарбадж коллектор мусор соберет».

Механизмы обеспечивающие работы сборки мусора в Unreal Engine

Простой подсчет ссылок в Unreal Engine

Стоит отметить, что в Unreal Engine в сборке мусора участвую только UObject'ы и их наследники. Но на базе подсчета ссылок еще работают умные указатели движка. Для управления памятью, используемой не UObject'ами, можно использовать, например, TSharedPtr<>он считает все ссылки, и если их не остается — вызывают деструктор объекта и освобождают память.

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

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

Хранение UObject'ов и ссылки на них

Вернемся к UObject, далее будет называть их просто объектами. Начнем с того, как они хранятся и какие для них есть ссылки.

Все объекты при создании помещаются в общий массив объектов - GUObjectArray , также там хранится дополнительная информация об объекте.

Тип элемента массива выглядит так:

struct FUObjectItem
{
  int32        ClusterRootIndex; //Индекс корневого элемента кластера (RootSet)
  int32        Flags; //Внутренние флаги сборки мусора, например о недостижимости
	UObjectBase* Object; //Указатель на сам объект
	int32 	     SerialNumber; //Серийный номер объекта для слабых указателей,
};

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

Понятие достижимости объекта и выставление флагов

Это работает так как было описано выше: алгоритм подсчета ссылок для каждого объекта рекурсивно ищет жесткие ссылки на него, и если их нет — ставит на объект флаг о недостижимости (RF_Unreachable). Вместе с GC флаг о ожидаемом удалении может ставить и пользовательский код UObject::MarkPendingKill() . Или же программист сам может начать удалять объекты при помощи вызова ConditionalBeginDestroy()это даст флаг RF_BeginDestroyed. Ниже будет более подробное описание инициации удаления объекта со стороны программиста.

Корневые объекты и обеспечение жесткости ссылки

Как мы уже узнали о достижимости, объект должен быть достижимым из корня по жестким ссылкам.

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

Наличие простого UObject* указателя не гарантирует жесткую ссылку. Чтобы ее увидел GC, нужно повесить над указателем макрос UPROPERTY(). Таким образом GC узнает какой объект какой пропертей куда ссылается. Ссылки на объекты внутри контейнеров, например TArray или TMap с макросом UPROPERTY() тоже считаются жесткими.

Отсюда вытекает одна штука, которая обычно приводит к проблемам - если на объект нет указателей под макросом UPROPERTY() он соберется сборщиком мусора, а указатели на него не будут занулены и будут указывать на тот самый “мусор”.

Кластеры объектов

Unreal Engine имеет возможность объединять объекты в кластеры. Это позволяет проводить операции определения достижимости и удаления над всем кластером, а не над отдельными объектами.

Объекты объединяются в кластеры исходя из ссылок на эти объекты из корневого элемента.

Кластеры так же могут объединяться, если GC видит что один кластер ссылается на другой, в таком случае кластеры могут быть объединены.

Актеры как и другие объекты могут объединяться в кластеры. Функция кластеризации по умолчанию выключенна для AActor. Применением кластеризации для актеров можно управлять для каждого актера, указывая поле bCanBeInCluster на true или переопределяя функцию CanBeInCluster(). Для некоторых актеров, например AStaticMeshActor кластеризация включена.

Что бы кластеризация объектов работала более эффективно стоит грамотно создавать ссылки между объектами, как минимум что бы не было ситуации, когда все является корнем или все ссылается на все. В таком случае корректно делить объекты становится сложнее.

Программисту достно управлять кластеризацией в целом через конфиг проекта: Project Settings→Engine→Garbage Collection.

Поиск жестких ссылок для объектов

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

Параллельно с механизмом подсчета ссылок построенном на рефлексии UObject'ов существует механизм подсчета ссылок на объекты из не-UObject'ов.

Основной единицей данного механизма является FGCObject, наследуясь от него можно переопределить функцию FGCObject::AddReferencedObjects() и указать в ней указатели на объекты, которые должны считаться жесткими ссылками. В конструктореFGCObject будет вызвана AddReferencedObjects() и объекты которые будут в ней переданы в FReferenceCollector будут обеспечены жесткими ссылками. Например, на базе FGCObject реализована шаблонная жесткая ссылка на один объект — TStrongObjectPtr<>

Как работает сам процесс сборки мусора внутри Unreal Engine

Создание объекта

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

  1. Для объекта выделяется память FMemory::Malloc.

  2. Объект добавляется в GUObjectArray и получает серийный номер.

  3. Вызывается конструктор объекта и прочая инициализация.

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

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

void CollectGarbageInternal(EObjectFlags KeepFlags, bool bPerformFullPurge)

Определение объектов подлежащих удалению

Для того, чтобы определить достижимость объектов вызывается FRealtimeGC::PerformReachabilityAnalysis. Там все весьма сложно и запутанно, но в кратце это выглядит так - при помощи FReferenceCollector ищутся ссылки на все объекты, если их нет - ставится отметка о недостижимости.

Поиск объектов подлежащих удалению происходит в функции - FRealtimeGC::PerformReachabilityAnalysisOnObjectsInternal, внутри для поиска используется алгоритм подсчета ссылок, который может запускаться в многопоточном режиме, но синхронно по отношению к вызывающему потоку (Game Thread).

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

Далее идет пометка найденных объектов без ссылок как недостижимых, она происходит в функции - FRealtimeGC::MarkObjectsAsUnreachable. Пометка объектов так же параллельна и синхронна.

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

Удаление объектов

Первым делом GC сбрасывает все ссылки, на объекты, которые будут удалены вызывая FGCArrayPool::ClearWeakReferences, на прошлом шаге в пул были помещены все ссылки, для обнуления. После чего начинается сам процесс удаления для тех объектов, которые были помещены в массив GUnreachableObjects.

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

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

  2. Далее для объектов в массиве вызывается ConditionalFinishDestroy(), что, в свою очередь, ставит флаг RF_FinishDestroyed и вызывает виртуальную функцию UObject::FinishDestroy(), так же зануляется его серийный номер, что делает слабые указатели не действительными, это происходит в GUObjectArray FUObjectArray::ResetSerialNumber(UObjectBase*)

???? На самом деле я не очень понимаю зачем сбрасывать слабые ссылки дважды. Возможно я описал это не очень точно.

Теперь, когда объект полностью удален из игрового процесса и на него сброшены ссылки, нужно освободить память, которую он занимал. Этим может заниматься как игровой поток, так и не игровой поток, а поток удаления мусора (Purge Thread).

Настроить это можно в Project Settings, по умолчанию это происходит в параллельном потоке.

Purge Thread чистит память для каждого объекта в массиве GUnreachableObjects:

  1. Вызывает деструктор ~UObject()

    1. Внутри деструктора объект удаляется из GUObjectArray

  2. Освобождает память выделенную для объекта FMemory::Free

  3. Напоследок удаляет объект из GUnreachableObjects

Все, объект удален, память почищена.

Практические советы

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

Удаление актеров и компонентов

Не секрет, что самые распространенные классы, которые создают программисты на Unreal - это наследники AActor или UActorComponent. Для этих классов и их производных есть простые и понятные механизмы удаления:

  • У Актера есть метод Destroy, который удалит его из мира и пометит на удаление для GC

  • У компонентов так же есть функция DestroyComponent, которая позволит удалить любой компонент

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

  • При удалении актера удаляются все его компоненты

  • При выгрузке уровня удаляются все актеры на этом уровне, а следовательно и все компоненты

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

Отключить эту проверку можно поставив поле компонента bAllowAnyoneToDestroyMe на true.

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

Оптимизация сборки мусора

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

Сначала разберемся какие узкие места есть у сборки мусора в целом:

  1. Сложно оценивать достижимость если объектов очень много

  2. Сложно разом удалять большое число объектов

  3. Большое количество объектов часто создаются и удаляются

Для решения первых двух проблем есть совершенно простые решения:

  1. Что бы оценивать достижимость объектов проще можно использовать механизм кластеризации объектов.

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

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

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

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

Нельзя так просто взять и удалить UObject самому

В интернете часто можно найти информацию, которая гласит: "для того, чтобы прямо здесь и сейчас удалить объект можно для него вызвать ConditionalBeginDestroy() и это удалит объект" - это не является правдой в полной мере.

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

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

Более правильный путь удаления ваших объектов прямо сейчас — пометить их для удаления Object→MarkPendingKill() и вызвать принудительную сборку мусора. GEngine→ForceGarbageCollection(true). Стоит отметить, что это не синхронный вызов. Вызывая эту функцию мы лишь указываем движку, что в следующем кадре нужно почистить мусор. Параметром передаваемым в функцию мы говорим, что “точно нужен”, ведь анриал может посчитать, что не нужно сейчас чистить мусор.

Это следует делать после того, как вы пометили ВСЕ объекты, так как больше одного раза за кадр нельзя собирать мусор.

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

Как сберечь объект от сборки мусора

Самый хороший вариант — положить объект в указатель или контейнер под UPROPERTY. Это работает как в С++ так и в блюпринтах, в них по дефолту любой указатель на объект — жесткий, так что можно не переживать. Так же это будет работать, если вы ссылаетесь из UStruct, на которую ссылается UObject. В случае, если итоговый ссылающийся объект помечен как корень RF_RootSet.

Если рядом с вами нету юобжектов с юпропертями или не хочется делать связи до них, то для вас хорошее решение: создать FGCObject и дать жесткую ссылку через него, переопределив функцию AddReferencedObject(). Или использовать его готовую реализацию под один объект -TStrongObjectPtr<>. При вызове деструктора FGCObject жесткая ссылка удалится и объект будет передан на удаление, если их не осталось.

Если же не хочется делать лишнюю структуру на разок и объект вам нужен только в рамках текущего кадра — лучший вариант — дать вашему объекту флаг RF_StrongRefOnFrame . Это сбережет объект от сборки мусора, но в следующем тике флаг будет убран.

Прям самый-самый простой способ - «укоренение» объекта который не должен собираться GC. Сделать объект корневым можно вызвав у него функцию AddToRoot(), а как захотите удалить - RemoveFromRoot(). Оно, конечно, сработает, и объект не будет удален. Но если объект не предполагает под собой хранение каких либо других объектов — мне кажется, много чести делать временные объекты корнями, и куда проще и правильнее — использовать другой способ обеспечения достижимости объекта.

Нельзя смешивать сборку мусора и общие ссылки

Есть одна ошибка, которую вы можете допустить - хранить объекты используя умные указатели движка, например TSharedPtr<>. Движок позволяет это делать, однако в случае, когда умному указателю понадобится удалить объект, то он действительно это сделает, это произойдет в обход всей системы сборки мусора. Следовательно большая часть всего остального кода будет думать что объект все еще жив, когда на деле это не так. Так же этот сетап позволит вам случайно удалять все еще нужные вам объекты, даже если на них есть жесткие ссылки.

На моей практике один раз случилось подобное, у одного джуна, которого я обучал, удалялся мир по непонятной причине. Оказалось что он хранил указатель на мир в видео TSharedPtr<UWorld>. Дело было на версии движка 4.24.

Игнорирование сборки мусора

Есть еще путь не самурая. Объекты, созданные до инициализации системы UObject'ов (например в FEngineLoop:: LoadStartupModules()),игнорируют сборку мусора. Если вы создаете свои объекты достаточно рано и хотите узнать, попадут ли они в сборку мусора, используйте FUObjectArray::IsOpenForDisregardForGC() . Оно вернет true, если ваш объект попадет в пул игнорирующих сборку мусора.

Это особое время заканчивается, когда FEngineLoop::PreInitPostStartupScreen() вызывает GUObjectArray.CloseDisregardForGC(). После этого применяются обычные правила.

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

Заключение

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

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

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


  1. PeloGetan
    19.04.2022 10:44

    Отличная статья, спасибо!