Аллокаторы в C++: свой аллокатор под конкретную задачу
Профилировщик показал, что 40% времени в горячем цикле уходит на malloc и free. Разбираем, когда стандартный аллокатор мешает и как написать свой — пул, стек и арену — на PMR из C++17.

Ситуация из практики: сервер обрабатывает пакеты, на каждый создаёт временный вектор, парсит, отдаёт ответ. Профилировщик (perf, flamegraph) показывает, что до 40% времени в горячем цикле съедают operator new и operator delete — точнее, системный malloc под ними с его блокировками и поиском подходящего блока. Аллокация мелких объектов миллионы раз в секунду — классический случай, когда стандартный аллокатор становится узким местом. Разбираем, как заменить его на специализированный и что за это придётся заплатить.
Речь про C++17 и новее: именно там появился <memory_resource> (PMR), который делает написание своего аллокатора задачей на десятки строк, а не на сотни. Для C++11/14 старый интерфейс Allocator тоже упомянем, но новый код лучше писать на PMR.
Почему стандартный аллокатор тормозит именно у вас
Проблема не в том, что malloc медленный сам по себе. Он универсальный, и универсальность стоит денег:
- Синхронизация. Глобальная куча общая для всех потоков. Даже с per-thread arena внутри современных аллокаторов (tcmalloc, jemalloc) остаётся координация.
- Поиск подходящего блока. Аллокатор хранит списки свободных блоков разных размеров и на каждый запрос выбирает подходящий. Это ветвления и промахи кеша.
- Фрагментация. Долгоживущий процесс с потоком мелких аллокаций разного размера дробит кучу. Память вроде свободна, но крупный запрос не влезает.
- Отдельная жизнь каждого объекта. Освобождать миллион мелких объектов по одному дорого, если по логике задачи они все умирают одновременно.
Свой аллокатор побеждает не потому, что «оптимизированнее», а потому, что знает про задачу то, чего не знает malloc: все объекты одного размера, или все живут ровно до конца запроса, или работает всегда один поток.
Кастомный аллокатор — это не «сделать быстрее вообще», а обменять гибкость на скорость. Вы отказываетесь от произвольных размеров и произвольного времени жизни в обмен на предсказуемость.
Три рабочих стратегии
Арена (monotonic): выделяем, освобождаем всё разом
Арена держит один большой буфер и на каждый запрос просто сдвигает указатель вперёд. Освобождение отдельного объекта не делает ничего — вся память возвращается одним махом, когда арена умирает. Идеально для запроса, кадра рендера, разбора документа: набор объектов создали, использовали, выкинули целиком.
В стандартной библиотеке это уже есть — std::pmr::monotonic_buffer_resource:
#include <memory_resource>
#include <vector>
#include <array>
// C++17. Обработка одного запроса: вся память живёт до конца функции.
void handle_request() {
// Буфер на стеке — первые аллокации вообще не идут в кучу.
std::array<std::byte, 64 * 1024> buffer;
// monotonic_buffer_resource выделяет из buffer, сдвигая указатель.
// Когда буфер кончится — дозапросит у upstream (по умолчанию new).
std::pmr::monotonic_buffer_resource arena{
buffer.data(), buffer.size()
};
// Контейнеры берут память из арены, а не из глобальной кучи.
std::pmr::vector<int> ids{&arena};
std::pmr::vector<std::pmr::string> names{&arena};
for (int i = 0; i < 1000; ++i) {
ids.push_back(i);
names.emplace_back("item", &arena);
}
// Выхода из функции достаточно: деструктор arena отпускает всё разом.
// Отдельные free по объектам не вызываются.
}
Здесь ids.push_back в горячем цикле не трогает malloc, пока хватает буфера на стеке. Освобождение — константа, а не проход по всем элементам.
Пул объектов одного размера
Если вы аллоцируете миллионы объектов одинакового размера (узлы дерева, элементы списка, частицы), пул хранит их плотным массивом и раздаёт по свободному списку. Аллокация и освобождение — снятие и возврат головы списка, без поиска.
#include <memory_resource>
#include <list>
// C++17. unsynchronized — без блокировок, только для одного потока.
void process_nodes() {
std::pmr::unsynchronized_pool_resource pool;
// Список постоянно создаёт и удаляет узлы одного размера —
// pool_resource переиспользует освобождённые блоки.
std::pmr::list<int> nodes{&pool};
for (int i = 0; i < 100000; ++i) {
nodes.push_back(i);
if (nodes.size() > 1000) nodes.pop_front();
}
}
Для многопоточного доступа есть std::pmr::synchronized_pool_resource — тот же пул с внутренней блокировкой. Но если можно обойтись одним потоком на пул, берите unsynchronized: это и есть выигрыш.
Свой memory_resource с нуля
Когда готовых ресурсов не хватает — например, нужен аллокатор поверх заранее выделенного mmap-региона или с трассировкой каждой аллокации — наследуйте std::pmr::memory_resource и реализуйте три метода:
#include <memory_resource>
#include <cstddef>
#include <cstdio>
// C++17. Обёртка, которая логирует каждый запрос и делегирует upstream.
class logging_resource : public std::pmr::memory_resource {
public:
explicit logging_resource(std::pmr::memory_resource* upstream)
: upstream_{upstream} {}
private:
void* do_allocate(std::size_t bytes, std::size_t align) override {
std::printf("alloc %zu bytes (align %zu)\n", bytes, align);
return upstream_->allocate(bytes, align);
}
void do_deallocate(void* p, std::size_t bytes,
std::size_t align) override {
std::printf("free %zu bytes\n", bytes);
upstream_->deallocate(p, bytes, align);
}
bool do_is_equal(const std::pmr::memory_resource& other)
const noexcept override {
return this == &other;
}
std::pmr::memory_resource* upstream_;
};
do_is_equal важен: два аллокатора считаются взаимозаменяемыми, только если он вернул true. От этого зависит, можно ли переместить контейнер без перевыделения памяти.
Что выбрать под задачу
| Стратегия | Когда подходит | Цена |
|---|---|---|
| monotonic_buffer_resource | Все объекты умирают одновременно (запрос, кадр) | Память не возвращается до конца жизни ресурса |
| unsynchronized_pool_resource | Много объектов близких размеров, один поток | Не для разнородных крупных объектов; не потокобезопасен |
| synchronized_pool_resource | То же, но несколько потоков | Блокировка съедает часть выигрыша |
| Свой memory_resource | Нестандартный источник памяти, трассировка, лимиты | Свой код — свои баги в управлении памятью |
| Стандартный new/malloc | Произвольные размеры и время жизни | Синхронизация, фрагментация, поиск блока |
Практический критерий: если объекты живут группой и умирают вместе — арена. Если размер один и тот же, а время жизни вразнобой — пул. Если ни то, ни другое — оставьте стандартный аллокатор, вы не выиграете.
Старый интерфейс Allocator и почему PMR удобнее
До C++17 свой аллокатор писали как шаблонный класс, удовлетворяющий требованиям Allocator, и подставляли в контейнер параметром типа: std::vector<int, MyAlloc<int>>. Проблема в том, что тип аллокатора становился частью типа контейнера — vector с одним аллокатором и vector с другим несовместимы, их нельзя присвоить друг другу и передать в общую функцию без шаблонов.
PMR решает это за счёт виртуальных вызовов: std::pmr::vector<int> всегда один тип, а стратегия задаётся указателем на memory_resource в рантайме. Плата — косвенный вызов на каждой аллокации, но на фоне выигрыша от пула или арены он теряется. Если аллокаций много и они дешёвые, шаблонный аллокатор без виртуального вызова быстрее — тогда старый интерфейс оправдан.
Что ломается
- Висячий указатель на буфер. Если
monotonic_buffer_resourceдержит буфер на стеке, а контейнер с этим ресурсом переживает функцию — обращение к освобождённой памяти. Ресурс обязан жить дольше всех контейнеров, которые из него берут память. - Перемещение между аллокаторами. Move-контейнера с PMR-аллокатором не переносит сам аллокатор, если ресурсы не равны по
do_is_equal— вместо дешёвого перемещения указателя произойдёт поэлементное копирование в память нового ресурса. Неожиданно медленно. - Арена не отдаёт память. Если в долгоживущей арене копится мусор, а вы ждёте, что
clear()вернёт память — не вернёт. Для monotonic это by design. Пересоздавайте ресурс. - Выравнивание. В своём
do_allocateнельзя игнорировать параметрalign: невыровненный указатель для типа с требованием выравнивания — UB, который проявится не сразу.
С чего начать на своём проекте
Сначала измерьте. Соберите под perf или запустите под heaptrack и убедитесь, что аллокации действительно в топе профиля — иначе вы оптимизируете то, что ничего не решает. Дальше:
- Найдите горячий участок, где создаётся много объектов с общим временем жизни или общим размером.
- Замените контейнеры на
std::pmr::-версии и подсуньтеmonotonic_buffer_resourceилиpool_resource. Свой класс не пишите, пока хватает готовых. - Замерьте до и после на одинаковой нагрузке: то же железо, тот же объём данных, та же параллельность. Смотрите на время в аллокаторе и на общий latency, а не только на пиковую память.
- Только если готовых ресурсов не хватает — наследуйте
memory_resourceи прогоните код под ASan и valgrind: свой аллокатор — прямой путь к тонким ошибкам с памятью.
Метрика, за которой стоит следить в проде: доля времени в аллокаторе (по профилю) и пиковое потребление памяти процессом. Если после перехода на арену память выросла, а latency упал — это ожидаемый размен, и решать, приемлем ли он, нужно по вашему бюджету памяти.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: std::pmr::memory_resource — cppreference, std::pmr::monotonic_buffer_resource — cppreference
Частые вопросы
У меня C++14, PMR ещё нет. Что делать?
Дал прирост меньше, чем ожидал. Почему?
Чем monotonic_buffer_resource отличается от pool_resource?
Можно ли использовать PMR-аллокатор для контейнера, который живёт долго?
Свой аллокатор безопаснее стандартного?
Насколько косвенный вызов через memory_resource замедляет?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.