Введение
По умолчанию все контейнеры STL (std::vector, std::map, std::list и т.д.) используют std::allocator<T>, который обращается к глобальным operator new/operator delete. В большинстве приложений этого достаточно. Но в высокопроизводительных системах — игровых движках, аудио/видео обработке реального времени, embedded-системах — частые обращения к системному аллокатору памяти становятся узким местом или вовсе неприемлемы (например, если нельзя выделять память в реальном времени). Кастомные аллокаторы решают эту проблему, давая контролировать, откуда и как контейнер берёт память.
Концепция
Аллокатор — это шаблонный класс, удовлетворяющий требованиям Allocator (с C++11 интерфейс сильно упростился по сравнению с C++98). Минимально необходимо определить:
using value_type = T;T* allocate(std::size_t n)— выделить память дляnобъектов;void deallocate(T* p, std::size_t n)— освободить память;- конструктор, способный конструироваться из
Allocator<U>для любогоU(rebind через шаблонный конструктор).
Контейнер передаёт тип аллокатора как второй шаблонный параметр: std::vector<T, MyAllocator<T>>.
Частые практические применения: pool-аллокаторы (выделение из заранее зарезервированного блока, без обращения к системе), stack-аллокаторы (для краткоживущих данных в пределах кадра/функции), аллокаторы для разделяемой памяти (mmap), аллокаторы с подсчётом и логированием выделений для отладки утечек.
Пример кода
#include <vector>
#include <memory>
#include <iostream>
#include <cstddef>
// Простой пул фиксированного размера, выделяющий память из заранее
// зарезервированного буфера (без обращения к operator new после старта)
template <typename T, std::size_t Capacity>
class PoolAllocator {
public:
using value_type = T;
PoolAllocator() noexcept = default;
// Требуется для совместимости с rebind (allocator_traits)
template <typename U>
PoolAllocator(const PoolAllocator<U, Capacity>&) noexcept {}
T* allocate(std::size_t n) {
std::size_t bytesNeeded = n * sizeof(T);
if (offset_ + bytesNeeded > buffer_.size()) {
std::cout << "[PoolAllocator] Pool exhausted, falling back to ::operator newn";
return static_cast<T*>(::operator new(bytesNeeded));
}
T* result = reinterpret_cast<T*>(buffer_.data() + offset_);
offset_ += bytesNeeded;
++allocations_;
std::cout << "[PoolAllocator] allocate " << n << " objects ("
<< bytesNeeded << " bytes), total allocations=" << allocations_ << "n";
return result;
}
void deallocate(T* p, std::size_t n) noexcept {
// Простой пул не освобождает отдельные блоки (только сбрасывается целиком).
// Если указатель не принадлежит буферу — освобождаем как обычно.
auto* bytePtr = reinterpret_cast<std::byte*>(p);
if (bytePtr < buffer_.data() || bytePtr >= buffer_.data() + buffer_.size()) {
::operator delete(p);
}
(void)n;
}
template <typename U>
bool operator==(const PoolAllocator<U, Capacity>&) const noexcept { return true; }
template <typename U>
bool operator!=(const PoolAllocator<U, Capacity>&) const noexcept { return false; }
private:
alignas(alignof(T)) std::array<std::byte, Capacity * sizeof(T)> buffer_{};
std::size_t offset_ = 0;
std::size_t allocations_ = 0;
};
int main() {
using MyAlloc = PoolAllocator<int, 16>;
std::vector<int, MyAlloc> pooledVec;
// reserve заставит сделать одну крупную выделение из пула
pooledVec.reserve(10);
for (int i = 0; i < 10; ++i) {
pooledVec.push_back(i * i);
}
std::cout << "Vector contents: ";
for (int v : pooledVec) std::cout << v << " ";
std::cout << "n";
// Превышение capacity пула вызовет fallback на обычный operator new
pooledVec.reserve(100);
for (int i = 0; i < 50; ++i) pooledVec.push_back(i);
std::cout << "Final size: " << pooledVec.size() << "n";
return 0;
}
Пояснения к коду
PoolAllocator<T, Capacity> хранит статический буфер фиксированного размера (Capacity * sizeof(T) байт) и выделяет из него память линейно, увеличивая offset_ при каждом запросе. Когда std::vector решает увеличить capacity (например, в reserve или автоматический рост при push_back), он вызывает allocate, получая память напрямую из этого буфера, без обращения к куче через operator new — это типичный паттерн для систем, где нужно минимизировать или вовсе исключить динамические выделения в критичных участках (например, в аудио-потоке реального времени).
Шаблонный конструктор PoolAllocator(const PoolAllocator<U, Capacity>&) необходим, потому что внутренние структуры контейнеров (например, узлы std::list или std::map) аллоцируют не T, а вспомогательные внутренние типы — std::allocator_traits использует rebind, чтобы получить PoolAllocator<InternalNode, Capacity> из PoolAllocator<T, Capacity>.
deallocate в этом упрощённом примере не возвращает память в пул индивидуально (типичное ограничение простых «арена»-аллокаторов) — память пула логически «теряется» до сброса всего буфера; для блоков, выделенных через fallback ::operator new (когда пул закончился), освобождение выполняется штатно.
Подводные камни и советы
1. operator==/operator!= у аллокаторов критичны: контейнер должен понимать, можно ли «обменять» память между двумя инстансами аллокатора (например, при swap контейнеров). Если аллокаторы стейтфул (как в примере с собственным буфером), сравнение обычно должно учитывать, ссылаются ли они на один и тот же пул. 2. Аллокатор должен быть дёшев в копировании — он копируется вместе с контейнером (если не указано иное через propagate_on_container_* трейты), и хранится по значению внутри инфраструктуры контейнера. 3. Не пишите свой аллокатор «для производительности» без профилирования — современные системные аллокаторы (tcmalloc, jemalloc, тот же стандартный malloc в современных рантаймах) уже очень эффективны; кастомный аллокатор оправдан в нишевых случаях (real-time constraints, специальные виды памяти, предсказуемость задержек), а не «просто чтобы было быстрее». 4. Используйте std::pmr (polymorphic memory resources, C++17) как более гибкую альтернативу шаблонным аллокаторам, если нужно менять стратегию выделения в runtime без изменения типа контейнера — std::pmr::vector<T> использует единый тип независимо от конкретного memory_resource. 5. Тщательно протестируйте выравнивание (alignof) и корректную обработку исключений при выделении — баги в аллокаторах крайне сложно диагностировать, так как они проявляются как порча памяти в совершенно других местах программы.
================================================================================