Стандартный 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)

monobogdan
23.01.2026 20:23Хорошая статья, спасибо. Поставил плюсик.
А не хотите рассказать про недетерминированную цену аллокаций в управляемых языках? В .NET, например, аллок может произойти как на стеке (то есть в условно линейном аллокаторе), если создается Value-тип, так и на хипе. Но при этом наверняка есть отдельные оптимизации.
В JVM начиная примерно с 8, мелкие объекты сначала попадают в краткосрочный регион в TLS, затем если выживает после конца скоупа функции - переносится в долгосрочный хип, который менеджит уже сам GC. Но при этом между разными JVM скорость аллокаций сильно отличается. Особенно это касается поддержки старых версий Android, где аллоки дорогие.

dyadyaSerezha
23.01.2026 20:23про недетерминированную цену аллокаций в управляемых языках? В .NET, например, аллок может произойти как на стеке... так и на хипе
Как раз это очень детерменировано, о чем, собственно, и написали сами же.
А сам внутренний механизм недетерменированных аллокаций на хипе на то и недетерменированный, что предсказать его очень трудно. Особенно с учётом того, что в любой момент при любой аллокации может сработать ещё более недетерменированный GC. Увы, тут только "оценочные суждения".

dalerank
23.01.2026 20:23Как то маловато у вас аллокаторов нашлось, их точно больше 10 https://habr.com/ru/articles/876804/. Время аллокации не меряют в нс, потому что наносекунды - это когда произошло, например измеряете latency с точки зрения пользователя, времея загрузки или I/O операции. Это как мерять скорость количеством заячьих прыжков, какое то понимание будет, но не то что вы рассчитываете. Время аллокации измеряется количеством тактов процессора, включая простои.
"Cache locality. Объекты, выделенные в разное время, разбросаны по памяти" - Ok, ну разбросаны, да по всей памяти, но мы к ним часто обращаемся и они постоянно сидят в кеше, при чем здесь локальность данных? А вы знаете что она бывает временная, пространственная, по типу, фазам, потокам и инструкциям. Видимо не знаете ибо чатгпт про это вам не сказал.
"Деаллокация отдельных объектов невозможна" - концептуально неверно, у неё есть адрес и метаданные, в чем проблема освободить аллокацию? Возможна, просто сброс всей арены будет дешевле, но об этом только одно предложение.
Pool работает с "одним типом" - это неверно, он вообще не знает про ваши типы, а работает с одинаковым размером, это не одно и тоже.
"Тысячи частиц рождаются и умирают каждый кадр" - вы представляете эту хрень на экране? с пулом это будет чуть быстрее чем с маллоком, но видимо и тут чатгпт не подсказал, что пул сделан не для этих вещей.
Дальше читать не стал ибо вообще дичь пошла.
Если вы и на курсах учите в таком же стиле мне жалко ваших студентов.
nin-jin
23.01.2026 20:23Видно же, что статья полностью сгенерирована. Консультациями с чатгпт сейчас уже никто не заморачивается.

Jijiki
23.01.2026 20:23да представляю причем у вас будет не просто частицы - позиции а еще геометрия будет нет?
26 соседей - чанков, в вокселях это и есть локальность
массив блоков одинаковый и размеры вершин и индексов разные и всё это не в воздухе на всё это нужна память

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

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

Jijiki
23.01.2026 20:23ну почему есть 2 подхода, плоское бинарное дерево BVH, или грид с засечками и то и другое пулы вообще получается, есть еще ECS ) но отсечение быстрее работает
причем BVH решает - если это не воксели больше вопросов, чем ECS как мне кажется помимо коллизии линейности, решаются кучи вопросов, просто на самом фундаменте BVH

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

Tuxman
23.01.2026 20:23Можно я, например, Pool поревьюирую? Я понимаю, что это не полная реализация, но всё равно, мимо косяков пройти не могу.
Аллокация
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)];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));Деструктор забыли, хотя это просто демо. Не забываем, что мы же выравненный new делали!
~Pool() { ::operator delete[](m_buffer, std::align_val_t{alignof(T)}); }

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)(без указателя). правда для данной конкретной структуры эти размеры по счастливому совпадению равны, так что ошибка носит исключительно семантический характер

xod1984
23.01.2026 20:23Кого интересует данная тема очень рекомендую чекнуть этот проект: https://github.com/holysqualor/slot-pool
Jijiki
а у пул аллокатора лучше выделить по капасити наверно
monobogdan
Я обычно закладываю в пулы возможность роста. Правда тогда пул превращается в ArrayList :)
Jijiki
это понятно, но эти три примера можно тестить на чанках в воксельных исполнениях, смотрите у нас оч много данных на самом деле, допустим есть чанк 4096 он всегда 1ой длинны одинаковой, но далее так не будет, как мы понимаем потомучто индексы и вершины разные, соотв придётся о нагрузке думать(потомучто есть этапы: генерация, мешинх - тут появятся еще 2 массива-вектора), тоесть возможно не пушить, и не использовать конструктор явно
если делать на цпу например, там еще есть ситуация с реймарчем например
получается только партиклы удобные там всегда одинаковые отрезки памяти