Мне в Rust всегда заходила одна штука. Он довольно быстро приучает не держать в голове мусор из серии «а я точно это освободил??». Большая часть рутины с памятью уезжает в автоматизм языка, и ты можно прям выдохнуть и думать про данные и инварианты, а не про то, где у тебя очередной free потерялся.

Но...

Как только начинаешь копать чуть глубже, выясняется, что у Rust есть вполне конкретная рука, которая раздаёт память под все эти Box::new(42), Vec::push и растущие String. Имя этой руке простое: аллокатор. Он отвечает за то, что происходит в куче, и именно через него проходят почти все интересные истории про производительность и поведение памяти.

Что такое аллокатор и при чём здесь куча?

Аллокатор это часть рантайма, которая умеет делать две базовые вещи: выделить кусок памяти и освободить его обратно. Ничего сверхъестественного, на вход размер и выравнивание, на выход указатель. Когда вы пишете Box::new(42) или пушите элемент в Vec, где уже не осталось места, Rust в какой то момент делает запрос в глобальный аллокатор: «дай мне N байт с align M».

По смыслу аллокатор такая вот прослойка между кодом и памятью операционной системы. ОС обычно умеет выдавать память крупными порциями, а аллокатор уже режет это добро на куски помельче, следит за свободными блоками, пытается не разнести кучу фрагментацией и по возможности не тормозить.

В языках с GC память может жить дольше, чем вы думаете, потому что сборщик решит, когда пора. В Rust освобождение происходит детерминированно, объект вышел из области видимости, сработал Drop, память пошла обратно. Но аллокатор при этом остаётся максимально глупцом в хорошем смысле, он не знает ничего про типы, владение и ваши красивенькие абстракции. Он видит только Layout,размер и выравнивание. Всё остальное это уже забота вызывающей стороны.

Теперь про стек и кучу. В Rust, как и в большинстве системных языков, есть две большие зоны:

  • Стек: локальные переменные, предсказуемая структура, живёт по принципу «вошёл, вышел».

  • Куча: динамика, когда размер заранее не известен или данных много. Вектор, строка, боксы, деревья, хешмапы.

Аллокатор работает именно с кучей. Получает запрос, находит свободное место, возвращает адрес. Когда память освобождают, он помечает участок как свободный и может выдать его снова под будущие запросы. То есть глобальный аллокатор буквально менеджер кучевой памяти всего приложения.

По дефолту Rust использует либо системный аллокатор ОС, либо jemalloc, про него дальше поговорим отдельно. Но момент такой: долгое время это было жёстко прошито. Что дали по умолчанию, то и ешь. Хотел другой аллокатор под свои паттерны выделений, под размер бинарника, под профилирование или под специфичную среду? Сочувствую.

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

Глобальный аллокатор Rust

Исторически у Rust было такое детство с толстым рантаймом. Тогда решили, раз уж всё равно тащат рантайм, то почему бы сразу не положить внутрь нормальный аллокатор и не зависеть от того, как себя ведёт системный malloc на конкретной машине. Так в стандартную библиотеку и попал jemalloc. Его любили за предсказуемость и за то, что на многих нагрузках он реально держал удар лучше типичного malloc из libc.

По факту до 2018 года картина была такая: на Unix-платформах Rust по умолчанию жил на jemalloc, а на винда использовала системный HeapAlloc. И вроде бы удобно, поставил Rust, получил хороший аллокатор, меньше сюрпризов.

Но довольно быстро всплыли два нюанса, которые в сумме сделали эту схему неудобной.

Первый: жёсткая привязка. Аллокатор был фактически частью стандарта. Хотел заменить его под свою задачу? Не мог. Хотел подключить другой аллокатор ради бенчей, профилирования или размера бинарника? Тоже не мог.

Второй: стоимость в бинарнике. Встраивание jemalloc добавляло ощутимый хвост к размеру итогового исполняемого файла, в районе сотен килобайт. Для серверного приложения это не проблема, а вот для маленьких утилит, CLI, wasm и всяких мелочевок уже начинает раздражать. Плюс отдельная тема: поддержка, совместимость, обновления, поведение на разных платформах.

Переломный момент пришёл с Rust 1.28. В этой версии стабилизировали механизм замены глобального аллокатора через атрибут #[global_allocator]. То есть наконец появилась нормальная официальная точка входа.

С этого момента мы получили право явно указать, какой аллокатор использовать в программе: системный, jemalloc, или вообще свой. И чтобы это работало, в стандартную библиотеку добавили готовую обёртку для системного аллокатора: std::alloc::System.

Дальше был логичный шаг в Rust 1.32: аллокатор по умолчанию сменили на системный на всех платформах. Jemalloc перестал быть частью стандартной поставки. Идея простая, по умолчанию используем то, что предоставляет ОС, а если тебе нужен jemalloc, подключай его осознанно как зависимостью Для этого появился внешний крейт jemallocator: подключил, выставил глобальным аллокатором и вернулся к jemalloc буквально в пару строк.

И вот в текущей реальности дефолт обычно такой, на Linux чаще всего это glibc malloc, на Windows тот самый HeapAlloc. И, честно, для подавляющего большинства проектов этого хватает с головой.

В 99% приложений аллокатор по умолчанию вообще не всплывает как тема. Пишешь код, спокойно пользуешься Vec, Box, String, и всё работает. Но иногда хочется поменять поведение осознанно: ради диагностики, предсказуемости, размера, специфического профиля аллокаций или странной платформы. Посмотрим, как это делается руками.

Как переопределить глобальный аллокатор

Замена глобального аллокатора в Rust — операция довольно простая.

Достаточно реализовать типаж GlobalAlloc для некого типа и пометить статическую переменную этим типом атрибутом #[global_allocator]. В момент запуска программы Rust использует указанный объект как глобальный аллокатор для всех операций работы с кучей. Такой объект должен быть объявлен static, а не локальной внутри функции. Если попытаться поставить атрибут на что‑то иное, компилятор выдаст ошибку allocators must be statics.

Для примера переключим аллокатор на системный. Предположим, пишем мы приложение под Unix, где по дефолту стоял бы jemalloc (хотя теперь уже нет, но представим). Хочется явно указать, что используем системный malloc. Воспользуемся типом std::alloc::System, он уже реализует GlobalAlloc и ссылается на ОС.

Код:

use std::alloc::System;

#[global_allocator]
static GLOBAL_ALLOCATOR: System = System;

fn main() {
    let mut v = Vec::new();
    v.push(123);
    println!("Len = {}, capacity = {}", v.len(), v.capacity());
}

Всего-то и дел, одна статическая переменная с атрибутом. Теперь все выделения памяти в куче пойдут через System, то есть через системный аллокатор ОС. В примере выше при выполнении v.push(123) вектор при необходимости вызовет malloc внутри через FFI-вызовы к библиотеке C. Конечно, данный код очень банальный, он делал бы то же самое и без нашего явного указания, ведь с Rust 1.32 системный аллокатор и так в деле. Но мы тут уже явно контролируем выбор.

Таким же способом можно подключить другой готовый аллокатор. Например, вернём легендарный jemalloc:

# добавляем в Cargo.toml зависимость
jemallocator = "0.3"    # версия может отличаться
use jemallocator::Jemalloc;

#[global_allocator]
static GLOBAL_ALLOCATOR: Jemalloc = Jemalloc;

fn main() {
    // ....
}

Библиотека jemallocator дает тип Jemalloc с нужной реализацией, и мы просто устанавливаем его глобально. Вуаля, cнова используем jemalloc, как в старые добрые времена, но уже контролируемо и обновляемо отдельно от самого Rust. Аналогично существуют крейты для других популярных аллокаторов, их подключение выглядит точно так же.

Конечно, одного лишь переключения глобального аллокатора бывает недостаточно для нетривиальных случаев. Иногда требуется написать свой собственный аллокатор, например, для спец. режима работы или экспериментов. Что ж, Rust и это умеет, было бы желание взяться за unsafe‑код. Пора перейти к самому интересному, реализации своего аллокатора.

Реализуем свой аллокатор

Чтобы создать пользовательский аллокатор нужно реализовать трейт std::alloc::GlobalAlloc. Этот трейт содержит несколько методов, два из которых обязательны к реализации:

  • unsafe fn alloc(&self, layout: Layout) -> *mut u8 должен выделить кусок памяти размером layout.size() с выравниванием layout.align() и вернуть указатель на него, или вернуть null_mut(), если памяти не хватило).

  • unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) должен освободить ранее выделенный блок памяти, адрес которого передан в ptr, и размер/выравнивание того блока указаны в layout.

Кроме них, есть ещё методы realloc (изменить размер уже выделенного блока) и alloc_zeroed (выделить и обнулить память),они имеют дефолтные реализации, так что их можно не трогать, хотя при желании можно переопределить для оптимизации.

Почему методы помечены как unsafe?

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

В случае нехватки памяти лучше возвращать null‑указатель или вызывать функцию std::alloc::handle_alloc_error(layout) (она по дефолту завершит программу с ошибкой). Но бросать исключения(паниковать) точно нельзя.

Также трейтом GlobalAlloc объявлен как unsafe trait. Реализуя его, вы берёте на себя соблюдение ряда контрактов.

В общем, нужно писать аккуратно и сознательно.

Аллокатор-счётчик

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

За базу возьмем системный аллокатор и добавим счётчик поверх него. В Rust уже есть тип std::alloc::System, поэтому мы просто вызовем его методы внутри нашего аллокатора. А подсчёт реализуем с помощью атомарной переменной. Код:

use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering};

// пользовательский аллокатор
struct CountingAllocator {
    allocated: AtomicUsize,  // счётчик выделенных байт
}

// GlobalAlloc
unsafe impl GlobalAlloc for CountingAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        // выделяем память через системный аллокатор
        let ptr = System.alloc(layout);
        if !ptr.is_null() {
            // успешно выделено layout.size() байт, увеличим счётчик
            self.allocated.fetch_add(layout.size(), Ordering::Relaxed);
        }
        ptr
    }

    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        // освобождаем память через системный аллокатор
        System.dealloc(ptr, layout);
        // уменьшаем счетчик на размер освобождённого блока
        self.allocated.fetch_sub(layout.size(), Ordering::Relaxed);
    }
}

// выставляем аллокатор глобальным
#[global_allocator]
static GLOBAL_ALLOC: CountingAllocator = CountingAllocator {
    allocated: AtomicUsize::new(0),
};

Каждый вызов alloc просто делегируем стандартному System. Если он вернул ненулевой указатель, считаем, что память выделена успешно, и увеличиваем наш счётчик allocated на запрошенный размер. В dealloc аналогично вызываем System.dealloc, а потом уменьшаем счётчик. Получается, allocated в любой момент времени отражает чистое количество байт, которое сейчас занято в куче через наш аллокатор.

Мы пометили методы unsafe — потому что трейтовые методы такие, но внутри аккуратно вызываем безопасные обёртки System.alloc/System.dealloc. Это позволительно, так как реализация System сама использует unsafe внутри, а нам снаружи её интерфейс безопасен.

Также обратите внимание, что наш CountingAllocator не содержит явных грубых данных (только атомарный счётчик), поэтому мы смело можем сделать его static. Важно сделать адекватный Sync, ведь глобальная переменная может использоваться из разных потоков. В данном случае AtomicUsize сам по себе реализует Sync, а больше в нашем типе ничего нет, компилятор и так позволит static без дополнительного вмешательства. Но если бы там была, скажем, RefCell или несинхронизированный указатель, пришлось бы явно пометить unsafe impl Sync for CountingAllocator {}.

Теперь проверим аллокатор. В функции main создадим какие‑нибудь динамические объекты и выведем статистику:

fn main() {
    // создаем вектор и заполняем его
    let mut v = Vec::with_capacity(100);
    for i in 0..100 {
        v.push(i);
    }
    println!("Через наш аллокатор выделено {} байт", GLOBAL_ALLOC.allocated.load(Ordering::Relaxed));

    // освободим половину элементов
    v.truncate(50);
    println!("После partial free осталось занято {} байт", GLOBAL_ALLOC.allocated.load(Ordering::Relaxed));

    // явно очищаем вектор 
    v.clear();
    println!("После очистки вектора занято {} байт", GLOBAL_ALLOC.allocated.load(Ordering::Relaxed));
}

Запустив эту программу, получим следующее:

Через наш аллокатор выделено 800 байт  
После partial free осталось занято 800 байт  
После очистки вектора занято 0 байт

При резервировании ёмкости вектора with_capacity(100) аллокатор выдал под него память. При усечении вектора до 50 элементов память не освобождается, емкость capacity остаётся прежней, поэтому занято тех же 800 байт. А вот после полного очищения (clear()), вектор освободил буфер, и аллокатор зафиксировал 0 байт занятых.

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

CountingAllocator основан на системном аллокаторе. А можно ли написать полностью свой аллокатор, не опираясь на готовый? Да, можно, хотя и сложнее.

Реализуем простейший аллокатор типа bump allocator.

Простенький bump-аллокатор

Bump allocator выдаёт память из заранее выделенного буфера простым движением указателя. Представьте себе длинный непрерывный массив байтов, и указатель конца использованной области, который смещается вперёд при каждом новом выделении.

Такой аллокатор очень быстры,по сути, ему нужно лишь проверить, влезает ли запрошенный блок в оставшееся пространство, и вернуть указатель на начало свободного места, сдвинув внутренний указатель. Освобождение памяти в классическом bump-аллокаторе обычно не поддерживается.

Заведём статический буфер фиксированного размера, и будем выдавать из него память подряд. Освобождать ничего не будем, ну, разве что весь буфер целиком теоретически можно обнулить, но в примере не потребуется. Такой аллокатор будет работать как наивность конечно, зато без внешних зависимостей.

Набросаем код:

use core::cell::Cell;
use core::ptr::null_mut;
use std::alloc::{GlobalAlloc, Layout};

// размер кучи в байтах
const ARENA_SIZE: usize = 1024 * 1024; // 1 МБ для примера

struct SimpleBumpAllocator {
    arena: [u8; ARENA_SIZE],
    next: Cell<usize>,  // индекс следующего свободного байта в arena
}

// сделаем аллокатор единственным во всей программе
unsafe impl Sync for SimpleBumpAllocator {}

// GlobalAlloc
unsafe impl GlobalAlloc for SimpleBumpAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let size = layout.size();
        let align = layout.align();
        // найдем начало блока с учётом выравнивания
        let current = self.next.get();
        let aligned_start = align_up(current, align);
        let end = aligned_start + size;
        if end > ARENA_SIZE {
            // не хватает места
            null_mut()
        } else {
            // выдаем указатель на свободный участок
            self.next.set(end);
            self.arena.as_ptr().add(aligned_start) as *mut u8
        }
    }

    unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
        // ничего не делаем, bump-аллокатор не умеет освобождать по одному блоку
    }
}

// выровнять адрес вверх до align
const fn align_up(offset: usize, align: usize) -> usize {
    let mask = align - 1;
    // выравниваем offset на ближайшее кратное align
    if offset & mask == 0 {
        offset
    } else {
        (offset + mask) & !mask
    }
}

// объявляем глобальный статический аллокатор
#[global_allocator]
static GLOBAL_ALLOC: SimpleBumpAllocator = SimpleBumpAllocator {
    arena: [0; ARENA_SIZE],
    next: Cell::new(0),
};

Получаем запрос, сколько байт и с каким выравниванием нужно. Берём текущий индекс self.nextи выравниваем его вверх до требуемого align. Для этого используем небольшую функцию align_up: она поднимает значение offset до ближайшего, которое делится на align. После выравнивания проверяем, хватит ли места в нашем массиве arena. Если да, то новое значение end станет началом следующего свободного места, а aligned_start — начало выделяемого блока. Возвращаем указатель на arena[aligned_start]. Если места уже не осталось — возвращаем null_mut(), сигнализируя об ошибке (в суровом мире стоило бы вызвать handle_alloc_error, но для простоты опустим).

Метод dealloc у нас пустой, сознательно не поддерживаем освобождение отдельных блоков. Можно было бы пометить их как свободные, но это усложнило бы код, нужна структура данных для свободных областей, то есть это уже другой тип аллокатора. Bump‑аллокатор обычно используют там, где живут кучей много маленьких объектов, а потом сразу все больше не нужны. Например, парсим файл, накучковали объектов, обработали, затем можно всю арену сразу сбросить. У нас сброс тоже не реализован, но можно добавить метод, который просто делает self.next.set(0), то есть повторно открывает весь буфер для использования.

Пару слов о свойствах этого аллокатора:

  • Операции alloc очень быстрые. По сути, это O(1), одна проверка и сдвиг индекса. Нет поиска по спискам свободных областей, нет сложных структур.

  • Отсутствие освобождения и плюс, и минус. Плюс в том, что накладных расходов меньше (мы не храним метаданные для блоков, не выполняем слияние областей и т.п.). Минус очевиден, память не возвращается системе до конца жизни арены. Если делать много аллокаций и не освобождать, можно вырасти в памяти.

  • SimpleBumpAllocator использует Cell<usize> для указателя next. Cell не является Sync, то есть доступен только из одного потока. Побили правило, пометив весь аллокатор как Sync вручную. Это не безопасно в общем случае, если два потока одновременно вызовут alloc, они оба могут прочитать старое значение next и выдать пересекающиеся области памяти. Наш аллокатор этому не препятствует. Мы пошли на это для упрощения кода. В жизни для глобального аллокатора, конечно, нужно обеспечить синхронизацию. Можно было бы использовать std::sync::Mutex или атомарную операцию fetch_update для адекватного увеличения next.

  • Размер буфера ограничен.

Тем не менее, даже в таком виде наш аллокатор работоспособен. Можно попробовать им воспользоваться:

fn main() {
    // попробуем выделить несколько объектов
    let a = Box::new(42);               // выделит 4 или 8 байт из arena
    let b = Box::new([0u8; 256]);       // выделит 256 байт
    let c = vec![0u8; 1024];           // попробует выделить 1024 байта
    println!("a = {}, b[0] = {}, c.len = {}", *a, b[0], c.len());
}

Если мы напишем и запустим такой код, он будет работать. Но если попробовать выделить больше памяти, чем у нас есть (например, попросить vec![0u8; 2000000], то есть ~2 МБ при арене 1 МБ), наш аллокатор вернёт null, а стандартная библиотека вызовет handle_alloc_error, в результате получим panic с сообщением о нехватке памяти.

Реализация получилась небезопасная!

Подобным образом работают многие специализированные аллокаторы. Когда мы пишем на Rust без стандартной библиотеки, стандартный глобальный аллокатор недоступен, и зачастую подключают простейший аллокатор. Без этого Box, Vec и другие полезные вещи работать не будут, т.к. им негде взять память. Так что вообще уметь создать свой аллокатор, практически обязательный навык в системной разработке. Кстати, в репозитории rust-lang/alloc есть коллекция готовых реализаций разных аллокаторов.

Перспективы

Мы говорили всё про глобальный аллокатор, который один на всё приложение. Но иногда хочется, чтобы разные структуры данных могли использовать разные аллокаторы. В C++ это решается шаблонами и параметром аллокатора в контейнерах. В Rust долгое время такой возможности не было в стабильном API, стандартные Vec, Box жестко завязаны на глобальный аллокатор. Однако работа в этом направлении ведётся.

В стандартной библиотеке уже есть экспериментальный трейт std::alloc::Allocator (не путать с GlobalAlloc) и тип Global, они позволяют параметризовать контейнеры аллокаторами Например, в будущем можно будет сделать что‑то вроде Vec::new_in(my_alloc), и вектор будет брать память из my_alloc, а не из глобального. Эти возможности пока нестабильны (требуют включения nightly‑фичи allocator_api), но в перспективе, возможно, станут частью стандарта. Тогда мы сможем комбинировать разные аллокаторы в одном приложении, не ограничиваясь единственным глобальным.

Кстати, уже сейчас доступны некоторые крейты, позволяющие использовать локальные аллокаторы.

Например, bumpalo дает удобный bump-аллокатор для локальных задач, вы создаёте объект арены и можете передавать его в свои структуры, которые на нём аллоцируют объекты. Это не интеграция в стандартные Vec/Box, но тоже вполне рабочий путь для изолированной памяти.

В любом случае, Rust продолжает развивать тему управления памятью.


Экспериментируйте, пробуйте написать что-то своё. В любом случае, даже если вы никогда не будете писать собственный аллокатор, вы теперь лучше понимаете эту тему. А это сразу делает вас сильнее как разработчика.

Спасибо за внимание.


Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

Воспользоваться

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


  1. ReadOnlySadUser
    22.12.2025 09:39

    Аллокатор был фактически частью стандарта

    Нет у раста стандарта)) Ну да ладно, это мелочи. Когда уже завезут не глобальные аллокаторы, а отдельно под каждый контейнер? Мне вот совсем не нужно bump allocator для всей программы)


    1. aegoroff
      22.12.2025 09:39

      Вам тогда в zig надо. Там аллокаторы везде с собой таскают :)


      1. Siemargl
        22.12.2025 09:39

        Зачем в Циг, когда давно есть в Стандарте?


        1. aegoroff
          22.12.2025 09:39

          ну как сказали выше - нет у раст стандарта, это первое. Ну и изначальный вопрос был - "Когда уже завезут не глобальные аллокаторы, а отдельно под каждый контейнер?" этого тоже в раст нет, это второе


          1. Siemargl
            22.12.2025 09:39

            Естественно, Стандарт с большой буквы это С++

            =)


            1. aegoroff
              22.12.2025 09:39

              а каким боком тут C++?


              1. Siemargl
                22.12.2025 09:39

                Таким, каким ты предлагал Zig?


                1. aegoroff
                  22.12.2025 09:39

                  Ладно, я понял


  1. rutenis
    22.12.2025 09:39

    А вот после полного очищения (clear()), вектор освободил буфер, и аллокатор зафиксировал 0 байт занятых.

    Что-то тут не так: по описанию Vec::clear

    Clears the vector, removing all values.

    Note that this method has no effect on the allocated capacity of the vector.

    Поэтому после вызова v.clear() должны были остаться те же 800 байт. Предполагаю, что это компилятор вставил drop(v) сразу после вызова v.clear() и перед println.


    1. KanuTaH
      22.12.2025 09:39

      Честно говоря, на плейграунде поведение у этого кода "несколько отличается":

      Через наш аллокатор выделено 948 байт
      После partial free осталось занято 1972 байт
      После очистки вектора занято 1972 байт


  1. ptr128
    22.12.2025 09:39

    Свой глобальный аллокатор нужен очень редко. До тех пор, пока нет возможности передать ему хотя бы пул, из которого хочется выделить память. На МК это было бы очень востребовано.