Chapter 2: The Allocator Traits¶
"It's undefined behavior if global allocators unwind."
— Safety requirements for GlobalAlloc
Introduction¶
With Layout defining what memory we need, we now examine how to get it. Rust provides two allocator traits:
GlobalAlloc— Stable, simple, the current production workhorseAllocator— Unstable, more sophisticated, the future
Both define contracts that any memory allocator must satisfy. We'll trace the path from these abstract traits down to actual libc::malloc calls.
2.1 GlobalAlloc: The Stable Interface¶
pub unsafe trait GlobalAlloc {
unsafe fn alloc(&self, layout: Layout) -> *mut u8;
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout);
// Provided defaults (can be overridden for efficiency)
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8;
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8;
}
Why unsafe trait?¶
The trait itself is unsafe to implement because the compiler cannot verify the guarantees. Implementors must manually ensure:
- No unwinding — Panicking from an allocator is undefined behavior
- Correct layouts — Returned memory must match the requested size and alignment
- Valid pointers — Returned pointers must be usable for reads/writes
The Four Methods¶
alloc — The core allocation method
- Takes a Layout, returns a raw pointer
- Returns null on failure (never panics!)
- Memory is uninitialized — contains garbage
- Zero-sized layouts are undefined behavior
dealloc — Frees memory
- Must receive a pointer from a previous alloc on the same allocator
- Must receive the same layout used to allocate
- Using the wrong layout is undefined behavior
alloc_zeroed — Allocation with zero-initialization
- Default implementation: alloc then memset to zero
- Can be overridden to use calloc for efficiency
realloc — Resize an allocation
- May return the same pointer (grown in place) or a new one
- Copies min(old_size, new_size) bytes to the new location
- Old pointer becomes invalid after successful realloc
2.2 The Safety Contracts¶
These contracts are not checked by the compiler. Violating them causes undefined behavior — crashes, corruption, security vulnerabilities.
Contract for alloc¶
Preconditions (caller must ensure):
- layout.size() > 0 — Zero-sized allocations are UB
Postconditions (implementor must ensure):
- Returns null OR a valid pointer that is:
- Properly aligned to layout.align()
- Valid for reads and writes of layout.size() bytes
- Not overlapping with any other live allocation
Contract for dealloc¶
Preconditions (caller must ensure):
- ptr was returned by a previous alloc or realloc on this allocator
- layout is the same layout used to allocate
- The memory has not already been deallocated
Postconditions: - The memory is released back to the allocator - The pointer becomes invalid — any use is UB
Contract for realloc¶
Preconditions:
- Same as dealloc for ptr and layout
- new_size > 0
- new_size rounded up to layout.align() must not overflow isize
Postconditions:
- If returns null: original allocation is unchanged, ptr still valid
- If returns non-null:
- Original ptr is now invalid
- Returned pointer has the new size
- First min(old_size, new_size) bytes are preserved
2.3 The Default Implementations¶
GlobalAlloc provides default implementations for alloc_zeroed and realloc. Understanding these reveals optimization opportunities.
Default alloc_zeroed¶
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
let size = layout.size();
let ptr = unsafe { self.alloc(layout) };
if !ptr.is_null() {
unsafe { ptr::write_bytes(ptr, 0, size) };
}
ptr
}
This is inefficient — it allocates uninitialized memory then writes zeros. A smart implementation uses calloc, which:
1. May get pre-zeroed pages from the OS
2. Uses copy-on-write to share zero pages
3. Avoids touching memory until actually needed
Default realloc¶
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
let new_layout = unsafe { Layout::from_size_align_unchecked(new_size, layout.align()) };
let new_ptr = unsafe { self.alloc(new_layout) };
if !new_ptr.is_null() {
unsafe {
ptr::copy_nonoverlapping(ptr, new_ptr, cmp::min(layout.size(), new_size));
self.dealloc(ptr, layout);
}
}
new_ptr
}
This is the naive allocate-copy-free strategy: 1. Allocate new block 2. Copy old data 3. Free old block
Real allocators (like glibc) try to grow in place first — if there's free space after the current block, just extend it. This is much faster for growing Vecs.
2.4 The Unix Implementation¶
Now let's see how these traits connect to actual OS memory:
// From library/std/src/sys/alloc/unix.rs
unsafe impl GlobalAlloc for System {
#[inline]
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
if layout.align() <= MIN_ALIGN && layout.align() <= layout.size() {
unsafe { libc::malloc(layout.size()) as *mut u8 }
} else {
unsafe { aligned_malloc(&layout) }
}
}
#[inline]
unsafe fn dealloc(&self, ptr: *mut u8, _layout: Layout) {
unsafe { libc::free(ptr as *mut libc::c_void) }
}
#[inline]
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
if layout.align() <= MIN_ALIGN && layout.align() <= layout.size() {
unsafe { libc::calloc(layout.size(), 1) as *mut u8 }
} else {
let ptr = unsafe { self.alloc(layout) };
if !ptr.is_null() {
unsafe { ptr::write_bytes(ptr, 0, layout.size()) };
}
ptr
}
}
#[inline]
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
if layout.align() <= MIN_ALIGN && layout.align() <= new_size {
unsafe { libc::realloc(ptr as *mut libc::c_void, new_size) as *mut u8 }
} else {
unsafe { realloc_fallback(self, ptr, layout, new_size) }
}
}
}
The Two-Path Strategy¶
Every method has the same pattern: 1. Simple path: If alignment requirements are modest, use standard libc functions 2. Aligned path: If alignment is strict, use special aligned allocation
The check layout.align() <= MIN_ALIGN && layout.align() <= layout.size() determines which path:
MIN_ALIGNis the guaranteed alignment frommalloc(usually 8 or 16 bytes)- The second condition (
align <= size) works around jemalloc quirks where small allocations might not respect alignment
Aligned Allocation¶
unsafe fn aligned_malloc(layout: &Layout) -> *mut u8 {
let mut out = ptr::null_mut();
let align = layout.align().max(size_of::<usize>());
let ret = unsafe { libc::posix_memalign(&mut out, align, layout.size()) };
if ret != 0 { ptr::null_mut() } else { out as *mut u8 }
}
posix_memalign is the POSIX standard for aligned allocation:
- First argument: output pointer (modified by the function)
- Second argument: alignment (must be ≥ sizeof(void*) and power of 2)
- Third argument: size
- Returns 0 on success, error code on failure
Platform-Specific Bug Workarounds¶
Older macOS/iOS versions have a bug where posix_memalign with huge alignments (>2GB) returns a pointer that isn't actually aligned. Rather than silently corrupt memory, Rust returns null immediately.
2.5 The Allocator Trait (Unstable)¶
The newer Allocator trait fixes several limitations of GlobalAlloc:
pub unsafe trait Allocator {
fn allocate(&self, layout: Layout) -> Result<NonNull<[u8]>, AllocError>;
unsafe fn deallocate(&self, ptr: NonNull<u8>, layout: Layout);
// Provided defaults
fn allocate_zeroed(&self, layout: Layout) -> Result<NonNull<[u8]>, AllocError>;
unsafe fn grow(...) -> Result<NonNull<[u8]>, AllocError>;
unsafe fn grow_zeroed(...) -> Result<NonNull<[u8]>, AllocError>;
unsafe fn shrink(...) -> Result<NonNull<[u8]>, AllocError>;
}
Key Differences from GlobalAlloc¶
| Aspect | GlobalAlloc | Allocator |
|---|---|---|
| Error handling | Null pointer | Result<_, AllocError> |
| Pointer type | *mut u8 |
NonNull<[u8]> |
| Zero-sized alloc | Undefined behavior | Allowed |
| Resize operations | Single realloc |
Separate grow/shrink |
| Alignment changes | Not supported | Supported in grow/shrink |
The Fat Pointer Return¶
NonNull<[u8]> is a fat pointer — it contains both the address and the length. This lets the allocator tell you the actual size allocated:
// You ask for 30 bytes, allocator gives you 32 due to internal binning
let result = allocator.allocate(Layout::from_size_align(30, 1)?)?;
let actual_size = result.len(); // Might be 32!
Collections like Vec can use this extra space for free.
Zero-Sized Allocations¶
Unlike GlobalAlloc, Allocator handles ZSTs gracefully:
// This is fine with Allocator
let layout = Layout::new::<()>(); // size=0
let ptr = allocator.allocate(layout)?; // Returns a valid NonNull
The implementation must catch this case and return a dangling-but-valid pointer without calling the underlying allocator.
2.6 The Global Allocator¶
By default, Rust uses the system allocator. You can change it:
use std::alloc::{GlobalAlloc, Layout, System};
#[global_allocator]
static ALLOCATOR: System = System;
Or use a custom allocator:
struct MyAllocator;
unsafe impl GlobalAlloc for MyAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
// Your implementation
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
// Your implementation
}
}
#[global_allocator]
static ALLOCATOR: MyAllocator = MyAllocator;
Example: A Simple Bump Allocator¶
From the GlobalAlloc documentation:
const ARENA_SIZE: usize = 128 * 1024;
#[repr(C, align(4096))]
struct SimpleAllocator {
arena: UnsafeCell<[u8; ARENA_SIZE]>,
remaining: AtomicUsize,
}
unsafe impl GlobalAlloc for SimpleAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let size = layout.size();
let align = layout.align();
let align_mask = !(align - 1);
if align > 4096 {
return null_mut();
}
let mut allocated = 0;
self.remaining
.fetch_update(Relaxed, Relaxed, |mut remaining| {
if size > remaining { return None; }
remaining -= size;
remaining &= align_mask; // Round down for alignment
allocated = remaining;
Some(remaining)
})
.ok()?;
unsafe { self.arena.get().cast::<u8>().add(allocated) }
}
unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
// Bump allocators don't free individual allocations
}
}
This "bump allocator" is extremely fast (just an atomic decrement) but never reuses memory. It's used in compilers, game frames, and other short-lived contexts.
2.7 The Optimizer's Freedom¶
A critical note from the docs:
You must not rely on allocations actually happening, even if there are explicit heap allocations in the source.
The optimizer can:
- Eliminate allocations — drop(Box::new(42)) might not allocate at all
- Move to stack — Small heap allocations might become stack allocations
- Merge allocations — Multiple small allocations might become one
This means debugging allocators that count allocations can be misleading. The count depends on optimization level!
2.8 Key Takeaways¶
- GlobalAlloc is stable and simple — Four methods, raw pointers, null for errors
- Allocator is the future — Result types, fat pointers, ZST support
- Safety contracts are critical — Violating them is undefined behavior
- Default implementations are naive — Override for efficiency
- Platform quirks exist — Real allocators handle bugs in underlying systems
- The optimizer can eliminate allocations — Don't rely on allocation counts
Source Files¶
| File | Purpose |
|---|---|
library/core/src/alloc/global.rs |
GlobalAlloc trait definition |
library/core/src/alloc/mod.rs |
Allocator trait and AllocError |
library/std/src/sys/alloc/unix.rs |
Unix implementation |
library/std/src/sys/alloc/windows.rs |
Windows implementation |
Exercises¶
-
Why must allocators never panic? What would happen if
allocpanicked insideBox::new? -
The default
reallocalways allocates new memory. When wouldlibc::reallocbe able to grow in place? -
Write a simple allocator that tracks total bytes allocated (hint: wrap the System allocator).
-
Why does
Allocator::allocatereturnNonNull<[u8]>instead of justNonNull<u8>?
Next Chapter¶
Chapter 3: Box — Owned Heap Allocation →
We'll see how Box<T> uses these allocator traits to provide safe, owned heap memory with automatic cleanup.