Стандартный malloc — универсальный инструмент, но в геймдеве универсальность часто означает «недостаточно быстро». Когда бюджет кадра 16 мс, а каждый кадр рождаются тысячи объектов, имеет смысл разобраться в специализированных аллокаторах.
Рассмотрим три основных типа: arena, pool и slab — когда какой использовать, как реализовать, и какие подводные камни ждут.
malloc не всегда подходит
malloc на Linux устроен сложно. Для мелких объектов (до ~256 байт) используется tcache — thread‑local кеш, это быстро. Для объектов покрупнее — поиск по bins, спискам свободных блоков разного размера. Для больших (от ~128 КБ) — системный вызов mmap напрямую.
В среднем аллокация занимает 50–200 наносекунд. Но «в среднем» — ключевое слово. В худшем случае — микросекунды: нужно расширить heap, или память фрагментирована, или сработал lock на глобальной арене.
Три проблемы, важных для игр:
Непредсказуемость. Время аллокации зависит от состояния кучи, которое постоянно меняется. Один вызов malloc — 50 нс, следующий — 5 мкс. В играх это проявляется как микрофризы: 59 кадров идут гладко, 60й не особо.
Фрагментация. После множества аллокаций и деаллокаций разного размера память превращается в много маленьких свободных блоков, но ни один не подходит для нового большого объекта. Приходится просить память у системы, хотя свободной вроде бы полно.
Cache locality. Объекты, выделенные в разное время, разбросаны по памяти.
Arena (linear/bump allocator)
Самый простой и самый быстрый аллокатор. Идея элементарна: берём большой кусок памяти, держим указатель «где мы сейчас». Аллокация — сдвигаем указатель вперёд. Деаллокация отдельных объектов невозможна — освобождаем всё разом через reset.
class Arena { uint8_t* m_buffer; size_t m_capacity; size_t m_offset = 0; public: explicit Arena(size_t capacity) : m_buffer(new uint8_t[capacity]) , m_capacity(capacity) {} ~Arena() { delete[] m_buffer; } void* allocate(size_t size, size_t alignment = alignof(std::max_align_t)) { // Выравниваем текущую позицию size_t aligned_offset = (m_offset + alignment - 1) & ~(alignment - 1); if (aligned_offset + size > m_capacity) { return nullptr; // Или throw, или grow } void* ptr = m_buffer + aligned_offset; m_offset = aligned_offset + size; return ptr; } void reset() { m_offset = 0; } size_t used() const { return m_offset; } size_t available() const { return m_capacity - m_offset; } };
Вся аллокация — выравнивание (одна битовая операция), проверка границ, инкремент. Три‑четыре инструкции, никаких системных вызовов, никаких блокировок. Время — единицы наносекунд, абсолютно детерминистично.
Когда использовать: временные данные кадра. Всё, что нужно только для текущего кадра и может быть выброшено в конце.
// Глобальная или thread-local арена для кадра thread_local Arena g_frame_arena(4 * 1024 * 1024); // 4 МБ void Game::update(float dt) { g_frame_arena.reset(); // Начало кадра — сброс // Временные буферы для culling auto* visible = g_frame_arena.allocate_array<Entity*>(max_entities); size_t visible_count = frustum_cull(all_entities, visible); // Временные данные для сортировки auto* sorted = g_frame_arena.allocate_array<RenderCommand>(visible_count); // ... рендеринг ... } // Конец кадра — все временные данные автоматически «исчезают»
Обратите внимание: никаких delete, free, деструкторов для временных данных. Reset в начале кадра — и память снова доступна.
Дополнительная возможность — savepoints (маркеры):
struct ArenaMarker { size_t offset; }; ArenaMarker Arena::save() const { return {m_offset}; } void Arena::restore(ArenaMarker marker) { m_offset = marker.offset; }
Это позволяет делать временные аллокации внутри функции и откатываться:
void calculate_something(Arena& arena) { auto marker = arena.save(); // Временные буферы для вычислений auto* temp = arena.allocate_array<float>(1000); // ... вычисления ... arena.restore(marker); // Откат — temp больше не занимает место }
Pool allocator
Arena не умеет освобождать отдельные объекты. Для долгоживущих объектов одного типа с произвольным временем жизни нужен pool.
Идея: память нарезана на слоты одинакового размера. Свободные слоты связаны в односвязный список. Аллокация — берём первый свободный. Деаллокация — возвращаем в начало списка.
template<typename T> class Pool { struct FreeNode { FreeNode* next; }; uint8_t* m_buffer = nullptr; size_t m_capacity = 0; FreeNode* m_free_list = nullptr; public: explicit Pool(size_t count) { static_assert(sizeof(T) >= sizeof(FreeNode*), "Object too small for pool"); m_capacity = count; m_buffer = new uint8_t[count * sizeof(T)]; // Инициализируем free list for (size_t i = 0; i < count; ++i) { auto* node = reinterpret_cast<FreeNode*>(m_buffer + i * sizeof(T)); node->next = m_free_list; m_free_list = node; } } T* allocate() { if (!m_free_list) return nullptr; // Или grow void* ptr = m_free_list; m_free_list = m_free_list->next; return static_cast<T*>(ptr); } void deallocate(T* ptr) { auto* node = reinterpret_cast<FreeNode*>(ptr); node->next = m_free_list; m_free_list = node; } // Удобные обёртки с конструктором/деструктором template<typename... Args> T* create(Args&&... args) { T* ptr = allocate(); if (ptr) new(ptr) T(std::forward<Args>(args)...); return ptr; } void destroy(T* ptr) { ptr->~T(); deallocate(ptr); } };
Указатель на следующий свободный слот хранится прямо внутри свободного слота. Пока слот не используется, там всё равно мусор — так почему бы не использовать эти байты?
O(1) аллокация, O(1) деаллокация. Нет фрагментации — все слоты одного размера, любой свободный подходит. Объекты лежат в непрерывном буфере.
Пример системы частиц:
class ParticleSystem { Pool<Particle> m_pool{10000}; std::vector<Particle*> m_active; public: void emit(const Vec3& position, const Vec3& velocity) { Particle* p = m_pool.create(); if (!p) return; // Пул исчерпан p->position = position; p->velocity = velocity; p->lifetime = 2.0f; m_active.push_back(p); } void update(float dt) { for (size_t i = 0; i < m_active.size(); ) { Particle* p = m_active[i]; p->lifetime -= dt; p->position += p->velocity * dt; if (p->lifetime <= 0) { m_pool.destroy(p); // Swap-and-pop для O(1) удаления из вектора m_active[i] = m_active.back(); m_active.pop_back(); } else { ++i; } } } };
Тысячи частиц рождаются и умирают каждый кадр. С malloc это было бы заметно в профилировщике. С пулом практически бесплатно.
Slab allocator
Pool хорош, когда все объекты одного размера. Но часто нужны объекты разных размеров, и создавать отдельный пул под каждый тип — муторно.
Slab allocator — обобщение pool для объектов разных размеров.
Создаём несколько пулов для разных «классов размеров». При аллокации округляем запрошенный размер вверх до ближайшего класса и берём слот из соответствующего пула.
Классы обычно — степени двойки: 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096 байт.
class SlabAllocator { static constexpr size_t NUM_CLASSES = 10; // 8 .. 4096 static constexpr size_t MAX_SIZE = 4096; static constexpr size_t SLAB_SIZE = 65536; // 64 КБ на slab struct Slab { std::vector<uint8_t> buffer; void* free_list = nullptr; size_t object_size; explicit Slab(size_t obj_size) : object_size(obj_size) { size_t count = SLAB_SIZE / obj_size; buffer.resize(count * obj_size); for (size_t i = 0; i < count; ++i) { void* ptr = buffer.data() + i * obj_size; *reinterpret_cast<void**>(ptr) = free_list; free_list = ptr; } } }; std::array<std::vector<Slab>, NUM_CLASSES> m_slabs; std::mutex m_mutex; // Для многопоточности public: void* allocate(size_t size) { if (size > MAX_SIZE) { return ::operator new(size); // Fallback на системный } size_t class_idx = size_to_class(size); std::lock_guard lock(m_mutex); auto& class_slabs = m_slabs[class_idx]; // Ищем slab со свободным слотом for (auto& slab : class_slabs) { if (slab.free_list) { void* ptr = slab.free_list; slab.free_list = *reinterpret_cast<void**>(ptr); return ptr; } } // Все slabs заняты — создаём новый size_t obj_size = class_to_size(class_idx); class_slabs.emplace_back(obj_size); return class_slabs.back().allocate(); } void deallocate(void* ptr, size_t size) { if (size > MAX_SIZE) { ::operator delete(ptr); return; } size_t class_idx = size_to_class(size); std::lock_guard lock(m_mutex); // Находим slab, которому принадлежит ptr, и возвращаем в free list // (упрощённо — в реальности нужен способ найти slab по указателю) } private: static size_t size_to_class(size_t size) { // Округляем до степени двойки, минимум 8 if (size <= 8) return 0; return std::bit_width(size - 1) - 2; // C++20 } static size_t class_to_size(size_t class_idx) { return size_t(8) << class_idx; } };
Запросил 33 байта — получил слот на 64 (ближайшая степень двойки). Да, оверхед есть — в среднем ~25% памяти теряется на округление. Зато O(1) аллокация и никакой фрагментации внутри класса.
Linux использует три реализации slab в ядре: SLAB (классический, много метаданных), SLUB (дефолт в современных ядрах, оптимизирован для SMP), SLOB (для embedded‑систем с минимумом памяти).
Сравнение производительности
Бенчмарк: 100 000 аллокаций случайного размера 8–256 байт, затем освобождение в случайном порядке.
Результаты на моем обычном ноуте:
Аллокатор | Alloc | Free | Примечание |
|---|---|---|---|
glibc malloc | ~85 ns | ~65 ns | Средние значения |
jemalloc | ~50 ns | ~45 ns | Оптимизирован для многопоточности |
slab | ~12 ns | ~8 ns | Наша реализация |
pool | ~5 ns | ~4 ns | Фиксированный размер |
arena | ~3 ns | N/A | reset() ~1 ns |
Slab в 7 раз быстрее glibc malloc. Pool — в 15 раз. Arena — в 25 раз.
И это без учёта фрагментации, которая со временем деградирует производительность malloc ещё сильнее.
Нюансы
Thread safety. Примеры выше используют mutex, что конечно же плохо влияет на производительность. Решения:
Thread‑local арены/пулы — каждый поток работает со своим, никаких блокировок
Lock‑free структуры — сложно, но возможно для free list
Sharding — несколько пулов, выбор по thread ID
Alignment. SSE требует 16-байтное выравнивание, AVX — 32-байтное. Забыть про alignment — словить SIGBUS на некоторых архитектурах или тихую деградацию производительности на x86.
void* allocate(size_t size, size_t alignment = alignof(std::max_align_t)) { // Всегда учитывайте alignment }
Debugging. Valgrind и AddressSanitizer не понимают кастомные аллокаторы — для них вся память выглядит валидной. Добавляйте свои проверки:
#ifdef DEBUG_ALLOCATOR static constexpr uint64_t CANARY_BEGIN = 0xDEADBEEFCAFEBABE; static constexpr uint64_t CANARY_END = 0xFEEDFACEDEADC0DE; void* debug_allocate(size_t size) { // Выделяем size + 2 * sizeof(canary) // Пишем canary в начало и конец // При освобождении проверяем — если повреждены, buffer overflow } #endif
Деструкторы. Arena не вызывает деструкторы при reset(). Если объекты владеют ресурсами (файлы, сокеты), нужно явно вызывать деструкторы перед reset или использовать только trivially destructible типы.
Что выбрать
Сценарий | Аллокатор | Почему |
|---|---|---|
Временные данные кадра | Arena | Сброс за O(1), никаких деаллокаций |
Много объектов одного типа | Pool | O(1), нет фрагментации |
Разные размеры, нужна скорость | Slab | Компромисс между скоростью и гибкостью |
Редкие аллокации, разные размеры | malloc | Не усложняйте без необходимости |
Начните с arena — это самый простой способ получить заметное ускорение. Потом, если профилировщик покажет проблемы в аллокациях конкретных типов, добавляйте пулы точечно.

Если тема аллокаторов зацепила, логичный следующий шаг — системно прокачать С++ до уровня, где такие решения пишутся и отлаживаются уверенно. На курсе «C++ Developer. Professional» разбирают современные стандарты до C++23, корректность кода, многопоточность и работу с памятью через практику (14 работ) и разбор с экспертами. Готовы к серьезному обучению? Пройдите входной тест.
А чтобы узнать больше о формате обучения и задать вопросы экспертам, приходите на бесплатные демо-уроки:
28 января в 20:00. «Паттерны проектирования на С++». Записаться
9 февраля в 20:00. «Lock-free в C++: Без блокировок к высокой производительности». За��исаться
19 февраля в 20:00. «С++ под капотом — что стоит за кодом, который мы пишем». Записаться
