Do You Really Need a Lock? Dissecting a Segmented Page Pool Design
In high-performance systems, the memory allocator is often a hidden bottleneck. Standard malloc/free implementations work well for general purposes, but under extreme concurrency, lock contention and cache thrashing can degrade performance significantly.
While analyzing the core library of an industrial-grade distributed system, I encountered a memory pool design that doesn't try to solve every allocation pattern. Instead, it targets one specific problem: efficiently distributing large memory pages while minimizing lock contention.
The design combines a Segmented Page List with a Speculative Allocation strategy. Let me walk through the mechanics.
The Core Challenge: Managing Large Memory Batches
Before a memory pool can slice small objects, it must first request large blocks from the OS—typically 2MB huge pages or 4KB standard pages. Doing this under high concurrency creates two problems:
- Lock Contention: If every thread fights for a global lock to request a new page, that lock becomes a severe bottleneck.
- Metadata Overhead: A simple linked list of pages requires pointer chasing, which is cache-unfriendly. A dynamic array demands costly resizing and copying.
The system's answer is a Segmented Page List.
Segmentation and Atomic Fine-Grained Allocation
Rather than a simple list, the memory pool uses PageListElement structures. Each element is a fixed-size container that manages a batch of pages—340 pages in this implementation.
Batch Management: Instead of tracking one page at a time, the system tracks a batch. This significantly reduces metadata overhead.
Array-Based Access: Inside a segment, page pointers are stored in an array. Threads can locate pages via index rather than pointer chasing, which is much faster for the CPU cache.
This "Array + Linked List" hybrid—similar to how std::deque is typically implemented—balances memory contiguity against dynamic growth without the resize-and-copy penalty.
Within a page, the allocator uses a linear allocation strategy with atomic operations. It defines two granularities:
- Small Chunk: 4KB
- Large Chunk: 32KB
When allocating, the allocator uses fetch_add to atomically advance a cursor. Multiple threads can slice memory from the same page concurrently without a mutex. Only when a page is exhausted does a thread need to fetch the next one.
Speculative Allocation: Preparing in Advance
In a multithreaded environment, allocating a new page is expensive—it may involve system calls or locks. If a thread waits until the current page is completely full before requesting the next one, the unlucky thread that hits the limit will block and cause a latency spike.
This allocator introduces a high-water mark. When a thread allocates from the current page, it checks usage. If usage exceeds a threshold—say, 87.5%—the allocator speculatively triggers allocation of the next page without blocking:
When you've consumed most of the current page, the system proactively prepares the next one. This moves the expensive allocation operation off the critical path. By the time a thread needs to switch to the next page, it's usually ready.
This is a classic throughput-for-latency trade-off, shifting the burden from the fast path to the background.
Implementation: A Rust Sketch
To clarify the mechanics, here's a simplified Rust implementation focusing on Segmented Management and Speculative Allocation logic. (Production code requires strict Acquire/Release memory ordering; details are simplified here.)
use std::sync::atomic::{AtomicPtr, AtomicU32, Ordering};
use std::ptr;
use std::sync::Mutex;
// Simulating industrial constants
const PAGES_PER_LIST: usize = 340; // Pages per segment
const SMALL_CHUNK_SIZE: usize = 4096; // 4K chunk
const LARGE_CHUNK_SIZE: usize = 32 * 1024; // 32K chunk
// Assuming 2MB pages
const CHUNKS_PER_PAGE: u32 = (2 * 1024 * 1024 / SMALL_CHUNK_SIZE) as u32;
const CHUNKS_PER_LARGE: u32 = (LARGE_CHUNK_SIZE / SMALL_CHUNK_SIZE) as u32;
// PageListElement: Manages a batch of pages
struct PageListElement {
// Array of atomic pointers to page memory
page_memory: [AtomicPtr<u8>; PAGES_PER_LIST],
// Tracks allocated chunks per page
allocated_chunks: [AtomicU32; PAGES_PER_LIST],
// Index of the currently active page in this segment
active_chunk: AtomicU32,
// Pointer to the next segment
next: AtomicPtr<PageListElement>,
}
impl PageListElement {
fn new() -> Self {
// Initialization logic omitted for brevity...
// Crucially, all Atomic pointers start as null/0
// ...
PageListElement {
// ... pseudo-initialization
page_memory: unsafe { std::mem::zeroed() },
allocated_chunks: unsafe { std::mem::zeroed() },
active_chunk: AtomicU32::new(0),
next: AtomicPtr::new(ptr::null_mut()),
}
}
}
pub struct PagePool {
first_page: Box<PageListElement>, // Head of the list
pages_remaining: AtomicU32, // Global quota
lock: Mutex<()>, // Mutex for the slow path (new page alloc)
}
impl PagePool {
// Core allocation logic
fn pop_internal(&self, list: &PageListElement) -> Option<*mut u8> {
// 1. Get the current active page index
let active_chunk = list.active_chunk.load(Ordering::Acquire) as usize;
// 2. If current segment is full, try the next one
if active_chunk >= PAGES_PER_LIST {
let next_ptr = list.next.load(Ordering::Acquire);
if !next_ptr.is_null() {
return self.pop_internal(unsafe { &*next_ptr });
}
// Slow path: Allocate new segment if quota allows
if self.pages_remaining.load(Ordering::Acquire) > 0 {
self.allocate_page(list);
return self.pop_internal(list);
}
return None;
}
// 3. Get the base address of the current page
let chunk_mem = list.page_memory[active_chunk].load(Ordering::Acquire);
if chunk_mem.is_null() {
// Page not allocated yet, trigger slow path
if self.pages_remaining.load(Ordering::Acquire) > 0 {
self.allocate_page(list);
return self.pop_internal(list);
}
return None;
}
// 4. Atomically claim space in the current page
// fetch_add returns the old value, effectively moving the cursor
let prev_idx = list.allocated_chunks[active_chunk].fetch_add(CHUNKS_PER_LARGE, Ordering::SeqCst);
let end_idx = prev_idx + CHUNKS_PER_LARGE;
if end_idx > CHUNKS_PER_PAGE {
// Page is full. CAS to advance active_chunk to the next page.
// If it fails, someone else already did it.
let _ = list.active_chunk.compare_exchange(
active_chunk as u32,
(active_chunk + 1) as u32,
Ordering::Release,
Ordering::Relaxed,
);
// Retry on the next page
return self.pop_internal(list);
}
// === KEY: Speculative Allocation ===
// If we've used 7/8 (112/128) of the page, trigger background allocation
// for the next page to prevent stalling the next thread.
let barrier = (CHUNKS_PER_PAGE * 112) / 128;
if prev_idx < barrier && end_idx >= barrier {
// Usually done asynchronously to avoid penalizing the current thread
self.allocate_page(list);
}
// Calculate final pointer
let offset = prev_idx as usize * SMALL_CHUNK_SIZE;
Some(unsafe { chunk_mem.add(offset) })
}
// Allocation logic (simplified)
fn allocate_page(&self, list: &PageListElement) {
// use try_lock: if someone is already allocating, we just return.
// This prevents the thundering herd problem.
let _guard = match self.lock.try_lock() {
Ok(g) => g,
Err(_) => return,
};
// ... Actual memory allocation logic (mmap, etc.)
// ... Update list.page_memory or list.next
}
}
Code Walkthrough
Lock-Free Fast Path: Most operations in
pop_internal—reading indices and atomic adds—are lock-free. The lock is only accessed in the slow path of allocating a new page.try_lockAvoids Blocking: Inallocate_page,try_lockmeans if another thread is already handling allocation, the current thread doesn't block; it returns instead. This sidesteps the "thundering herd" problem.Barrier Transition: The line
if prev_idx < barrier && end_idx >= barriercaptures the exact moment a page enters the "running low" state and triggers speculative allocation.
Trade-offs in System Design
This design is not a universal solution.
The Cost:
- Internal Fragmentation: Unused space at the end of a page cannot be reclaimed.
- Complexity: Managing segmented indices and atomic states is far more complex than a simple
malloc.
The Gain:
- Extreme Throughput: Allocation is usually just a single atomic add instruction.
- Low Latency Jitter: Speculative allocation smooths out latency spikes caused by growth.
When a system demands extreme performance, rewriting core components like allocators for that final 1% tail latency improvement is where engineering judgment matters most.
Note: This demo code is a simplified illustration. Production use requires complete error handling, memory reclamation logic, and strict memory ordering.