Geschichte: Das Speichermanagement von Rust hat sich von einer einzigen globalen Allocator-Schnittstelle (GlobalAlloc, stabilisiert in Rust 1.28) zu einem flexibleren, typbewussten System (Allocator, derzeit instabil, aber verfügbar in std::alloc) entwickelt. GlobalAlloc fungiert als die niedrigstufige Schnittstelle zu den Speichermethoden des Betriebssystems (z. B. malloc, VirtualAlloc), die ausschließlich auf Rohzeigern und Byte-Größen ohne Typinformationen operiert.
Das Problem entsteht, weil GlobalAlloc die Rohspeicherbearbeitung offenlegt, die der Compiler nicht überprüfen kann. Implementierer müssen manuell kritische Invarianten durchsetzen: Ausrichtungsanforderungen, Zuordnungs-/Freigabe-Paarung und das Verbot von doppelten Freigaben. Da GlobalAlloc die Basis für Box, Vec und Rc bildet, führt jede Verletzung zu undefiniertem Verhalten im gesamten Programm und erfordert das unsafe impl-Marker, um anzuzeigen, dass der Programmierer die Verantwortung für diese Sicherheitsverträge übernimmt.
Die Lösung besteht in einer strengen Einhaltung des Layout-Vertrags. Die alloc-Methode muss einen Zeiger zurückgeben, der Layout::align() erfüllt, und dealloc darf nur mit dem identischen Layout aufgerufen werden, das zur Zuordnung verwendet wurde. Darüber hinaus muss der Allocator sicherstellen, dass der Speicher nicht zurückgefordert wird, während er noch von sicheren Abstraktionen referenziert wird. Der Allocator-Trait mindert diese Risiken, indem er eine sichere, generische Schnittstelle bereitstellt, die Layout-Berechnungen intern behandelt und die unsicheren Operationen an die zugrunde liegenden GlobalAlloc-Implementierungen delegiert.
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), };
Ein Team, das einen Hochfrequenzhandel-Engine entwickelt, stellte fest, dass der Allocator der Standardbibliothek eine unakzeptable Verzögerungsschwankung aufgrund von Sperrkonkurrenz im globalen Heap einführte. Sie benötigten einen benutzerdefinierten Bump Allocator, der aus einem riesigen Seitenbereich vorab zugewiesen wurde, um einen NUMA-lokalen, deterministischen Speicherzugriff für Hot-Path-Orderbuchaktualisierungen sicherzustellen.
Mehrere Lösungen wurden evaluiert. Der erste Ansatz erwog, den Systemallocator mit einem mutex-geschützten Pool zu umwickeln, aber das verschob lediglich die Konkurrenz und verletzte die Latenzanforderungen. Der zweite Ansatz bestand darin, die instabile Allocator-API mit nightly Rust zu verwenden, um eine typisierte Arena für bestimmte Ordersstrukturen zu schaffen; dies erforderte jedoch umfassende Refaktorisierungen der Vec- und Box-Verwendungen im gesamten Code und stieß auf Stabilitätsprobleme für die Produktionsbereitstellung.
Die dritte Lösung, die letztendlich ausgewählt wurde, implementierte GlobalAlloc, um alle dynamischen Zuweisungen innerhalb des Handels-Threads abzufangen und sie über einen thread-lokalen Bump Allocator zu routen, der durch mmap-Bereiche unterstützt wurde. Diese Implementierung erforderte unsafe impl, da der Bump Allocator Rohzeiger verwaltete und garantieren musste, dass die zurückgegebenen Zeiger für bis zu 64-Byte-Cache-Linien-Grenzen korrekt ausgerichtet waren. Das Team wählte diesen Weg, da es eine systemweite Intervention bot, ohne bestehende Sammlungstypen zu ändern, obwohl es strenge Tests mit Miri erforderte, um zu validieren, dass das an dealloc übergebene Layout immer mit der ursprünglichen Zuweisung übereinstimmte. Das Ergebnis war eine 40%ige Reduktion der p99-Latenz, obwohl das Team ein strenges Prüfprotokoll für die unsafe-Code-Blöcke aufrechterhielt, um Speicherlecks bei außergewöhnlicher Marktvolatilität zu verhindern.
Warum muss das an dealloc übergebene Layout genau mit dem übereinstimmen, das an alloc übergeben wurde, und was passiert, wenn die Größe unterschiedlich ist, aber die Ausrichtung korrekt ist?
Der GlobalAlloc-Vertrag erfordert eine bitweise Identität zwischen dem Layout, das für die Zuweisung und Dealloziierung verwendet wird, da viele Allocatoren (wie jemalloc oder dlmalloc) Metadaten innerhalb des zugewiesenen Blocks einbetten oder größenklassenspezifische Listen führen. Das Übergeben einer anderen Größe - selbst einer kleineren - lässt den Allocator im falschen Behälter nachsehen oder einen falschen Offset zum Zusammenführen berechnen, was zu Heap-Korruption oder doppelten Freigaben führen kann. Dies unterscheidet sich von C's free, das typischerweise nur den Zeiger erfordert, und macht Rusts Anforderung strenger, aber notwendig für Allocator-Agnostik.
Wie interagiert GlobalAlloc mit Box::new, wenn die Box später gelöscht wird, und warum ist die Implementierung von Drop für den Allocator selbst problematisch?
Wenn Box::new aufgerufen wird, erfolgt ein Aufruf von GlobalAlloc::alloc über den #[global_allocator]-Statischen. Beim Löschen der Box fügt der Compiler einen Aufruf zu GlobalAlloc::dealloc mit dem automatisch berechneten Layout des Typs ein. Kandidaten übersehen oft, dass die GlobalAlloc-Implementierung selbst 'static und thread-sicher (Implementierung von Sync) sein muss, aber sie darf keinen Zustand halten, der auf den verwalteten Speicher verweist, da dies eine zirkuläre Abhängigkeit schafft, bei der das Löschen des Allocators den Zugriff auf sich selbst erfordert, was potenziell zu einer Verwendung nach Freigabe während des Programmabbruchs führen kann.
Was unterscheidet die Sicherheitsanforderungen von GlobalAlloc::alloc_zeroed von alloc, und warum kann die Implementierung nicht einfach alloc gefolgt von std::ptr::write_bytes aufrufen?
Während alloc_zeroed theoretisch als alloc plus Nullsetzen implementiert werden könnte, bietet die Standardbibliothek es als separate Methode an, um den Allokatoren zu ermöglichen, betriebssystemspezifische Nullseitenoptimierungen zu nutzen (z. B. gibt MAP_ANONYMOUS unter Linux vorab nullierte Seiten zurück). Aus einer sicherheitstechnischen Perspektive muss alloc_zeroed garantieren, dass der zurückgegebene Speicher Nullen enthält, was eine stärkere Nachbedingung ist als alloc (das uninitialisierten Speicher zurückgibt). Wenn eine Implementierung fälschlicherweise behauptet, sie nullt, aber Müll zurückgibt, würde sicherer Code, der von einer Nullinitialisierung ausgeht (kritisch für sicherheitssensitive Kontexte), uninitialisierte Daten lesen, und die Sicherheitsgarantien von Rust verletzen.