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

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


  1. Jijiki
    23.01.2026 20:23

    а у пул аллокатора лучше выделить по капасити наверно


    1. monobogdan
      23.01.2026 20:23

      Я обычно закладываю в пулы возможность роста. Правда тогда пул превращается в ArrayList :)


      1. Jijiki
        23.01.2026 20:23

        это понятно, но эти три примера можно тестить на чанках в воксельных исполнениях, смотрите у нас оч много данных на самом деле, допустим есть чанк 4096 он всегда 1ой длинны одинаковой, но далее так не будет, как мы понимаем потомучто индексы и вершины разные, соотв придётся о нагрузке думать(потомучто есть этапы: генерация, мешинх - тут появятся еще 2 массива-вектора), тоесть возможно не пушить, и не использовать конструктор явно

        если делать на цпу например, там еще есть ситуация с реймарчем например

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


  1. monobogdan
    23.01.2026 20:23

    Хорошая статья, спасибо. Поставил плюсик.

    А не хотите рассказать про недетерминированную цену аллокаций в управляемых языках? В .NET, например, аллок может произойти как на стеке (то есть в условно линейном аллокаторе), если создается Value-тип, так и на хипе. Но при этом наверняка есть отдельные оптимизации.

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


    1. dyadyaSerezha
      23.01.2026 20:23

      про недетерминированную цену аллокаций в управляемых языках? В .NET, например, аллок может произойти как на стеке... так и на хипе

      Как раз это очень детерменировано, о чем, собственно, и написали сами же.

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


    1. AbuMohammed
      23.01.2026 20:23

      Про LOH еще не забудьте. Ооочень попила кровушки в свое время.


  1. dalerank
    23.01.2026 20:23

    Как то маловато у вас аллокаторов нашлось, их точно больше 10 https://habr.com/ru/articles/876804/. Время аллокации не меряют в нс, потому что наносекунды - это когда произошло, например измеряете latency с точки зрения пользователя, времея загрузки или I/O операции. Это как мерять скорость количеством заячьих прыжков, какое то понимание будет, но не то что вы рассчитываете. Время аллокации измеряется количеством тактов процессора, включая простои.

    "Cache locality. Объекты, выделенные в разное время, разбросаны по памяти" - Ok, ну разбросаны, да по всей памяти, но мы к ним часто обращаемся и они постоянно сидят в кеше, при чем здесь локальность данных? А вы знаете что она бывает временная, пространственная, по типу, фазам, потокам и инструкциям. Видимо не знаете ибо чатгпт про это вам не сказал.

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


    1. nin-jin
      23.01.2026 20:23

      Видно же, что статья полностью сгенерирована. Консультациями с чатгпт сейчас уже никто не заморачивается.


    1. Jijiki
      23.01.2026 20:23

      да представляю причем у вас будет не просто частицы - позиции а еще геометрия будет нет?

      26 соседей - чанков, в вокселях это и есть локальность

      массив блоков одинаковый и размеры вершин и индексов разные и всё это не в воздухе на всё это нужна память


    1. Jijiki
      23.01.2026 20:23

      ну раз на то пошло, то многопоточку World Composition мы еще не разбирали же так? так что пока не разбирали можно теоретически в теории всё чо хочешь говорить о аллокаторах же

      там же есть разница между тем как будет и тем как обсуждаем, вам же нужны будут соседи тот самый локалити, и у вас вообще будет выбор стоять - может вообще забить на локальность в пользу производительности

      тоесть вот например, попробуйте это рассмотреть с позиции Tokio/mspc - например,libuv - возможно ), у вас 9 кусков или 26, при достижении центра вы выгружаете задние и загружаете передние в потоках, потом это мало аллоцировать по накладным расходам надо еще будет шум и нормали посчитать(или распаковать если карта в полигональном мире на картинке), тоесть как не крути теории мало, надо столкнуться с многопоточностью разгрузить main thread и там уже обсуждать аллокаторы моё мнение )

      Когда дойдем до практики, будет интересно посмотреть, как вы планируете решать вопрос Stitching (стыковки) чанков, ведь мы говорим только о аллокаторах покачто - тратим время только на эту теорию, когда суть разгрузить сначала main thread и получить хотябы швы с работающей многопоточкой или лучше застрять на теории аллокаторов ?)


  1. Dasfex
    23.01.2026 20:23

    Эх, жаль что подобный ai-slop уже принимается как должное. Хоть бы руками подредачили.


    1. Jijiki
      23.01.2026 20:23

      ну почему есть 2 подхода, плоское бинарное дерево BVH, или грид с засечками и то и другое пулы вообще получается, есть еще ECS ) но отсечение быстрее работает

      причем BVH решает - если это не воксели больше вопросов, чем ECS как мне кажется помимо коллизии линейности, решаются кучи вопросов, просто на самом фундаменте BVH


    1. TheDreamsWind
      23.01.2026 20:23

      Когда под каждой первой статьёй вижу такие комментарии, невольно задаюсь вопросом - по каким именно признакам происходит распознавание генерации?


  1. Tuxman
    23.01.2026 20:23

    Можно я, например, Pool поревьюирую? Я понимаю, что это не полная реализация, но всё равно, мимо косяков пройти не могу.

    1. Аллокация new uint8_t[count * sizeof(T)] будет гарантировать выравнивание по alignof(std::max_align_t). А что если я в T напихаю каких-нибудь __m256?
      В C++17 надо вот так написать new (std::align_val_t{alignof(T)}) uint8_t[count * sizeof(T)];

    2. reinterpret_cast<FreeNode*> - классический UB.
      В старых C++ надо вот так new (node) FreeNode{m_free_list};
      В C++23 уже вот так можно auto* node = std::start_lifetime_as<FreeNode>(m_buffer + i * sizeof(T));

    3. Деструктор забыли, хотя это просто демо. Не забываем, что мы же выравненный new делали!
      ~Pool() { ::operator delete[](m_buffer, std::align_val_t{alignof(T)}); }


    1. AskePit
      23.01.2026 20:23

      пункт 2 про lifetime:

      предлагаемый вами код не будет некорректным, но он и не обязателен для trivially constructible типов - UB не случится, поскольку для таких типо lifetime неявно начинается при первой записи в такой объект.

      а по ревью Pool могу до кучи докинуть, что в ассерте

      static_assert(sizeof(T) >= sizeof(FreeNode*), "Object too small for pool");
      

      автор все же наверное имел в виду sizeof(FreeNode) (без указателя). правда для данной конкретной структуры эти размеры по счастливому совпадению равны, так что ошибка носит исключительно семантический характер


  1. xod1984
    23.01.2026 20:23

    Кого интересует данная тема очень рекомендую чекнуть этот проект: https://github.com/holysqualor/slot-pool