История: управление памятью в Rust эволюционировало от одного интерфейса глобального аллокатора (GlobalAlloc, стабилизированный в Rust 1.28) к более гибкой, осознающей типы системе (Allocator, в настоящее время нестабильный, но доступный в std::alloc). GlobalAlloc служит низкоуровневым мостом к примитивам памяти операционной системы (например, malloc, VirtualAlloc), работая исключительно с сырыми указателями и размерами в байтах без информации о типах.
Проблема возникает, потому что GlobalAlloc подвергает манипуляции с сырой памятью, которые компилятор не может проверить. Реализаторы должны вручную обеспечивать критические инварианты: гарантии выравнивания, соответствие выделения/освобождения и запрет на двойное освобождение. Поскольку GlobalAlloc служит основой для Box, Vec и Rc, любое нарушение вызывает неопределенное поведение по всему программе, что требует маркера unsafe impl для указания на то, что программист принимает на себя ответственность за эти контракты безопасности.
Решение заключается в строгом соблюдении контракта Layout. Метод alloc должен возвращать указатель, соответствующий Layout::align(), и dealloc должно вызываться только с идентичным макетом, использованным для выделения. Кроме того, аллокатор должен гарантировать, что память не будет восстановлена, пока она все еще ссылается на безопасные абстракции. Трейт Allocator снижает эти риски, предоставляя безопасный, обобщенный интерфейс, который обрабатывает вычисления Layout внутри, делегируя небезопасные операции базовым реализациям GlobalAlloc.
use std::alloc::{GlobalAlloc, Layout, System}; use std::sync::atomic::{AtomicUsize, Ordering}; struct CountingAllocator { bytes_allocated: AtomicUsize, } unsafe impl GlobalAlloc for CountingAllocator { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { let ptr = System.alloc(layout); if !ptr.is_null() { self.bytes_allocated.fetch_add(layout.size(), Ordering::SeqCst); } ptr } unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { System.dealloc(ptr, layout); self.bytes_allocated.fetch_sub(layout.size(), Ordering::SeqCst); } } #[global_allocator] static GLOBAL: CountingAllocator = CountingAllocator { bytes_allocated: AtomicUsize::new(0), };
Команда, разработавшая движок высокочастотной торговли, заметила, что аллокатор стандартной библиотеки вызывает неприемлемые колебания задержки из-за конкуренции за блокировки в глобальной кучи. Им требовался кастомный аллокатор буфера, предварительно выделенный из большой страницы, чтобы обеспечить локальный для NUMA, детерминированный доступ к памяти для обновлений книги заказов в горячем пути.
Несколько решений были оценены. Первый подход состоял в том, чтобы обернуть системный аллокатор в пул, защищенный мьютексом, но это лишь сместило конкуренцию и нарушило требования к задержке. Второй подход заключался в использовании нестабильного API Allocator с ночной версией Rust, создавая типизированную арену для специфических структур заказа; однако это требовало обширной переработки использования Vec и Box по всему коду и столкнулось с проблемами стабильности для развертывания в производстве.
Третье решение, в конечном итоге выбранное, реализовало GlobalAlloc, чтобы перехватить все динамические выделения внутри торгового потока, направляя их через локальный для потока аллокатор буфера, поддерживаемый регионами mmap. Эта реализация требовала unsafe impl, потому что аллокатор буфера управлял сырыми указателями и должен был гарантировать, что возвращенные указатели сохраняли выравнивание вплоть до границ кэш-строки в 64 байта. Команда выбрала этот путь, потому что это обеспечивало вмешательство на уровне системы, не модифицируя существующие типы коллекций, хотя это требовало строгого тестирования с Miri, чтобы подтвердить, что Layout, переданный в dealloc, всегда совпадал с исходным выделением. Результат — снижение задержки p99 на 40%, хотя команда поддерживала строгий протокол аудита для блоков unsafe кода, чтобы предотвратить утечки памяти во время необычной волатильности рынка.
Почему Layout, переданный в dealloc, должен точно соответствовать тому, который был дан в alloc, и что происходит, если размеры различаются, но выравнивание верно?
Контракт GlobalAlloc требует битового соответствия между Layout, использованным для выделения и освобождения, потому что многие аллокаторы (например, jemalloc или dlmalloc) встраивают метаданные в выделяемый блок или поддерживают списки, сегрегированные по размеру классов. Передача другого размера – даже меньшего – заставляет аллокатор искать в неправильной корзине или вычислять неправильный смещение для объединения, что приводит к повреждению кучи или уязвимостям двойного освобождения. Это отличается от free в C, который обычно требует только указатель, что делает требование Rust более строгим, но необходимым для агностицизма аллокатора.
Как GlobalAlloc взаимодействует с Box::new, когда коробка позже удаляется, и почему реализация Drop для самого аллокатора проблематична?
Когда вызывается Box::new, он вызывает GlobalAlloc::alloc через статическое #[global_allocator]. При удалении Box компилятор вставляет вызов GlobalAlloc::dealloc с автоматически вычисленным Layout типа. Кандидаты часто упускают, что реализация GlobalAlloc сама должна быть 'static и потокобезопасной (реализуя Sync), но она не должна хранить состояние, которое ссылается на управляемую выделенную память, так как это создаёт круговую зависимость, где удаление аллокатора потребует доступа к самому себе, что потенциально вызывает использование после освобождения во время завершения программы.
Что отличает требования безопасности GlobalAlloc::alloc_zeroed от alloc, и почему реализация просто не может вызывать alloc, а затем std::ptr::write_bytes?
Хотя alloc_zeroed теоретически может быть реализован как alloc плюс обнуление, стандартная библиотека предоставляет его как отдельный метод, чтобы позволить аллокаторам использовать оптимизации страниц с обнулением, специфичные для ОС (например, MAP_ANONYMOUS в Linux возвращает предварительно обнуленные страницы). С точки зрения безопасности, alloc_zeroed должен гарантировать, что возвращенная память содержит нулевые байты, что является более строгим постусловием, чем у alloc (который возвращает неинициализированную память). Если реализация ошибочно утверждает, что обнуление выполнено, но возвращает мусор, безопасный код, предполагающий нулевую инициализацию (что критично для контекстов, чувствительных к безопасности), будет считывать неинициализированные данные, нарушая гарантии безопасности Rust.