Стандартный 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. «С++ под капотом — что стоит за кодом, который мы пишем». Записаться