Введение в проблему управления памятью
Когда во время рендеринга сложной игровой сцены или обработки миллионов запросов в секунду микропроцессор начинает простаивать в ожидании данных, причиной часто оказывается не медленная бизнес-логика, а хаос в куче. Стандартные аллокаторы malloc и new универсальны, но их универсальность бьет по производительности: накладные расходы на метаданные и непредсказуемые паузы на сборку мусора или борьбу с фрагментацией могут стоить проекту миллисекунд, критичных для стабильного FPS ((ну или хотя бы того, чтобы ваш код не компилировался слишком долго, пока вы пьете очередной кофе)).
В системном программировании, где каждый такт на счету, разработчики переходят на кастомные решения. Классический стековый аллокатор работает молниеносно по принципу LIFO, но что делать, если жизненный цикл данных требует одновременного роста двух независимых очередей внутри единого непрерывного буфера? Представьте отрисовку кадра, где временные ресурсы для физического движка и UI живут по разным правилам, но делят общую память — здесь на сцену и выходит The Double Headed Stack Allocator.
Анатомия классического стекового аллокатора
Прежде чем заставлять память расти в две стороны сразу, давайте вспомним, как устроен классический стек:
- Непрерывный блок предварительно выделенной памяти (буфер).
- Указатель верхушки стека (stack pointer), указывающий на следующий свободный байт.
- Маркер отката (marker), позволяющий освобождать группу аллокаций за один шаг.
Выделение памяти сводится к чистой арифметике указателей: проверяется доступный объем с учетом выравнивания (alignment), после чего указатель просто смещается вперед. Деаллокация отдельных объектов не поддерживается во избежание фрагментации — вместо этого применяется мгновенный массовый сброс состояния.
Но что если одна структура данных должна жить дольше, а другая уничтожается каждую итерацию? Создавать два отдельных буфера накладно, и именно эту архитектурную боль решают разработчики, когда «на моей машине всё работало идеально, пока не ушло в продакшн».
Архитектура The Double Headed Stack Allocator
Двусторонний стековый аллокатор делит единый предварительно выделенный блок памяти на два виртуальных субстека, которые буквально растут навстречу друг другу:
- Левый стек (Left Head): растет от начала буфера (индекс 0) вправо.
- Правый стек (Right Head): растет от конца буфера (размер буфера) влево.
+------------------+------------------+------------------+
| Left Allocations| Free Space | Right Allocations|
+------------------+------------------+------------------+
^ ^ ^ ^
Buffer Start Left Pointer Right Pointer Buffer End
Главное правило безопасности здесь — зорко следить, чтобы указатели не перешагнули черту (Double Headed Stack Allocator — это как соседи с перфораторами по обе стороны вашей стены: рано или поздно они встретятся, если не контролировать границы). Суммарный объем занятой памяти левым и правым стеками никогда не должен превышать общий размер буфера, иначе мы получим классический и крайне опасный buffer overflow.
Практическая реализация на C++
А теперь давайте превратим теорию в рабочий код и напишем надежный двусторонний стековый аллокатор с корректным выравниванием памяти:
#include <cstdint>
#include <cstddef>
#include <cstdlib>
#include <algorithm>
class DoubleHeadStackAllocator {
private:
uint8_t* m_buffer;
size_t m_capacity;
size_t m_leftTop;
size_t m_rightTop;
static inline size_t align_forward(size_t address, size_t alignment) {
return (address + (alignment - 1)) & ~(alignment - 1);
}
public:
DoubleHeadStackAllocator(size_t size) : m_capacity(size), m_leftTop(0), m_rightTop(size) {
m_buffer = static_cast<uint8_t*>(std::malloc(size));
}
~DoubleHeadStackAllocator() {
std::free(m_buffer);
}
void* allocate_left(size_t size, size_t alignment = alignof(void*)) {
size_t current_address = reinterpre