Введение

Современные аллокаторы общего назначения умеют оптимизировать выделение памяти для небольших объектов и не только, но зачастую они не дают строгих гарантий отсутствия системных вызовов при очередной аллокации или освобождении памяти.

Для высоконагруженных систем, чтобы эффективно выделять и освобождать память под объекты без лишних вызовов malloc/free и потенциальных системных вызовов, используют паттерн пулов объектов.

Этот паттерн особенно хорошо подходит для следующих систем и сценариев:

  1. Системы реального времени (промышленные контроллеры, автопилоты) – здесь важна предсказуемость времени выполнения операций выделения памяти под объекты.

  2. Встраиваемые системы (микроконтроллеры, устройства с ограниченным объёмом RAM) – пул позволяет избежать фрагментации и полностью контролировать расход памяти.

  3. Игровые движки – часто требуют быстрого создания/уничтожения большого количества однотипных объектов.

  4. Высоконагруженные сетевые серверы (обработчики запросов, прокси, фаерволы) – во время обработки трафика важно не тратить время на системные вызовы при выделении памяти под соединения, пакеты, сессии.

В статье будет описан один из вариантов реализации этого паттерна на языке C.

Как это работает?

Весь необходимый объём памяти для пула выделяется заранее, во время его создания. Размер хранимых объектов задаётся при инициализации пула, также фиксируется максимальное количество хранимых объектов. В процессе работы из выделенного участка памяти берутся свободные сегменты под объекты, если свободных сегментов нет возвращается NULL. Когда нужно вернуть сегмент в пул, он помечается как свободный. Во время удаления пула освобождается вся выделенная память. Таким образом, мы выделяем и освобождаем память только при создании и удалении пула.

Сложность операций работы с пулом

Операция

Функция

Сложность

Описание

Создание пула

pool_create

O(N)

Выполняется один раз. Зачастую все пулы создаются во время старта программы

Получение свободного сегмента под объект

get_seg

O(1)

Основные и наиболее часто используемые операции

Освобождение сегмента

put_seg

O(1)

Удаление пула

pool_destroy

O(1)

Обычно вызывается во время завершения программы

Чтобы добиться константного времени взятия и удаления сегментов, нужно свести всю работу с пулом к константным обращениям по индексам, без линейных поисков свободных участков памяти. Для этого используется дополнительная структура для хранения индексов свободных элементов — часто применяют стек, реализованный поверх линейного участка памяти.

Визуализация работы пула

В самом начале имеется пул объектов и стек всех свободных сегментов. Стек заполняется индексами сверху вниз.

Начальное состояние пула и стека
Начальное состояние пула и стека

После вызова get_seg с вершины стека берется индекс свободного сегмента, вычисляется его позиция в пуле, возвращается указатель на свободный сегмент памяти, индекс в стеке помечается как невалидный, вершина стека смещается на одну позицию вниз.

Взятие свободного сегмента из пула
Взятие свободного сегмента из пула

Аналогичным образом при повторном вызове get_seg.

Взядите свободного сегмента из пула
Взядите свободного сегмента из пула

Когда нужно вернуть элемент, вычисляется его позиция и помещается на вершину стека.

Возврат сегмента в пул
Возврат сегмента в пул

Также при повторном возврате объекта.

Возврат сегмента в пул
Возврат сегмента в пул

Когда свободных индексов в стеке не остаётся, get_seg возвращает NULL.

Полный цикл работы пула:

Полная визуализация работы пула
Полная визуализация работы пула

Описание кода

Структура заголовка стека:

typedef struct stack_header_s {
    size_t n_obj_;          // Количество элементов
    size_t head_indx_;      // Индекс вершины стека
    unsigned int obj_sz_;   // Размер хранимого объекта
} stack_header_t;

#define INVALID_INDX (~(0u)) // Если head_indx_ == INVALID_INDX — стек пуст

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

Функция создания стека:

void* create_stack(size_t n_obj, unsigned int obj_size, int set_zero) {
    size_t msz = n_obj * obj_size + sizeof(stack_header_t);
    stack_header_t *sh = malloc(msz);

    if (sh == NULL) 
        return NULL;

    if (set_zero)
        memset(sh, 0, msz);

    sh->head_indx_ = INVALID_INDX;
    sh->n_obj_ = n_obj;
    sh->obj_sz_ = obj_size;

    return sh + 1; // указатель на данные после заголовка
}

Функция удаления стека:

void destroy_stack(void *stack) {
    stack_header_t *sh = ((stack_header_t*)(stack)) - 1;
    free(sh);
}

Функция добавления элемента в стек:

void* push_obj(void* stack, void* obj) {
    stack_header_t *sh = ((stack_header_t*)(stack)) - 1;

    if (sh->head_indx_ != INVALID_INDX && sh->head_indx_ + 1 == sh->n_obj_)
        return NULL;

    if (sh->head_indx_ == INVALID_INDX)
        sh->head_indx_ = 0;
    else
        ++sh->head_indx_;

    unsigned char* obj_addr = ((unsigned char*)(stack)) + (sh->head_indx_ * sh->obj_sz_);
    memcpy(obj_addr, obj, sh->obj_sz_);

    return obj_addr;
}

Функция удаления элемента из стека (без возврата значения):

void pop_obj(void* stack) {
    stack_header_t *sh = ((stack_header_t*)(stack)) - 1;

    if (sh->head_indx_ == INVALID_INDX)
        return; // стек пуст — ничего не делаем

    if (sh->head_indx_ == 0) 
        sh->head_indx_ = INVALID_INDX;
    else
        --sh->head_indx_;
}

Получение указателя на верхний элемент стека (без удаления):

void* peek_obj(void* stack) {
    stack_header_t *sh = ((stack_header_t*)(stack)) - 1;

    if (sh->head_indx_ == INVALID_INDX)
        return NULL;
    
    return ((unsigned char *)stack) + (sh->head_indx_ * sh->obj_sz_);
}

Структура заголовка пула:

typedef struct pool_obj_header_s {
    unsigned int *free_indxs_; // стек со свободными индексами
    unsigned int obj_sz_;      // размер хранимых объектов
    size_t n_obj_;             // количество сегментов под хранение объектов
} pool_obj_header_t;

Функция создания пула (версия без учёта выравнивания — см. раздел ниже):

void* create_pool(size_t n_obj, unsigned int obj_size, int set_zero) {
    size_t msz = n_obj * obj_size + sizeof(pool_obj_header_t);
    pool_obj_header_t *ph = malloc(msz);

    if (ph == NULL) 
        return NULL;

    if (set_zero)
        memset(ph, 0, msz);

    ph->free_indxs_ = create_stack(n_obj, sizeof(unsigned int), 1);

    if (ph->free_indxs_ == NULL) {
        free(ph);
        return NULL;
    }

    ph->n_obj_ = n_obj;
    ph->obj_sz_ = obj_size;

    for (unsigned int i = 0; i < n_obj; ++i) {
        push_obj(ph->free_indxs_, &i);
    }

    return ph + 1; // указатель на область данных
}

Функция удаления пула:

void destroy_pool(void *pool) {
    pool_obj_header_t *ph = ((pool_obj_header_t *)pool) - 1;
    destroy_stack(ph->free_indxs_);
    free(ph);
}

Функция освобождения сегмента:

void put_seg(void* pool, void* obj) {
    pool_obj_header_t *ph = ((pool_obj_header_t *)pool) - 1;

    // Обязательно приводим к unsigned char* для корректной арифметики
    unsigned int obj_indx = ((unsigned char*)obj - (unsigned char*)pool) / ph->obj_sz_;

    push_obj(ph->free_indxs_, &obj_indx);
}

Функция взятия свободного сегмента:

void* get_seg(void* pool) {
    pool_obj_header_t *ph = ((pool_obj_header_t *)pool) - 1;

    unsigned int *free_indx_ptr = peek_obj(ph->free_indxs_);
    if (free_indx_ptr == NULL)
        return NULL;

    unsigned int idx = *free_indx_ptr; // сохраняем значение до pop
    pop_obj(ph->free_indxs_);

    return ((unsigned char*)pool) + idx * ph->obj_sz_;
}

Почему важно подумать о выравнивании памяти

Процессоры и операционные системы накладывают ограничения на адреса, по которым можно читать и писать данные определённых типов. Например, переменная типа double на многих архитектурах должна быть выровнена по адресу, кратному 8. Если обратиться к такой переменной по невыровненному адресу, может произойти:

  1. Замедление доступа (в несколько раз на x86).

  2. Аппаратное исключение (на ARM, SPARC, некоторых других архитектурах).

В текущей реализации память выделяется одним блоком через malloc, который возвращает адрес, подходящий для любого стандартного типа (то есть выровненный по max_align_t, обычно 8 или 16 байт). Однако сам пул разбивает этот блок на сегменты размером obj_sz_ байт. Если obj_sz_ не кратен требуемому выравниванию для хранимых объектов, то, начиная с некоторого сегмента, адреса перестанут быть правильно выровненными.

Пример: Пусть max_align_t требует выравнивания 8, а obj_sz_ = 12. Первый объект получит адрес, который кратен 8. Второй объект — указатель на pool + 12, он уже не кратен 8, что может вызвать проблемы при попытке доступа к полю типа double.

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

Недостатки данного подхода к реализации пула

  1. Повышенный расход памяти из-за необходимости храния индексов свободных сегментов. Рекомендуется использовать пулы объектов для составных структур и классов, размер которых значительно превышает размер индекса.

  2. Текущая реализация — непотокобезопасна. Для использования в многопоточных программах необходимо добавить синхронизацию в get_seg и put_seg или использовать паттерн «per thread data», о котором пойдет речь в следующей статье.

Заключение

Пул объектов — эффективный паттерн управления памятью, который обеспечивает константное время выделения и освобождения памяти под объекты и полное отсутствие фрагментации. Текущая реализация на C даёт базовую функциональность с удобным интерфейсом, но требует внимания к выравниванию данных в зависимости от архитектуры. При соблюдении этих рекомендаций пул станет надёжным инструментом для вышеперечисленных систем.

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


  1. denis_iii
    16.05.2026 12:06

    Да, тут вся соль в многопоточности. Как локально кэшировать выделяемые объекты и как затем возвращать свободные - в глобальный пул. Лучше, чем это делают jemalloc и tcmalloc.


    1. slinkinone
      16.05.2026 12:06

      Как вариант решения кейса для многопоточности - атомарный индекс для вершины стека.

      Когда приходит запрос на выделение сегмента - первым делом резервируется сегмент через инкремент индекса.

      Или альтернативный и самый простой вариант - thread_local пул. Но тогда потребление памяти будет не рациональным.


  1. Dhwtj
    16.05.2026 12:06

    Пул используется более широко: когда создать объект дорого и проще использовать освободившийся. Например, сетевые соединения.

    Почему вы взяли для примера критерий экономии памяти не понятно.


  1. Granulex
    16.05.2026 12:06

    Хорошая реализация для базового случая – особенно стек индексов вместо linked list: он лучше дружит с кэшом при одном потоке. Кстати, к разделу про потокобезопасность стоит добавить ещё один сценарий: даже с мьютексом пул может деградировать на SMP-системах из-за ложного разделения кэша (false sharing). Если поток A держит сегмент #5, а поток B – сегмент #6, и оба помещаются на одну кэш-линию (64 байта на x86), каждая запись одного потока инвалидирует строку у другого. Лечится выравниванием сегментов до размера кэш-линии – с небольшим компромиссом по памяти.


  1. Jijiki
    16.05.2026 12:06

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

    я про ситуацию когда этот паттерн на синхро-уровне по владению, тогда в этих красках кусочки приходящии от генерации тоже должны крутиться(наверно из пула тоже) ). Я понимаю что описываю словами, но в коде гемор, получается 2-3 пула одного и тоже, одно на владение между каналами, другое на паралельный запуск генерации, тогда получается на такую систему 2 версии одного и того же)

    Скрытый текст
    pub struct ChunkTaskResult {
        pub pos: Vec3i,
        pub task_type: TaskType,
        pub chunk_data: ChunkDataTask,
        // pub light_data: Option<Arc<[u8; 4096]>>,
        pub vertices: Option<Vec<VoxelVertex>>,
        pub indices: Option<Vec<u32>>,
        pub revision: u32, // К какой версии данных относится этот меш
        // Делаем Option, так как соседи нужны ТОЛЬКО для TaskType::GenerateMesh
        pub neighbors: Option<MeshNeighbors>,
    }
    ...
    #[allow(unused)]
    pub struct World {
        pub chunks: HashMap<Vec3i, VoxelChunk>,
        // Приемник результатов мешизации
        cave_noise: Perlin,
        pub loading_queue: HashSet<Vec3i>, // Для GenerateData
        pub meshing_queue: HashSet<Vec3i>, // Для GenerateMesh
        // Канал для получения готовых чанков
        rx_done: Receiver<ChunkTaskResult>,
        // Канал для отправки заданий (если нужно динамически)
        tx_tasks: Sender<ChunkTaskResult>,
        pub allocator: mxg11alc::alloc::Gsa, пул слотов на 258 мебагайт для карточки - синхронизированные куски которыми владеют каналы по отрисовке )
        pub mega_vbo: u32,
        pub mega_ebo: u32,
    }
    ...
    и в такой ситуации ворлд каналы в апдейт синхронизируют общее владение
    казалось бы ок, но
      fn new() -> Self{
    ...
            std::thread::spawn(move || {
                while let Ok(task) = rx_tasks.recv() {
                    match task.task_type {
                        TaskType::GenerateData =>{
                            //1 (16*4096)
                            let raw_data=generate_c(&task.pos, &seed);
    ...
                        TaskType::GenerateMesh =>{
                            match task.chunk_data {
                                ChunkDataTask::Full(..)=>{
                                    if let Some(nb) = task.neighbors {
                                        //2 а тут либо плохие случаи шихматная доска(либо компромисс 7к каждый на первое время )*16)
                                        let mut vertices = Vec::new();
                                        //3 как выше 7к*16
                                        let mut indices = Vec::new();
                                        build_seamless_mesh(&nb, &mut vertices, &mut indices);
    }//fn new Self
    ...
    fn update можно настроить чтобы он брал и чистил, но системе не будет хватать опять тех же пулов чтобы откудато брать пулы на генерацию и создания мешев вообще интересная ситуация )
    

    я пока так оставил(тоесть ЦПУ пула нет просто чищу приходящую в канал память и это не грузит проц на дистанции 16х2х16 чанков(16х16х16 блоков) по горизонтали), 16 потоков держит по синхронизации и не грузит проц)


    1. Jijiki
      16.05.2026 12:06

      только что проверил на сколько хватило опыта с зеркалом на ЦПУ, нагрузка возросла и видимо из-за нетривиальности памяти аллоцированных контейнеров Vec - их я заменил зеркалом, я словил рабочую сборку, но с багами, но теперь надо иметь опыт, как в такой ситуации проводить память, утилизировать валидно, потомучто ОС пока что лучше освобождает эти кусочки памяти... Кароче тема интересная, потомучто там реализация интересная, не надо ничего копировать, просто слоты шардируются, но как возращать валидно память и делать так бесконечный мир пока не понятно мне ), потомучто у меня канал делает бесконечный мир, и когда канал перестаёт владеть(он владеет дистанцией) происходит чистка )