Rust’s memory allocator is a surprisingly pluggable system, and you can swap out the global one for entirely custom behavior, even if you’re not writing an embedded system.
Let’s watch this simple allocator in action.
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering};
struct MyAllocator;
unsafe impl GlobalAlloc for MyAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8> {
// Delegate to the system allocator for actual allocation
System.alloc(layout)
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
// Delegate to the system allocator for actual deallocation
System.dealloc(ptr, layout)
}
}
static ALLOCATOR_STATS: MyAllocatorStats = MyAllocatorStats {
total_allocations: AtomicUsize::new(0),
total_deallocations: AtomicUsize::new(0),
};
struct MyAllocatorStats {
total_allocations: AtomicUsize,
total_deallocations: AtomicUsize,
}
unsafe impl GlobalAlloc for MyAllocatorStats {
unsafe fn alloc(&self, layout: Layout) -> *mut u8> {
self.total_allocations.fetch_add(1, Ordering::SeqCst);
System.alloc(layout)
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
self.total_deallocations.fetch_add(1, Ordering::SeqCst);
System.dealloc(ptr, layout)
}
}
// You'd typically use this in your main function like this:
// #[global_allocator]
// static GLOBAL: MyAllocatorStats = MyAllocatorStats { ... };
This code defines MyAllocatorStats, which implements the GlobalAlloc trait. The core of this trait has two methods: alloc and dealloc. When you call Box::new or Vec::new, Rust’s standard library doesn’t directly manage that memory. Instead, it asks the currently registered GlobalAlloc for a chunk of memory. MyAllocatorStats intercepts these requests, increments its atomic counters, and then passes the actual allocation and deallocation work to the System allocator (which is the default behavior if you don’t specify anything).
The problem this solves is fundamental: Rust needs a way to get memory from the operating system. The GlobalAlloc trait provides a standardized interface for this. By implementing this trait, you can hook into every single allocation and deallocation that happens within your Rust program. This is incredibly powerful for performance tuning, debugging memory leaks, or even implementing specialized memory pools.
The Layout struct is crucial. It describes the size and alignment requirements for an allocation. All memory you get back from an allocator must be aligned according to the Layout. The System allocator handles this for you, but if you were implementing your own allocation strategy (e.g., a bump allocator or a free list), you’d need to be very careful about respecting these Layout constraints.
The Ordering::SeqCst (Sequentially Consistent) is the strongest memory ordering for atomics. It ensures that all threads see memory operations in the same order, which is generally what you want when counting events across potentially concurrent allocations. While Relaxed or Acquire/Release might be sufficient for specific, more complex allocator designs, SeqCst is the safest default for simple counters.
The #[global_allocator] attribute is how you tell the Rust compiler to use your custom allocator. You can only have one #[global_allocator] in your entire program.
When you implement GlobalAlloc, you’re essentially building a wrapper around whatever memory management strategy you want. You can delegate to the system allocator for the heavy lifting, as shown here, or you could implement a completely different strategy. For instance, you could pre-allocate a large chunk of memory at startup and then manage that pool yourself, returning slices from it. This is common in embedded systems or high-performance scenarios where you want to avoid the overhead of OS-level allocations or ensure memory fragmentation doesn’t become an issue. The key is that the alloc and dealloc methods must be unsafe because the compiler cannot verify that your implementation correctly handles memory safety (e.g., double frees, out-of-bounds writes).
The most surprising thing about Rust’s allocator system is that the GlobalAlloc trait doesn’t enforce any particular allocation strategy. You could technically implement an allocator that always returns the same pointer, regardless of the Layout, as long as it’s valid for that Layout. This would be a terrible allocator, but the trait itself wouldn’t prevent it. The responsibility for correctness, efficiency, and safety entirely rests on your implementation.
The next step in understanding allocators is often exploring arenas or other custom allocation strategies that manage memory within a pre-allocated buffer.