Концепции умных указателей

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

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

// Сырые указатели. Нужно вручную аллоцировать объект и освобождать память
{
  MyObject* obj = new MyObject();  // Создаем объект
  ...                              // Как-то его используем
  delete obj;                      // Удаляем объект и освобождаем память. Если забыть освободить, то память "утечет"
}

// Умные указатели. Не нужно вручную освобождать память, она освободится автоматически, когда объект-обертка прекратит свое время жизни
{
  std::unique_ptr<MyObject> obj = std::make_unique<MyObject>(); // Создаем объект
  ...                                                           // Как-то его используем
} // Объект удаляется автоматически, т.к. переменная obj прекращает свое существование, в ее деструкторе происходит delete 

Есть несколько распространенных концепций умных указателей:

Уникальные указатели (unique_ptr)

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

Шареные указатели (shared_ptr)

Сорри за англицизм, но думаю так большинству понятнее.

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

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

Интерес здесь в том, как устроен счетчик ссылок, а точнее где он лежит. Есть несколько вариантов:

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

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

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

    Однако, такой подход требует специального конструирования объекта. Просто создать его через new не получится, ведь нам нужно переопределить логику аллокации, чтобы подпихать туда еще и счетчик. Для этого существуют специальные шаблонные функции наподобие std::make_shared<>(); Они делают все что нужно внутри.

Есть существенный нюанс в шареных указателях - это зацикленные ссылки. Представим себе два объекта: А и В. Объект А держит шареный указатель на В, а В держит шареный указатель на А. Это зацикленная ссылка. И такие объекты не будут освобождены, даже если они никому больше не нужны, ведь у обоих счетчик ссылок равен единице. Такая же утечка памяти, как если бы мы забыли удалить объект при ручном управлении.

Слабые указатели (weak_ptr)

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

То есть блок счетчика ссылок вырастает до 2х значений: счетчик сильных и счетчик слабых ссылок. Работает это так:

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

  • при занулении счетчика слабых ссылок, удаляется счетчик.

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

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

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

weak_ptr<MyObject> objWeak = ...;

if (objWeak)                       // Проверяем валидность объекта
{
  auto objStong = objWeak.lock();  // Берем сильную ссылку на него
  ....                             // Безопасно работаем с объектом
}                                  // Сильная ссылка освобождается

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

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

Концепция слабых ссылок звучит сложной, если не понимать семантику их применения. Банальный вопрос к примеру выше, в какой именно объект поместить слабую ссылку, в А или В? Сходу вопрос "куда именно поставить слабую ссылку" может ввести в ступор, но если понять пару простых правил, то становится гораздо проще:

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

  • слабую ссылку (weak_ptr) держат все остальные. Тут два варианта:

    • слабая ссылка на родителя. Объект хочет знать о нем, при этом не зациклить ссылку. Самое место для слабой ссылки

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

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

Сборщик мусора (garbage collector, GC)

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

Немного разберемся как это работает (использую гифки из старой статьи):

Маркировка дерева объектов
Маркировка дерева объектов
  • запускается процесс маркировки:

    • 1. выбираем рутовые объекты. Это статические объекты и на стеке. Они считаются верхнеуровневыми (объект A на гифке)

    • 2. делаем этот список объектов текущим списокм итерации

    • 3. помечаем текущие объекты (красным цветом)

    • 4. ищем объекты, принадлежащие текущим объектам, по ссылкам внутри объекта (для A это будут B и C). Если объект уже маркирован, не берем его в итерацию

    • 5. новые объекты делаем текущим списком, переходим к п. 2. Повторяем пока на текущей итерации есть объекты

  • проходимся по списку всех объектов, освобождаем те, что не были промаркированы (объекты G и H)

Это очень примитивное описание, иллюстрирующее общую идею, но для понимания этого достаточно.

Казалось бы вот она, концепция идеальных умных указателей! Так и есть, она используется во многих языках: Java, C#, JS, Lua и многих других. Но в ней есть существенный минус - накладные расходы.

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

Моя концепция

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

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

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

Для этого понадобилось написать свои умные указатели. Это не так сложно, тем более всегда полезно и интересно самому реализовать базовые примитивы, которыми мы пользуемся даже на задумываясь.

И раз уж делать свои, то почему бы не подумать над более компактным неймингом и плюшками. Сильная ссылка объявлена как Ref<>, слабая как WeakRef<>. Для создания объекта используется макрос mmake<>(args ...), который делает небольшую магию, чтобы запомнить место аллокации в коде. Плюс к этому используется движковая рефлексия, для построения именованного дерева объектов, чтобы понять как называется поле класса со ссылкой на дочерний объект. Моя текущая реализация здесь.

Далее, о тех проблемах, что я повстречал в ходе перевода сырых указателей на умные.

Ссылка на себя в конструкторе

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

template<typename _type, typename ... _args>
Ref<_type> Make(_args&& ... args)
{
    // 1. Получаем размеры счетчика и типа
    constexpr auto counterSize = sizeof(RefCounter);
    constexpr auto typeSize = sizeof(_type);

    // 2. Аллоцируем единый кусок памяти под счетчик и объект
    auto memory = (std::byte*)malloc(counterSize + typeSize);

    // 3. Создаем inplace счетчик в начале выделенной памяти
    auto refCounter = new (memory) RefCounter();

    // 4. Создаем inplace объект за счетчиком. Выравнивание обеспечивается размером самого счетчика
    _type* object = new (memory + counterSize) _type(std::forward<_args>(args)...);

    // 5. Передаем счетчик в объект
    object->SetRefCounter(refCounter);

    // 6. Возвращаем готовую сильную ссылку на объект
    return Ref<_type>(object);
}

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

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

Здесь я попробовал несколько вариантов решения:

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

  • запретить использовать ссылки на себя из конструктора, но добавить вызов специального метода, который вызывается сразу после передачи счетчика. Например, в компайл-тайм проверить наличие метода PostRefConstruct(), и если таковой определен у типа, вызвать его. В нем уже можно взять ссылку на себя и "прокинуть" куда нужно. Метод рабочий, и в каком-то виде остался в текущей реализации, однако приходится делить свой алгоритм инициализации объекта на 2 фазы: инициализация без счетчика и прокидывание его внутрь структуры. Для части алгоритмов это крайне не удобно, особенно при копировании дерева объектов вглубь.

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

Множестенное и виртуальное наследование

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

И да, и нет. Нужно несколько ссылок на один и тот же счетчик. То есть мы создаем счетчик, затем объект, и прокидываем ссылку на счетчик во все базовые классы. Это можно сделать либо через констуктор, либо через макросно-шаблонную магию, в которой нужно перечислить все базовые типы со счетчиком. Работает это примерно так

// Структура-хелпер, для рекурсивного вызова функции SetRefCounter() у набора типов _type ... _other_types
template<typename _object_ptr_type>
struct RefCountersSetter
{
    template<typename _type, typename ... _other_types>
    static void Set(_object_ptr_type object, RefCounter* refCounter)
    {
        static_cast<_type*>(object)->SetRefCounter(refCounter);

        if constexpr (sizeof...(_other_types) > 0)
            Set<_other_types...>(object, refCounter);
    }
};

// Макрос для перечисления базовых типов внутри класса
#define REF_COUNTERABLE_IMPL(BASE_CLASS, ...) \                                                                
    void SetRefCounter(RefCounter* refCounter) { RefCountersSetter<typename std::remove_pointer<decltype(this)>::type*>::template Set<BASE_CLASS, ##__VA_ARGS__>(this, refCounter); } 

// Пара классов со счетчиками
class A: public RefCounterable { ... };
class B: public RefCounterable { ... };

// Наследник, имплементирующий SetRefCounter таким образом, чтобы счетчик был прокинут в оба базовых класса
class Derived: public A, public B
{
... 
    REF_COUNTERABLE_IMPL(A, B);
};

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

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

Функция-конструктор mmake<>()

Казалось бы, в чем проблема, ведь функция выглядит практически идентично тому же new, разве что с немного другим синтаксисом:

auto rawPtr = new MyObject(argument);

VS

auto smartPtr = mmake<MyObject>(argument);

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

Так же есть проблема и со стороны языка: при передаче аргументов таким образом, компилятор не может понять что initializer list нужно преобразоваться в конкретный тип. Если при вызове обычного конструктора, у него есть информация, например, что первым параметром ожидается вектор, то он преобразует initializer list в вектор. С шаблонным оборачиванием аргументов есть проблемы, нужно явно передавать вектор и кастить на месте.

// Список преобразуется в вектор без проблем
auto rawPtr = new MyObject({ "A", "B", "C" }); 

// Приходится явно указывать тип, передать просто { "A", "B", "C" } не получится
auto smartPtr = mmake<MyObject>(std::vector<std::string> { "A", "B", "C" }); 

Трекинг места аллокации в коде

Знать в каком исходнике, в какой строке был создан объект - весьма полезно. Можно элементарно и быстро профилировать откуда идут аллокации.

В работе с сырыми указателями я использовал перегрузку оператора new, в паре с макросом. Из макроса получаем имя .cpp и номер строки, и передаем это в параметры перегруженного new:

// Добавляем наше определение специфичного new
void* operator new(size_t size, const char* location, int line);

// Определяем макрос, который автоматом передает нужную инфу
#define mnew new(__FILE__, __LINE__)

// В нужном месте делаем аллокацию через mnew, запоминаем что в этом месте выделено n байт
auto ptr = mnew MyObject();

В случае с функцией конструирования шареного указателя - Make<>(), такое не прокатит, просто синтаксически. Не получится нормально определить макрос, который смог бы передать имя исходника и строку в аргументы функции. Ведь макрос стоит до указания типа в треугольных скобках:

#define mmake Make(__FILE__, __LINE__) // Попытаемся определить

auto ptr = mmake<MyObject>(); // Развернется в такое: Make(__FILE__, __LINE__)<MyObject>(), 
                              // что невозможно скомпилировать
                        

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

// Хитрый макрос
#define mmake RefMaker(__FILE__, __LINE__).Make

// Обертка 
struct RefMaker
{
    const char* location;
    int line;

    RefMaker(const char* location, int line):location(location), line(line) {}
      
    template<typename _type, typename ... _args>
    Ref<_type> Make(_args&& ... args) { ... }
};

// Использование
auto ptr = mmake<MyObject>();

// Разворачивается в:
auto ptr = RefMaker(__FILE__, __LINE__).Make<MyObject>();

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

Попытки напрячь ChatGPT

Честно говоря, кроме интересных архитектурных штук, перевод сырых указателей на умные - довольно рутинная штука. Синтаксис похожий, нужно напрягать буквально одну извилину. Но работы просто много, и делать ее лень.

Как раз хотелось поизучать API ChatGPT для реализации своих некоторых идей. Почему бы не попробовать попросить его порефакторить мой код?

Я могу отправлять текст исходников через API с определенным промтом. В нем подробноописать чего я хочу. Казалось что ChatGPT с легкостью справится с такой работой на одну извилину.

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

replace raw pointers with Ref<>, except void* pointers. Ref<> is a smart pointer like shared_ptr. Use const Ref<>& for function arguments with pointer types, also in overridden methods. Dont replace void* pointers!! Remove default nullptr value for class members with type Ref<>. If class variable is some kind of "parent", use WeakRef<>.

Путем проб и ошибок получилось получить более-менее работающий промт на выборке из примеров кода. Далее был сделан python скрипт, который закидывал исходники через API в несколько параллельных запросов.

Даже с учетом параллельности работало это не быстро. Провернуть 1/3 сорцов (пара мегабайт) занимало примерно полтора часа. Впрочем, это не мое личное время, поэтому я оставлял его шуршать и уходил пить чай. И это было не дорого, на все эксперименты ушло примерно 5$.

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

Типы ошибок, которые встречались:

  • не исправлено то, что явно было прописано исправлять

  • исправлено то, что явно было прописано не исправлять

  • исправлено, но неправильно. Например забытые const и &

  • удаление кускров кода или комментариев

  • банальные опечатки и несуразицы

  • и конечно же поехавшее к чертям форматирование

Примеры корявой работы AI рефакторинга
Просто поудалял части кода и комментарии, сделал ошибки
Просто поудалял части кода и комментарии, сделал ошибки
Вставки непонятных фантазий, код перемешан
Вставки непонятных фантазий, код перемешан
Что-то получилось ОК, где-то забыл что просили
Что-то получилось ОК, где-то забыл что просили
Что-то удалил, где-то накосячил
Что-то удалил, где-то накосячил

В конечном итоге я отказался от такой затеи, т.к. затраты по времени на поиск ошибок были сопоставимы с ручным рефакторингм кода. Фух, пока что программисты в безопасности...

Построение дерева памяти и поиск утечек

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

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

struct MemoryNode
{
    std::string name; // Name of object
    std::string type; // Type of object

    void* memory = nullptr; // Pointer to allocated memory

    MemoryAnalyzeObject* object = nullptr;  // Memory analyzeable object
    IObject*             iobject = nullptr; // Pointer to IObject, if can be casted

    size_t size = 0;        // Allocated size in bytes
    size_t summarySize = 0; // Summary size of all children
    size_t leakedSize = 0;  // Summary leaked size

    MemoryNode*              mainParent = nullptr; // Main parent node, the owner of this node
    std::vector<MemoryNode*> parents;              // Parent nodes
    std::vector<MemoryNode*> children;             // Children nodes
};

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

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

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

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

Еще одна проблема, как понять где утек объект? Мы видим что он "повис", никто им не владеет, однако как понять откуда он взялся и где еще висит ссылка на него? Здесь помогает трассировка стека на момент создания объекта. По нему мы можем понять откуда он был создан, а так же откуда были созданы ссылки на него.

Итоговый инструмент анализа выглядит вот так:

Слева дерево памяти, справа информация о выделенном объекте
Слева дерево памяти, справа информация о выделенном объекте
  • он умеет строить дерево памяти, показывает какой объект каким владеет

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

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

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

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


Репозиторий движка: https://github.com/zenkovich/o2

Мой телеграм-канал для менее формального обсуждания: https://t.me/o2engine

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


  1. unreal_undead2
    30.07.2024 06:34
    +1

    А в игровом движке (где, казалось бы, важна производительность) повсеместное использование умных указателей оправдано? А то видел недавно код (правда, немного из другой области) с атомарными инкрементами/декрементами, растущими из изменения счётчика ссылок, в числе основных хотспотов.


    1. anz Автор
      30.07.2024 06:34

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


      1. unreal_undead2
        30.07.2024 06:34

        На крайний случай в какие-то тяжелые алгоритмы

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


        1. anz Автор
          30.07.2024 06:34

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


          1. unreal_undead2
            30.07.2024 06:34

            Если профилировка конкретной реализации (самих указателей и основанного на них движка) это подтверждает, никаких проблем.


  1. eao197
    30.07.2024 06:34

    Нет ли здесь:

    // 4. Создаем inplace объект за счетчиком
    _type* object = new (memory + counterSize) _type(std::forward<_args>(args)...);
    

    проблем с выравниванием для типа _type?
    Скажем, sizeof(counterSize) у вас 4, а выравнивание для _type -- 8.


    1. anz Автор
      30.07.2024 06:34

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


      1. eao197
        30.07.2024 06:34

        Кем и как этот размер подбирается?


        1. anz Автор
          30.07.2024 06:34

          кхм.. мной, с учетом платформ. Ничего специфичного пока в моих планах нет, скорее всего размера 4 + 4 будет достаточно для всех платформ - ios/andoird/mac/win/linux/web. Ну а если понадобится, конечно, добавлю выравнивание здесь


          1. eao197
            30.07.2024 06:34

            ИМХО, этот момент имеет смысл отразить в статье. Потому что выравнивание -- это штука, про которые не все помнят, а кто-то и не знает. Скопируют ваш подход и наступят на грабли где-нибудь.


            1. anz Автор
              30.07.2024 06:34

              Согласен, немного дополнил это место. Спасибо!


  1. andy_p
    30.07.2024 06:34

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


    1. eao197
      30.07.2024 06:34

      При правильном проектировании сырых указателей вполне достаточно.

      Это утверждение относится к игровым движкам или к программированию на C++ вообще?


      1. andy_p
        30.07.2024 06:34

        Вообще.


        1. eao197
          30.07.2024 06:34
          +5

          Ясно-понятно, вопросов больше не имею.


    1. anz Автор
      30.07.2024 06:34

      Хм, и я так считал. Но у меня буквально был опыт сравнения двух подходов - сырыу указатели в домашнем проекте и умные на работе.

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


    1. Darell_Ldark
      30.07.2024 06:34

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


      1. anz Автор
        30.07.2024 06:34

        Плюсую. Как бы аккуратно не был сделан менеджмент памяти в движке, в игровом коде будет ад и содомия


  1. Lekret
    30.07.2024 06:34

    Я по работе пишу на Unity и C#, с низкоуровневой разработкой игр и движков знаком, но не сильно, только учусь (язык Odin). Но смотря разные видео-лекции, от того же Кейси Муратори, Майка Актона (знаменитый доклад по DOD), или Джонатана Блоу. Все они топят против умных указателей и против массивов указателей в целом.
    Мол делай плотно упакованные массивы данных, вместо указателей используй индексы, смотри не на отдельные объекты, а на множества объектов, используй кастомные аллокаторы вместо new, аллоцируй и переиспользуй большие блоки памяти, и твой код будет работать в 5-10 раз быстрее, при тех же усилиях, а умные указатели отпадут за ненадобностью.
    Что думаете про подобный подход? Писать как говорят джентельмены выше на практике слишком сложно? Или если оверхед небольшой, то и ладно?


    1. anz Автор
      30.07.2024 06:34

      Это и правда классный подход, но имхо не для всего подходит. Все таки игра не это не только (и далеко не всегда) туча бегающих юнитов, куча рассчетов и т.п. Есть куча UI, особенно в мобайле, где все это просто вредит, т.к. код становится сильно сложнее.

      Для такой не требовательной к производительности логике нужно максимальное удобство, и в С++ это дают умные указатели и более простая схема работы


  1. azTotMD
    30.07.2024 06:34

    shared_pointer - это понятно, а можно наоборот? Есть несколько указателей на объект. Если я по одному из указателей сделал delete, то всем остальным бы привоился nullptr?


    1. zzzzzzerg
      30.07.2024 06:34

      weak_ptr


      1. azTotMD
        30.07.2024 06:34

        ага, метод expired(), спасибо. А что если объект был уничтожен, а потом была выделена новая память и так случайно получилось, она выделилась в том же месте, где был старый объект, expired по-прежнему вернёт true?