Historia: Zarządzanie pamięcią w Rust opracowane zostało z jednego globalnego interfejsu alokatora (GlobalAlloc, ustabilizowanego w Rust 1.28) do bardziej elastycznego, świadomego typu systemu (Allocator, obecnie niestabilny, ale dostępny w std::alloc). GlobalAlloc służy jako niskopoziomowy most do pamięci operacyjnego systemu (np. malloc, VirtualAlloc), działając wyłącznie na surowych wskaźnikach i rozmiarach bajtów bez informacji o typie.
Problem pojawia się, ponieważ GlobalAlloc naraża na manipulację pamięcią surową, której kompilator nie może zweryfikować. Implementatorzy muszą ręcznie egzekwować krytyczne invarianty: gwarancje wyrównania, parowanie alokacji/dealokacji oraz zakaz podwójnego zwolnienia. Ponieważ GlobalAlloc wspiera Box, Vec i Rc, jakiekolwiek naruszenie propaguje niezdefiniowane zachowanie w całym programie, co wymaga użycia wskaźnika unsafe impl, aby zaznaczyć, że programista przyjmuje odpowiedzialność za te kontrakty bezpieczeństwa.
Rozwiązanie polega na ścisłym przestrzeganiu umowy Layout. Metoda alloc musi zwracać wskaźnik spełniający Layout::align(), a dealloc może być wywołana tylko z identycznym układem użytym do alokacji. Ponadto, alokator musi zapewnić, że pamięć nie zostanie odzyskana, gdy jest nadal odniesiona przez bezpieczne abstrakcje. Cechy Allocator łagodzą te ryzyka, zapewniając bezpieczny, ogólny interfejs, który wewnętrznie obsługuje obliczenia Layout, delegując operacje niebezpieczne do podstawowych implementacji 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), };
Zespół opracowujący silnik handlu wysokiej częstotliwości zaobserwował, że alokator biblioteki standardowej wprowadzał nieakceptowalne zakłócenia opóźnienia z powodu rywalizacji o zamek w globalnej stercie. Potrzebowali niestandardowego alokatora bump, który byłby wstępnie przydzielony z dużej strony, aby zapewnić lokalny dla NUMA, deterministyczny dostęp do pamięci dla gierek aktualizacji książki zleceń na gorących ścieżkach.
Rozważono kilka rozwiązań. Pierwsze podejście polegało na owijaniu alokatora systemowego w pulę chronioną przez mutex, ale to jedynie przesunęło rywalizację i naruszyło wymagania dotyczące opóźnienia. Drugie podejście wykorzystywało niestabilne API Allocator w wersji nocnej Rust, tworząc typową arenę dla specyficznych struktur porządkowych; jednak wymagało to obszernej refaktoryzacji użyć Vec i Box w całej bazie kodowej oraz napotkało problemy ze stabilnością wdrożenia produkcyjnego.
Trzecie rozwiązanie, które ostatecznie wybrano, implementowało GlobalAlloc, aby przechwycić wszystkie dynamiczne alokacje w wątku handlowym, kierując je przez niestandardowy alokator bump lokalny dla wątku wspierany przez obszary mmap. Ta implementacja wymagała unsafe impl, ponieważ alokator bump zarządzał surowymi wskaźnikami i musiał zapewnić, że zwrócone wskaźniki zachowują wyrównanie do 64-bajtowych granic linii cache. Zespół wybrał tę ścieżkę, ponieważ zapewniała interwencję w całym systemie bez modyfikacji istniejących typów kolekcji, chociaż wymagała rygorystycznego testowania przy użyciu Miri, aby upewnić się, że Layout przekazany do dealloc zawsze odpowiadał pierwotnej alokacji. Efektem było zmniejszenie opóźnienia p99 o 40%, chociaż zespół utrzymywał surowy protokół audytu dla bloków kodu unsafe, aby zapobiec wyciekom pamięci podczas wyjątkowej zmienności rynku.
Dlaczego Layout przekazywany do dealloc musi dokładnie odpowiadać temu, który podano do alloc, i co się stanie, jeśli rozmiar będzie różny, ale wyrównanie jest poprawne?
Umowa GlobalAlloc wymaga tożsamości bitowej między Layout użytym do alokacji i dealokacji, ponieważ wiele alokatorów (takich jak jemalloc czy dlmalloc) osadza metadane w przydzielonym bloku lub utrzymuje listy segregowane według rozmiaru. Przekazanie innego rozmiaru – nawet mniejszego – powoduje, że alokator szuka w niewłaściwej binie lub oblicza błędny offset dla scalania, co prowadzi do uszkodzenia sterty lub podatności na podwójne zwolnienie. To różni się od free w C, które zazwyczaj wymaga tylko wskaźnika, co sprawia, że wymaganie Rust jest surowsze, ale konieczne dla niezależności alokatora.
Jak GlobalAlloc współdziała z Box::new, gdy pudełko zostaje później usunięte, i dlaczego implementacja Drop dla samego alokatora jest problematyczna?
Gdy wywoływana jest Box::new, wywołuje ona GlobalAlloc::alloc przez statyczny #[global_allocator]. Po usunięciu Box, kompilator wstawia wywołanie do GlobalAlloc::dealloc z automatycznie obliczonym Layout typu. Kandydaci często umykają, że sama implementacja GlobalAlloc musi być 'static i bezpieczna dla wątków (implementując Sync), ale nie może przechowywać stanu, który odnosi się do alokowanej pamięci, którą zarządza, ponieważ tworzy to cykliczną zależność, w której usunięcie alokatora wymaga dostępu do samego siebie, co może powodować użycie po zwolnieniu podczas zakończenia programu.
Co odróżnia wymagania dotyczące bezpieczeństwa GlobalAlloc::alloc_zeroed od alloc, i dlaczego implementacja nie może po prostu wywołać alloc, a następnie std::ptr::write_bytes?
Chociaż alloc_zeroed teoretycznie można by zaimplementować jako alloc plus zerowanie, biblioteka standardowa dostarcza ją jako odrębną metodę, aby umożliwić alokatorom wykorzystanie specyficznych dla systemu operacyjnego optymalizacji stronic zerowanych (np. MAP_ANONYMOUS w Linuxie zwraca strony już zerowane). Z perspektywy bezpieczeństwa, alloc_zeroed musi zagwarantować, że zwrócona pamięć zawiera bajty zerowe, co jest silniejszym warunkiem końcowym niż alloc (które zwraca nieinicjowaną pamięć). Jeśli implementacja fałszywie twierdzi, że dokonuje zerowania, ale zwraca śmieci, bezpieczny kod zakładający zerowe zainicjowanie (kluczowe dla kontekstów wrażliwych na bezpieczeństwo) odczyta nieinicjowane dane, naruszając gwarancje bezpieczeństwa Rust.