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