Управление памятью в Unreal Engine
Обозначим проблему
Программа в процессе выполнения держит необходимые данные в оперативной памяти, некоторые из которых теряют свою актуальность. Такую информацию необходимо периодически удалять, чтобы освободить место. Это можно делать вручную, но соевые куколды такое не любят. Поэтому были придуманы такие системы, как сборщики мусора, которые периодически проходятся по данным, определяют их валидность и удаляют ненужные.
Управление памятью
В UE есть две системы, которые существуют параллельно и дополняют друг друга.
- Garbage Collector (GC) — сборщик мусора. Предназначена для работы с объектами-наследниками UObject (далее UObject).
- Smart Pointer (умные указатели) — для всех остальных объектов.
Казалось бы, зачем две системы для работы с указателями? Дело в том, что если мы будем использовать умные указатели с UObject, то можем получить неожиданное поведение. Ведь, удалив его через умный указатель, никто об этом не узнает, и он будет считаться живым.
Умные указатели
Умные указатели — это обёртка вокруг обычных указателей, которая считает число ссылок на себя. Для начала разберем какие вообще бывают умные указатели.
- Shared Pointer (TSharedPtr) — общий указатель. Владеет объектом, на который ссылается, предотвращая его удаление. Сразу же удаляет объект, как число Shared Pointer или Shared Reference становится 0. Может быть нулевым (не ссылаться ни на что).
- Shared Reference (TSharedRef) — общая ссылка. Всё как у Shared Pointer, но не может указывать на нулевой объект.
- Weak Pointer (TWeakPtr) — слабый указатель. Не владеет объектом, никак не влияет на его жизненный цикл, поэтому может стать нулевым в процессе выполнения.
- Unique Pointers (TUniquePtr) — уникальный указатель. Исключительно и явно владеет объектом, на который указывает. Может передавать «владение» объектом, но не делить его. Удаляет объект, на который указывает, как только выходит из зоны видимости.
Используйте умные указатели для всего, кроме UObject (либо используйте С-стиль, но не удивляйтесь, что нога дырявая 🙂). Они будут сами следить за памятью, которую занимает объект.
Garbage Collector (GC)
Что такое сборщик мусора и зачем он нужен. Если верить Википедии, то
Сборка мусора(англ. garbage collection) в программировании — одна из форм автоматического управления памятью. Специальный процесс, называемый сборщиком мусора (англ. garbage collector), периодически освобождает память, удаляя из неё ставшие ненужными объекты.
Сборщики мусора избавляют нас от висячих ссылок и утечек памяти.
Висячие ссылки — ссылка на ранее удаленную память, которая могла быть уже переиспользована или передана другому процессу. Обращение к удаленной памяти может привести к непредвиденному поведению программы.
- Утечки памяти — ситуация при которой на выделенную память, не осталось ссылок, но она еще не освобождена и до сих пор принадлежит процессу. Тем самым она просто занимает место в ОЗУ.
Как известно, UE работает на C++, который не имеет сборщика мусора. Не проблема, он есть в самом движке. И тут возникает вопрос, как он работает и как не поссориться с ним по незнанию. Ведь ошибки, которые могут появиться из-за неправильной работы с памятью, буквально ломают мозг. Вот один пример из моей практики.
На прошлом проекте мы делали туториал, который требовал нажатия кнопок для перехода на следующий шаг. Естественно, самый логичный способ привязываться к ивенту нажатия кнопки, что мы и делали. Но по завершению шага не отвязывались от кнопки, и он висел в памяти, влияя на другую логику. И понять, что именно в этом проблема заняло гораздо больше времени, чем сделать всё сразу нормально.
GC в Unreal Engine
GC, как было сказано выше, работает только с UObject, поэтому нужно их где-то хранить. И такое место есть — GUObjectArray. Это список всех UObject, которые были созданы. Они в нем хранятся в виде структур FUObjectItem.
При создании какого-либо экземпляра класса производного от UObject происходит следующее.
- Выделяется память FMemory::Malloc.
- Описание объекта FUObjectItem помещается в GUObjectArray.
- Конструктор и инициализация.
- Возврат жесткой ссылки (hard reference).
Да, для UObject тоже есть разные ссылки. Называются почти так же, но с добавлением слова «Object». Мы рассмотрим только два основыных из них
- TStrongObjectPtr - сильный указатель.
- TWeakObjectPtr — слабый указатель.
- есть еще TSoftObjectPtr и FLazyObjectPtr.
Про последние 2 хорошо написано в китайской статье (переведите страницу плагином в браузере и наслаждайтесь) .
Принципиальная разница сильного и слабого указателя в том, что сильный отражает владение, а слабый использование.
Сильный указатель хранит в себе напрямую адрес UObject в памяти, при чем гарантирует, что объект не может быть собран GC. Это делается путем перегрузки функции AddReferencedObjects, где сильный указатель добавляет хранимый объект список жестких связей (для определения достижимости объекта, при обходе GC, о чём чуть позже).
Слабый указатель не хранит внутри адрес UObject в памяти напрямую. Взамен этого он хранит его серийный номер и индекс в массиве GUObjectArray. При попытки получить указатель на объект происходит поиск в массиве по индексу и сравнение серийных номеров. Если всё совпадает, выдается указатель (class UObjectBase* Object). Серийные номера не могут совпасть у разных объектов, так как гарантируется, что они не могут уменьшаться. Таким образом гарантируется однозначность слабых указателей.
Алгоритм
Простое объяснение состоит в следующем.
При запуске сборки мусора начинается обход всех элементы GUObjectArray по жестким ссылкам (проверяется их достижимость). Те элементы, которые не были пройдены за этот обход, считаются мусором и помечаются для удаления. Обход начинается с элементов корневого набора (Root Set). Корневой набор — это набор объектов, которые не могут быть удалены сборщиком мусора.
Обход можно представить в виде такого графа.
При обходе каждого элемента происходит отсмотр всех его жестких ссылок. Жесткой ссылкой считается
- Обычная (не мягкая) ссылка на объект в Blueprint классе.
- Указатель на объект, с макросом UPROPERTY().
- Указатель в стандартном контейнере UE (TArray, TSet или TMap), с макросом UPROPERTY().
- Сильный указатель.
Обычной ссылкой в блупринтах считается следующее.
Кроме того, что CachedCharacter — это уже ссылка из нашего класса на персонажа, так мы еще персонажа ссылаем на наш класс по средствам делегата, который так же считаются жесткой ссылкой.
Если мы хотим удалить эти ссылки, то нужно отвязаться напрямую и обнулить переменную.
Если жесткая ссылка указывает на валидный объект, помечаем его как достижимый и проверяем все его жесткие ссылки. И таким образом получаем все достижимые объекты, остальные помечаем для удаления.
Если пройтись по этому углубленно, то получится следующее.
- void CollectGarbageInternal(EObjectFlags KeepFlags, bool bPerformFullPurge) — с чего всё начинается.
- FRealtimeGC::PerformReachabilityAnalysis — определяется достижимость объектов (с помощью FReferenceCollector).
- FRealtimeGC::PerformReachabilityAnalysisOnObjectsInternal — поиск объектов для удаления (может запускаться в многопоточном режиме, но синхронно по отношению Game Thread).
- FGCArrayPool — массив всех ссылок на объекты, которые будут удалены (чтоб потом их занулить).
- FRealtimeGC::MarkObjectsAsUnreachable — пометка объектов, как недостижимых (параллельна и синхронна).
- GUnreachableObjects — массив всех объектов, которые нужно удалить (информация о том, какие объекты будут удалены, передаются в поток асинхронной загрузки, для остановки загрузки удаляемых объектов) .
После того, как мы нашли все объекты, нужно их удалить.
- Обнуляем все ссылки, используя FGCArrayPool::ClearWeakReferences.
- Для всех объектов из GUnreachableObjects вызывается ConditionalBeginDestroy(), что ставит флаг RF_BeginDestroyed и вызывает BeginDestroy(), где преполагается высвобождение всех ресурсов и завершение логики.
- Для всех объектов из GUnreachableObjects вызывается ConditionalFinishDestroy(), что ставит флаг RF_FinishDestroyed и вызывает FinishDestroy().
- Так же зануляется его серийный номер, что делает слабые указатели недействительными. Это происходит в FUObjectArray::ResetSerialNumber(UObjectBase*)
- Далее нужно освободить память. Это может происходить как в основном потоке Game Thread так и отдельном Purge Thread.
Purge Thread чистит память для каждого объекта в массиве GUnreachableObjects:
- Вызывает деструктор ~UObject(). Внутри деструктора объект удаляется из GUObjectArray
- Освобождает память выделенную для объекта FMemory::Free
- Удаляет объект из GUnreachableObjects.
И вот всё чистенько.
Настройки GC
Настройки сборщика мусора Project Settings — Engine — Garbage Collection.
Пара слов про кластеризацию. Объекты могут объединяться в кластеры, для уменьшения числа шагов обхода массива. Таким образом мы можем не проверять каждый элемент кластера на достижимость, а проверить только один.
По умолчанию кластеризация объектов (UObject) включена, так же её можно включить и для всех актеров (AActor). Либо для каждого нужного вам актора делать это вручную (bCanBeInCluster=true или CanBeInCluster() override).
Но есть один минус. Если кластер достаточно большой, то все отдельные элементы в нём будут подготовлены к удалению в одном и том же кадре, что может вызвать спайк.
Также можно выключить/включить многопоточный поиск достижимости GC.
Заключение
Система контроля памяти в UE реализованна на высоком уровне, а что более важно, имеет открытый исходный код, который можно досконально изучить и подправить под свои нужды. Кроме того, важно понимать, что как и когда удаляется из памяти, чтобы создавать технически качественные игры. Подводя итоги можно выделить следующие советы.
- Не использовать Smart Pointers (умные указатели) для UObject.
- Не удаляйте UObject в обход GC (вызовом ConditionalBeginDestroy() напрямую) , так как ссылки на него не будут занулены. Если очень надо, пометьте объект для удаления и вызовите принудительную сборку мусора (Object→MarkPendingKill() и GEngine→ForceGarbageCollection(true)). Удаление произойдет в следующем кадре.
- Не забывайте, что делегаты в Blueprint также являются жесткими ссылками и удерживают объект от дуления.
- Если есть необходимость сохранить объект от сборки мусора, то самый простой способ — это пометить его макросом UPROPERTY().
- Если связь не подразумевает отношение владения, используйте TWeakObjectPtr.
Пара слов от меня
Спасибо за прочтение этой статьи! Буду рад вашим дополнениям и уточнениям. Заходите в мой телеграм чатик, буду рад видеть вас там)
Статья поверхностная, жаль. Про рут сет один раз упомянуто, но какие юз кейсы, и что в него можно добавлять вручную, а что нет?
Очевидное продолжение темы это что происходит при загрузке нового левла и есть ли способы заставить актора или объект пережить эту процедуру. А также разница в поведении между едитором и игрой.
Например, если дать UObject'у World и заставить его выполнять looping timer, то при смене левла в эдиторе, он продолжит своё выполнение, а в билде его загребет гц. Почему?
отличные вопросы, если есть материалы по ним, скинь, пожалуйста. Согласен, что тема не полностью раскрыта, но для общего понимания мне показалось достаточным
о, эта же та штука, про которую забывают крупные конторы
ага, древние утерянные технологии)
Комментарий недоступен
А там разве основная проблема - это память?
Кто шарит за UE, объясните, почему им не хватило смарт поинтеров, зачем понадобилось прикручивать сборку мусора?