Свой аллокатор в C++: где он ломается на практике
Кастомный аллокатор кажется способом ускорить работу с памятью, но требования стандарта, rebind и распространение через контейнеры превращают его в источник тонких багов. Разбираем, что ломается первым.

Свой аллокатор пишут ради двух вещей: убрать общий системный malloc из горячего пути и уменьшить фрагментацию при миллионах мелких аллокаций. На бумаге выигрыш очевиден: пул фиксированного размера отдаёт блок за пару инструкций против сотен на пути через operator new. На практике первый же std::map с вашим аллокатором выделяет узлы не того размера, который вы ждали, а std::vector, скопированный из контейнера с другим аллокатором, роняет программу на деструкторе. Разберём, где именно кастомный аллокатор ломается и почему стандарт устроен так, что наивная реализация почти всегда неверна.
Материал ориентирован на C++17 и C++20 (компиляторы GCC 13+, Clang 16+, MSVC 19.3x). Модель аллокаторов не менялась принципиально с C++11, но C++17 упростил интерфейс, а C++20 добавил constexpr-аллокацию — на неё отдельно обратим внимание.
Что стандартная библиотека ждёт от аллокатора
Аллокатор в STL — не просто «функция, которая даёт память». Это тип, который контейнер копирует, сравнивает, перепривязывает к другому типу элемента и распространяет (или не распространяет) при копировании и перемещении. Именно эти операции, а не сам allocate, дают большинство багов.
Минимум, который вы обязаны предоставить в C++17, — тип value_type, методы allocate и deallocate, конструктор шаблонного копирования и операторы сравнения. Всё остальное std::allocator_traits достроит за вас со значениями по умолчанию. Проблема в том, что значения по умолчанию не всегда те, что вам нужны.
#include <cstddef>
#include <new>
// Минимальный аллокатор для C++17. Каждый allocate идёт в ::operator new,
// то есть выигрыша в скорости тут ещё нет — это только каркас.
template <class T>
struct SimpleAllocator {
using value_type = T;
SimpleAllocator() noexcept = default;
// Обязателен: контейнер перепривязывает аллокатор к другому типу
// (например, к внутреннему узлу списка), копируя через этот конструктор.
template <class U>
SimpleAllocator(const SimpleAllocator<U>&) noexcept {}
T* allocate(std::size_t n) {
if (n > static_cast<std::size_t>(-1) / sizeof(T))
throw std::bad_alloc();
return static_cast<T*>(::operator new(n * sizeof(T)));
}
void deallocate(T* p, std::size_t) noexcept {
::operator delete(p);
}
};
template <class T, class U>
bool operator==(const SimpleAllocator<T>&, const SimpleAllocator<U>&) noexcept {
return true; // все экземпляры взаимозаменяемы
}
template <class T, class U>
bool operator!=(const SimpleAllocator<T>& a, const SimpleAllocator<U>& b) noexcept {
return !(a == b);
}Этот каркас компилируется и работает с любым контейнером. Но как только вы замените ::operator new на пул, привязанный к конкретному экземпляру аллокатора, вылезут все подводные камни разом.
Камень первый: allocate работает с типом, о котором вы не думали
Вы пишете аллокатор для std::list<int> и настраиваете пул под блоки размером sizeof(int). Программа падает или тихо портит память. Причина: std::list не аллоцирует int. Он аллоцирует внутренний узел — структуру с двумя указателями и полем данных. Её размер у вас нигде не заявлен, а определяется реализацией стандартной библиотеки.
Аллокатор параметризован типом элемента, но вызывается для типа узла. Любая настройка пула «под sizeof(value_type)» ошибочна для узловых контейнеров: list, set, map, unordered_*.
Механизм здесь — rebind. Контейнер берёт ваш SimpleAllocator<int> и через allocator_traits::rebind_alloc<Node> получает SimpleAllocator<Node>, а затем зовёт allocate(1) уже у него. Именно поэтому шаблонный конструктор копирования обязателен: без него rebind не соберётся. Если ваш пул должен работать с фиксированным размером блока, размер надо вычислять в момент rebind, а не хардкодить.
Камень второй: propagate-флаги и сравнение на равенство
Самый коварный участок. allocator_traits определяет три типа-флага, каждый из которых по умолчанию — false_type:
propagate_on_container_copy_assignment— копируется ли аллокатор приa = b;propagate_on_container_move_assignment— приa = std::move(b);propagate_on_container_swap— приswap(a, b).
Если ваш аллокатор stateful (хранит указатель на пул), но флаги оставлены по умолчанию в false, а operator== у вас всегда возвращает true, вы получаете рассинхрон: контейнер думает, что память из двух аллокаторов взаимозаменяема, освобождает блок через пул, которому этот блок не принадлежит. Итог — повреждение кучи, которое проявится где угодно, кроме места ошибки.
Правило простое: для stateful-аллокатора operator== должен сравнивать состояние (например, указатели на один и тот же пул), а не возвращать константу. Если два аллокатора не равны, контейнер при перемещении не украдёт указатель, а перекопирует элементы поэлементно — медленнее, но корректно.
Как перемещение зависит от равенства
При move-присваивании контейнера без propagate_on_container_move_assignment стандарт требует: если аллокаторы равны — украсть буфер, если не равны — выделить свой и переместить элементы. Наивный аллокатор с operator== = true заставит контейнер украсть буфер, принадлежащий чужому пулу. При разрушении исходного контейнера пул освободит блок, а целевой продолжит им пользоваться.
Камень третий: выравнивание
::operator new возвращает память, выровненную под максимальное фундаментальное выравнивание (alignof(std::max_align_t)). Ваш пул, нарезающий большой буфер на блоки, обязан гарантировать то же самое, а для типов с расширенным выравниванием (SIMD-векторы, структуры с alignas(64)) — ещё больше. Если блок пула отдан по адресу, не кратному alignof(T), поведение неопределено: на x86 вы можете не заметить, на ARM получите аппаратное исключение, а с AVX-инструкциями — падение на выровненной загрузке.
В C++17 появился ::operator new(std::size_t, std::align_val_t) для переопределённого выравнивания. Если ваш аллокатор поддерживает over-aligned типы, обрабатывать выравнивание нужно явно; детали и точную сигнатуру смотрите в документации по overaligned-версиям operator new.
Сравнение стратегий
| Стратегия | Что выигрываете | Чем платите | Когда брать |
|---|---|---|---|
| Stateless (обёртка над new/delete) | Простота, любые контейнеры, безопасное копирование | Нет выигрыша в скорости — тот же malloc | Логирование аллокаций, замена глобального new точечно |
| Pool на фиксированный размер | Аллокация за O(1), нет фрагментации | Работает только под один размер блока, сложный rebind | Узлы одного контейнера с частыми add/remove |
| Arena / monotonic | Максимальная скорость, нулевая стоимость deallocate | Память не возвращается до сброса всей арены | Пакетная обработка: собрал, использовал, сбросил |
| std::pmr (C++17) | Полиморфизм, готовые ресурсы, не заражает тип контейнера | Виртуальные вызовы на каждой аллокации | Когда нужна гибкость, а микросекунды не критичны |
Часто std::pmr решает задачу без своего аллокатора
Прежде чем писать Allocator с нуля, посмотрите на std::pmr из заголовка <memory_resource> (C++17). Там уже есть monotonic_buffer_resource (арена) и unsynchronized_pool_resource (пул), а тип контейнера остаётся std::pmr::vector<int> — он не зависит от конкретного ресурса, ресурс передаётся в рантайме.
#include <memory_resource>
#include <vector>
#include <array>
int main() {
// Буфер на стеке: пока хватает — аллокаций в куче нет вообще.
std::array<std::byte, 4096> buffer;
std::pmr::monotonic_buffer_resource pool{buffer.data(), buffer.size()};
std::pmr::vector<int> v{&pool};
for (int i = 0; i < 500; ++i)
v.push_back(i);
// deallocate не делает ничего: память вернётся при разрушении pool.
}Здесь нет rebind-ловушек, нет propagate-флагов и нет ручного выравнивания — всё это уже решено внутри ресурса. Вы теряете виртуальный вызов на аллокации, но для большинства задач это дешевле, чем отлаживать повреждённую кучу в своём аллокаторе.
Что ломается первым и как это ловить
- Повреждение кучи от неверного operator==. Проявляется далеко от причины. Ловится санитайзером: сборка с
-fsanitize=address(GCC/Clang) показывает, что блок освобождён не тем аллокатором. - Неверный размер блока в пуле. Проверяйте
allocateне только дляvalue_type, а для реального типа узла — прогоняйтеstd::list,std::map, а не толькоstd::vector. - Выравнивание. Тестируйте на ARM или под ASan с проверкой alignment, а не только на x86, где нарушение часто проходит незаметно.
- Утечка при исключении из конструктора элемента. Если конструктор
Tкидает послеallocate, освободить блок должен контейнер черезallocator_traits::construct/destroy— не делайте construct вручную в обход traits.
Что делать дальше
Соберите тестовый набор из всех контейнеров, которые будете использовать с аллокатором, и прогоните под AddressSanitizer с массовыми вставками и удалениями — большинство ошибок proagate-логики и размеров блока всплывают именно там. Если выигрыш от собственного аллокатора вы не измерили бенчмарком (например, через google/benchmark) на реальном профиле аллокаций, а взяли на веру — почти наверняка std::pmr::unsynchronized_pool_resource даст сопоставимый результат с меньшим риском. Пишите свой аллокатор только когда pmr-ресурс замерен и оказался узким местом.
Дефектный акт по фото: Осмотр по фотографиям: классификация дефектов, ссылки на нормы, требования к устранению. Готовый акт в Word.
Спросить за 80 ₽Источники: cppreference: Allocator (named requirements) и std::allocator_traits, cppreference: std::pmr::memory_resource и полиморфные аллокаторы
Частые вопросы
У меня C++11, а не C++17. Что изменится?
Почему мой аллокатор компилируется, но падает только с std::map?
operator== у меня всегда возвращает true — это опасно?
Как поймать повреждение кучи, если оно всплывает в случайном месте?
Стоит ли писать свой аллокатор ради скорости или взять std::pmr?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.