Ручное управление памятью с С++ — одновременно один из самых больших плюсов и минусов в языке. Действительно, эта парадигма позволяет создавать очень производительные программы, однако она же рождает и кучу проблем. Существует несколько способов избавится от них. Я попробовал несколько и в итоге пришел к сборщику мусора. В этой статье я хочу изложить не столько реализацию сборщика мусора на С++, сколько историю идеи и его использования, каково им пользоваться и почему в итоге от него отказался.
Итак, как у большинства программистов у меня есть свой проект, а именно двумерный игровой движок. На нем и производились все «эксперименты».
Самая часто встречающаяся проблема при ручном управлении памятью — это утечки. Чтобы узнать о них нужно следить за памятью. Именно таков и был мой первоначальных подход. Следим за созданием и удалением объектов, проверяем после завершения программы что осталось не удаленным. Делается очень просто: перегружаем операторы new и delete, чтобы они могли принимать в параметрах имя файла исходного кода и строчку, откуда происходит аллокация, и храним все аллокации в одном месте. Для удобства объявляем макрос, который и передает имя файла и строку в оператор. Соответственно при удалении объекта удаляем запись о соответствующей аллокации.
Плюсы
Минусы
И правда же, самое распространенное решение проблемы управления памятью — умные указатели. О них вас обязательно спросят на собеседовании. Идея проста: вместо обычных указателей мы используем шаблонные классы, которые работают как указатели, но имеют дополнительный функционал для автоматического освобождения объектов. Вместе с объектом храним счетчик ссылок, при присваивании значения указателю увеличиваем счетчик, при уничтожении указателя — уменьшаем. Если счетчик равен нулю — объект никому не нужен и его можно освободить. Однако, есть небольшая проблема — если два объекта ссылаются друг на друга, они никогда не будут освобождены, так как у обоих счетчик ссылок равен единице.

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

Что ж, давайте придумаем ситуацию посложнее.

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

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

Для реализации нам потребуется такой же шаблонный класс указателя, как и в случае с умными указателями. Плюс нужен сам сборщик, который будет за всем следить и производить собственно сборку мусора.
Внимательный читатель наверняка заметил еще одно — за удобство мы платим производительностью. Мы получаем все типичные минусы сборщиков мусора: излишний расход памяти и большое время работы сборки мусора. Именно на моем проекте игрового движка это фатальный минус, ведь на каждый кадр должно уходить несколько миллисекунд и просто нет времени чтобы все остановить и произвести сборку мусора. Однако, есть и хороший момент, этот сборщик мусора полностью наш и мы можем делать все что захотим!
Первое что приходит в голову — это то, что мы полностью управляем моментом сборки мусора. Совсем не обязательно делать это посреди геймплея. Можно сделать это как раз тогда, когда и происходит наибольшее количество инициализаций и уничтожений объектов, во время загрузки уровней. При разработке игр не принято делать много аллокаций/уничтожений во время активной игры. Если при каждом выстреле загружать и создавать объект пули, никакая оптимизация памяти вас не спасет. Поэтому делать долгую сборку мусора между уровнями кажется довольно живучей идеей.
Следующая идея — вовсе не вызывать сборку мусора или делать это крайне редко. Объекты по-прежнему удалять вручную или умными указателями, а сборщик мусора можно использовать в роли отладчика и подстраховки. В таком варианте мы получим производительность эквивалентную ручному управлению памятью и безопасность от утечек при сборке мусора.
Таким образом мы можем использовать сборщик по-разному. При быстром прототипировании нас не заботит производительность, но нужна стабильность, в этом случае используем автоматическую сборку. В обычной ситуации стараемся вручную управлять памятью, а сборщик нам подсказывает и подстраховывает. Ну и кон��чно же его можно просто выключить и перейти к полностью ручному управлению.
Помимо этого можно реализовать еще немного «вкусностей». Так как мы используем шаблонные классы-указатели, мы можем проверять их на корректность, то есть при удалении объекта вручную делать все ссылки на него невалидными. И далее в нужных местах их проверять. Еще одно из возможных улучшений — это дефрагментация памяти. На этапе сборки мусора можно перерасположить актуальные объекты в памяти для уменьшения кеш-промахов процессора, что несомненно даст прирост производительности.
Плюсы
Минусы
В конце концов на решение об отказе от сборщика повлияла специфика моего проекта. Проект планируется с открытым исходным кодом и он нацелен прежде всего на удобство использования. Усложнение и без того сложного синтаксиса С++ специфичными указателями и добавление сборщика мусора несомненно плохо повлияют на проект. Просто представьте знакомство разработчика с новой технологией: ему необходимо изучать новое API да еще и с мудреной моделью управления памятью, тем более, что большинство программистов С++ и так неплохо управляются с памятью вручную.
Окончательно я убедился в возврате к ручной модели когда принял решение использовать скрипты. Именно они нужны для простоты и удобства. Делаешь прототип или простую игру — используй скрипты, экономь время и ресурсы. Необходима гибкость и производительность — добро пожаловать в С++. Тем, кто будет использовать С++ на самом деле вряд ли понадобится сборщик мусора.
Вот так я и вернулся к началу. Надеюсь мой опыт будет полезен или хотя бы интересен другим велосипедостроителям.
P.S. Код из статьи не оптимизирован и приведен лишь для понимания работы.
Итак, как у большинства программистов у меня есть свой проект, а именно двумерный игровой движок. На нем и производились все «эксперименты».
Этап первый: отладка памяти
Самая часто встречающаяся проблема при ручном управлении памятью — это утечки. Чтобы узнать о них нужно следить за памятью. Именно таков и был мой первоначальных подход. Следим за созданием и удалением объектов, проверяем после завершения программы что осталось не удаленным. Делается очень просто: перегружаем операторы new и delete, чтобы они могли принимать в параметрах имя файла исходного кода и строчку, откуда происходит аллокация, и храним все аллокации в одном месте. Для удобства объявляем макрос, который и передает имя файла и строку в оператор. Соответственно при удалении объекта удаляем запись о соответствующей аллокации.
Код
#include <vector> class MemoryManager { struct AllocInfo { const char* source; int line; int size; void* data; }; static MemoryManager instance; std::vector<AllocInfo> allocations; public: static void RegAllocation(void* ptr, int size, const char* source, int line) { AllocInfo info; info.data = ptr; info.size = size; info.source = source; info.line = line; instance.allocations.push_back(info); } static void UnregAllocation(void* ptr) { for (std::vector<AllocInfo>::iterator it = instance.allocations.begin(); it != instance.allocations.end(); ++it) { if (it->data == ptr) { instance.allocations.erase(it); return; } } } static void PrintInfo() { for (std::vector<AllocInfo>::iterator it = instance.allocations.begin(); it != instance.allocations.end(); ++it) printf("0x%x: %i bytes at %s: %i", it->data, it->size, it->source, it->line); } }; MemoryManager MemoryManager::instance; void* operator new(size_t size, const char* location, int line) { void* res = ::operator new(size); MemoryManager::RegAllocation(res, size, location, line); return res; } void operator delete(void* allocMemory, const char* location, int line) { MemoryManager::UnregAllocation(allocMemory); } void operator delete(void* allocMemory) { MemoryManager::UnregAllocation(allocMemory); } #define mnew new(__FILE__, __LINE__) int main() { int* data = mnew int; MemoryManager::PrintInfo(); return 0; }
Плюсы
- просто и понятно
- не затратно в плане ресурсов
- в ходе выполнения программы мы знаем количество задействованной памяти
Минусы
- чтобы узнать об утечках, нужно завершать программу
- не очень информативно откуда именно происходят аллокации
- использование перегруженных операторов немного меняет синтаксис
Этап второй: умные указатели
И правда же, самое распространенное решение проблемы управления памятью — умные указатели. О них вас обязательно спросят на собеседовании. Идея проста: вместо обычных указателей мы используем шаблонные классы, которые работают как указатели, но имеют дополнительный функционал для автоматического освобождения объектов. Вместе с объектом храним счетчик ссылок, при присваивании значения указателю увеличиваем счетчик, при уничтожении указателя — уменьшаем. Если счетчик равен нулю — объект никому не нужен и его можно освободить. Однако, есть небольшая проблема — если два объекта ссылаются друг на друга, они никогда не будут освобождены, так как у обоих счетчик ссылок равен единице.

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

Что ж, давайте придумаем ситуацию посложнее.

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

Нужно как-то узнать что два нижних объекта зациклены и их можно удалить, ведь на них никто не ссылается. По рисунку уже несложно догадаться: если ссылки от объекта не ведут к верхнеуровневым объектам, значит он может быть освобожден. Верхнеуровневые объекты — это, грубо говоря, те объекты, с которых начинается инициализация приложения. Для С++ это объекты на стеке и статические.
Этап четвертый: сборщик мусора
Думаю, внимательный читатель уже понял, что это и есть схема работы сборщика мусора, только наоборот. Самый простой сборщик работает примерно так: спускаясь по ссылкам о�� верхнеуровневых объектов помечаем всех как актуальные, после этого мы имеем право удалить те, что оказались не помеченными.

Для реализации нам потребуется такой же шаблонный класс указателя, как и в случае с умными указателями. Плюс нужен сам сборщик, который будет за всем следить и производить собственно сборку мусора.
Чуть больше кода
#include <vector> #include <map> #include <algorithm> class IPtr; template<typename T> struct Destroyer { static void Destroy(void* obj) { (*(T*)(obj)).~T(); } }; class MemoryManager { public: struct ObjectInfo { void* object; size_t size; bool mark; const char* source; int line; std::vector<IPtr*> pointers; void(*destroy)(void*) = nullptr; }; private: static MemoryManager instance; std::map<void*, ObjectInfo*> objects; std::vector<IPtr*> pointers; size_t allocatedBytes = 0; bool currentMark = true; public: static void CollectGarbage(); protected: void MarkObject(ObjectInfo* info); friend void* operator new(size_t size, const char* source, int line); friend void operator delete(void* object, const char* source, int line); friend void operator delete(void* object); template<typename T> friend class Ptr; }; MemoryManager MemoryManager::instance; class IPtr { protected: void* object; MemoryManager::ObjectInfo* info; public: virtual ~IPtr() {} virtual bool IsRoot() const = 0; protected: void MarkInvalid() { object = nullptr; info = nullptr; } friend void operator delete(void* object); friend class MemoryManager; }; template<typename T> class Ptr: public IPtr { public: Ptr() { object = nullptr; info = nullptr; MemoryManager::instance.pointers.push_back(this); } Ptr(T* object) { this->object = object; auto fnd = MemoryManager::instance.objects.find(object); if (fnd != MemoryManager::instance.objects.end()) { info = fnd->second; info->pointers.push_back(this); if (!info->destroy) info->destroy = &Destroyer<T>::Destroy; } MemoryManager::instance.pointers.push_back(this); } Ptr(const Ptr<T>& other) { object = other.object; info = other.info; if (info) info->pointers.push_back(this); MemoryManager::instance.pointers.push_back(this); } ~Ptr() { if (info) { auto fnd = std::find(info->pointers.begin(), info->pointers.end(), this); if (fnd != info->pointers.end()) info->pointers.erase(fnd); } auto fnd = std::find(MemoryManager::instance.pointers.begin(), MemoryManager::instance.pointers.end(), this); if (fnd != MemoryManager::instance.pointers.end()) MemoryManager::instance.pointers.erase(fnd); } T* Get() const { return (T*)object; } bool IsValid() const { return object != nullptr; } bool IsRoot() const { return false; } operator bool() { return object != nullptr; } operator T*() { return (T*)object; } T* operator->() { return (T*)object; } T& operator*() { return *(T*)object; } const T& operator*() const { return *(T*)object; } Ptr<T>& operator=(const Ptr<T>& other) { if (info) { auto fnd = std::find(info->pointers.begin(), info->pointers.end(), this); if (fnd != info->pointers.end()) info->pointers.erase(fnd); } object = other.object; info = other.info; if (info) info->pointers.push_back(this); return *this; } Ptr<T>& operator=(T* other) { if (info) { auto fnd = std::find(info->pointers.begin(), info->pointers.end(), this); if (fnd != info->pointers.end()) info->pointers.erase(fnd); } object = other; auto fnd = MemoryManager::instance.objects.find(object); if (fnd != MemoryManager::instance.objects.end()) { info = fnd->second; info->pointers.push_back(this); if (!info->destroy) info->destroy = &Destroyer<T>::Destroy; } else info = nullptr; return *this; } }; template<typename T> class RootPtr: public Ptr<T> { public: RootPtr():Ptr() {} RootPtr(T* object):Ptr(object) {} RootPtr(const Ptr<T>& other):Ptr(other) {} bool IsRoot() const { return true; } operator bool() { return object != nullptr; } operator T*() { return (T*)object; } T* operator->() { return (T*)object; } T& operator*() { return *(T*)object; } const T& operator*() const { return *(T*)object; } RootPtr<T>& operator=(const Ptr<T>& other) { Ptr<T>::operator=(other); return *this; } RootPtr<T>& operator=(T* other) { Ptr<T>::operator=(other); return *this; } }; void* operator new(size_t size, const char* source, int line) { void* res = malloc(size); MemoryManager::ObjectInfo* objInfo = new MemoryManager::ObjectInfo(); objInfo->object = res; objInfo->size = size; objInfo->mark = MemoryManager::instance.currentMark; objInfo->source = source; objInfo->line = line; MemoryManager::instance.objects[res] = objInfo; MemoryManager::instance.allocatedBytes += size; return res; } void operator delete(void* data, const char* source, int line) { delete data; } void operator delete(void* data) { auto fnd = MemoryManager::instance.objects.find(data); if (fnd != MemoryManager::instance.objects.end()) { MemoryManager::instance.allocatedBytes -= fnd->second->size; for (auto ptr : fnd->second->pointers) ptr->MarkInvalid(); delete fnd->second; MemoryManager::instance.objects.erase(fnd); } free(data); } void MemoryManager::CollectGarbage() { instance.currentMark = !instance.currentMark; for (auto ptr : instance.pointers) { if (ptr->IsRoot()) { if (ptr->info) instance.MarkObject(ptr->info); } } std::vector< std::map<void*, ObjectInfo*>::iterator > freeObjects; for (auto obj = instance.objects.begin(); obj != instance.objects.end(); ++obj) { if (obj->second->mark != instance.currentMark) freeObjects.push_back(obj); } for (auto obj : freeObjects) { instance.allocatedBytes -= obj->second->size; obj->second->destroy(obj->first); free(obj->first); for (auto ptr : obj->second->pointers) ptr->MarkInvalid(); delete obj->second; instance.objects.erase(obj); } } void MemoryManager::MarkObject(ObjectInfo* info) { info->mark = MemoryManager::instance.currentMark; char* left = (char*)info->object; char* right = left + info->size; for (auto ptr : instance.pointers) { char* cptr = (char*)ptr; if (cptr >= left && cptr < right) { if (ptr->info && ptr->info->mark != MemoryManager::instance.currentMark) MarkObject(ptr->info); } } } #define mnew new(__FILE__, __LINE__) struct B; struct C; struct D; struct A { Ptr<B> pb; Ptr<C> pc; A() { printf("A()\n"); } ~A() { printf("~A()\n"); } }; struct B { Ptr<C> pc; B() { printf("B()\n"); } ~B() { printf("~B()\n"); } }; struct C { Ptr<D> pd; C() { printf("C()\n"); } ~C() { printf("~C()\n"); } }; struct D { Ptr<C> pc; D() { printf("D()\n"); } ~D() { printf("~D()\n"); } }; int main() { RootPtr<A> pa = mnew A; pa->pb = mnew B; pa->pc = mnew C; pa->pc->pd = mnew D; pa->pc->pd->pc = pa->pc; pa->pc = nullptr; MemoryManager::CollectGarbage(); return 0; }
Как это работает
Для каждого созданного объекта создается мета-информация ObjectInfo и хранится в менеджере памяти MemoryManager. Каждый такой объект создается перегруженным оператором new. ObjectInfo хранит в себе информацию о размере объекта, месте, откуда он был создан, список указателей на него и указатель на функцию для вызова деструктора этого объекта.
Для работы с объектами вместо привычных указателей используются шаблонные классы Ptr и RootPtr. RootPtr необходим для обозначения верхнеуровневых объектов, так как в ходе выполнения программы невозможно узнать что объект создан на стеке или статически (поправьте меня, если я не прав). При инициализации или копировании указателей происходит добавление и удаление указателей из соответствующих ObjectInfo.
При вызове сборки мусора меняется булевая переменная маркера на противоположную и начинается цикл отметки объектов. Цикл проходит по верхнеуровневым указателям, затем рекурсивно по их дочерним указателям. Дочерний указатель — это тот, что находится в теле объекта. После этого все объекты, у которых маркер не совпадает с текущим удаляются.
Для работы с объектами вместо привычных указателей используются шаблонные классы Ptr и RootPtr. RootPtr необходим для обозначения верхнеуровневых объектов, так как в ходе выполнения программы невозможно узнать что объект создан на стеке или статически (поправьте меня, если я не прав). При инициализации или копировании указателей происходит добавление и удаление указателей из соответствующих ObjectInfo.
При вызове сборки мусора меняется булевая переменная маркера на противоположную и начинается цикл отметки объектов. Цикл проходит по верхнеуровневым указателям, затем рекурсивно по их дочерним указателям. Дочерний указатель — это тот, что находится в теле объекта. После этого все объекты, у которых маркер не совпадает с текущим удаляются.
Внимательный читатель наверняка заметил еще одно — за удобство мы платим производительностью. Мы получаем все типичные минусы сборщиков мусора: излишний расход памяти и большое время работы сборки мусора. Именно на моем проекте игрового движка это фатальный минус, ведь на каждый кадр должно уходить несколько миллисекунд и просто нет времени чтобы все остановить и произвести сборку мусора. Однако, есть и хороший момент, этот сборщик мусора полностью наш и мы можем делать все что захотим!
Этап пятый: импровизация
Первое что приходит в голову — это то, что мы полностью управляем моментом сборки мусора. Совсем не обязательно делать это посреди геймплея. Можно сделать это как раз тогда, когда и происходит наибольшее количество инициализаций и уничтожений объектов, во время загрузки уровней. При разработке игр не принято делать много аллокаций/уничтожений во время активной игры. Если при каждом выстреле загружать и создавать объект пули, никакая оптимизация памяти вас не спасет. Поэтому делать долгую сборку мусора между уровнями кажется довольно живучей идеей.
Следующая идея — вовсе не вызывать сборку мусора или делать это крайне редко. Объекты по-прежнему удалять вручную или умными указателями, а сборщик мусора можно использовать в роли отладчика и подстраховки. В таком варианте мы получим производительность эквивалентную ручному управлению памятью и безопасность от утечек при сборке мусора.
Таким образом мы можем использовать сборщик по-разному. При быстром прототипировании нас не заботит производительность, но нужна стабильность, в этом случае используем автоматическую сборку. В обычной ситуации стараемся вручную управлять памятью, а сборщик нам подсказывает и подстраховывает. Ну и кон��чно же его можно просто выключить и перейти к полностью ручному управлению.
Помимо этого можно реализовать еще немного «вкусностей». Так как мы используем шаблонные классы-указатели, мы можем проверять их на корректность, то есть при удалении объекта вручную делать все ссылки на него невалидными. И далее в нужных местах их проверять. Еще одно из возможных улучшений — это дефрагментация памяти. На этапе сборки мусора можно перерасположить актуальные объекты в памяти для уменьшения кеш-промахов процессора, что несомненно даст прирост производительности.
Плюсы
- защищенность от утечек и использования некорректных указателей
- минимальные затраты при работе в полуавтоматическом режиме
- максимальное удобство при полностью автоматическом режиме
Минусы
- усложнение синтаксиса
- необходимо понимание схемы работы и использования
- процесс сборки мусора все еще занимает значительное время
Этап шестой: возврат к началу
В конце концов на решение об отказе от сборщика повлияла специфика моего проекта. Проект планируется с открытым исходным кодом и он нацелен прежде всего на удобство использования. Усложнение и без того сложного синтаксиса С++ специфичными указателями и добавление сборщика мусора несомненно плохо повлияют на проект. Просто представьте знакомство разработчика с новой технологией: ему необходимо изучать новое API да еще и с мудреной моделью управления памятью, тем более, что большинство программистов С++ и так неплохо управляются с памятью вручную.
Окончательно я убедился в возврате к ручной модели когда принял решение использовать скрипты. Именно они нужны для простоты и удобства. Делаешь прототип или простую игру — используй скрипты, экономь время и ресурсы. Необходима гибкость и производительность — добро пожаловать в С++. Тем, кто будет использовать С++ на самом деле вряд ли понадобится сборщик мусора.
Вот так я и вернулся к началу. Надеюсь мой опыт будет полезен или хотя бы интересен другим велосипедостроителям.
P.S. Код из статьи не оптимизирован и приведен лишь для понимания работы.
