← Back to Blog
EN中文

隔离的艺术:无锁内存分配器中的段隔离策略

在构建高性能并发系统时,内存分配往往是潜伏在深处的性能杀手。当数百个线程同时申请小对象时,传统的互斥锁(Mutex)会瞬间成为热点,导致 CPU 大量周期浪费在上下文切换和等待上。

最近在研究某工业级分布式系统的核心库时,我注意到了一个精妙的内存管理设计:通过将堆内存切分为线程独占的"段"(Segment),在绝大多数路径上实现了零锁分配。这并非全新的发明,但其在工程实现上的克制与权衡,值得我们细细品味。

核心设计:所有权与隔离

该分配器的设计哲学可以概括为一句话:"让数据靠近计算,让竞争远离热点"。

在标准的多线程分配器中,堆是一个全局共享资源。为了安全,必须加锁。而"段隔离"策略则反其道而行之:它预先向操作系统申请一大块内存(Arena),然后将其切分为若干个固定大小的段。每个线程在启动时,或在首次分配时,会"认领"一个或多个段作为自己的独占领地。

1. 极速分配路径(Fast Path)

当线程 T 需要分配内存时,它首先检查自己持有的当前段。如果段内还有剩余空间,分配过程仅仅是指针的移动(Bump Pointer Allocation)。

这个过程不需要任何原子操作(Atomic),更不需要锁。它就像栈分配一样快,但分配的是堆内存。只有当当前段用尽,或者线程需要分配超大对象时,才会回退到更复杂的慢速路径。

2. 跨线程释放的难题

隔离策略带来的最大挑战在于"借出"的内存如何归还。如果线程 A 分配了一块内存,传给了线程 B,最后由 B 释放,该怎么办?

  • 简单方案:B 直接操作 A 的段数据结构。但这瞬间破坏了"无锁"的前提,引入了竞争。
  • 工业级方案:引入一个"远程释放队列"(Remote Free List)。

在这个设计中,每个段都有一个与之关联的无锁链表。当线程 B 想要释放属于线程 A 的内存块时,它并不真正归还内存,而是通过原子操作(CAS)将这个块挂到 A 的段的远程释放链表上。

线程 A 在后续的分配过程中,会定期检查这个链表,批量回收被其他线程释放的内存。这种设计将高频的竞争(每次释放都抢锁)转化为了低频的批处理(只有回收时才通过原子操作交互),极大地提升了吞吐量。

净室重构:Rust 演示

为了更好地理解这一机制,我使用 Rust 对其核心思想进行了重构。Rust 的所有权模型与这种"段隔离"思想有着天然的契合,但要在 Unsafe 层面实现无锁结构依然需要小心翼翼。

在这个演示中,我们模拟了一个简化的 Segment 结构,它拥有一个本地分配指针和一个跨线程的释放队列。

use std::sync::atomic::{AtomicPtr, Ordering};
use std::ptr::{null_mut, NonNull};
use std::cell::UnsafeCell;

// Represents a block of memory header
struct Block {
    next: AtomicPtr<Block>,
    // Payload follows...
}

// A memory segment owned by a specific thread
struct Segment {
    // Thread-local bump pointer start
    current: UnsafeCell<*mut u8>,
    // Thread-local bump pointer end
    end: UnsafeCell<*mut u8>,
    
    // Remote free list: other threads push returned blocks here via CAS
    remote_free_head: AtomicPtr<Block>,
}

impl Segment {
    fn new(size: usize) -> Self {
        let layout = std::alloc::Layout::from_size_align(size, 8).unwrap();
        let ptr = unsafe { std::alloc::alloc(layout) };
        
        Segment {
            current: UnsafeCell::new(ptr),
            end: UnsafeCell::new(unsafe { ptr.add(size) }),
            remote_free_head: AtomicPtr::new(null_mut()),
        }
    }

    // Fast path: thread-local allocation (No Atomics, No Locks)
    // Only safe to call from the owning thread
    unsafe fn alloc(&self, size: usize) -> *mut u8 {
        let current = *self.current.get();
        let end = *self.end.get();
        let next = current.add(size);

        if next <= end {
            *self.current.get() = next;
            return current;
        }
        
        // If exhausted, try to reclaim remote frees
        if self.reclaim_remote() {
            return self.alloc(size); // Retry
        }
        
        null_mut() // Out of memory in this segment
    }

    // Reclaim memory freed by other threads
    unsafe fn reclaim_remote(&self) -> bool {
        // Atomically steal the entire list
        let mut head = self.remote_free_head.swap(null_mut(), Ordering::Acquire);
        
        if head.is_null() {
            return false;
        }

        // Simulating recycling logic: 
        // In a real allocator, we would add these blocks back to a free list
        // or reset the bump pointers if the segment was completely empty.
        // Here we just acknowledge the retrieval for demonstration.
        true
    }

    // Remote free: safe for any thread to call
    unsafe fn dealloc_remote(&self, ptr: *mut u8) {
        let block = ptr as *mut Block;
        
        // Lock-free push to the remote_free_head
        let mut old_head = self.remote_free_head.load(Ordering::Relaxed);
        loop {
            (*block).next.store(old_head, Ordering::Relaxed);
            match self.remote_free_head.compare_exchange_weak(
                old_head,
                block,
                Ordering::Release,
                Ordering::Relaxed,
            ) {
                Ok(_) => break,
                Err(x) => old_head = x,
            }
        }
    }
}

// Safety: Segment handles internal synchronization for remote_free_head
unsafe impl Sync for Segment {}

fn main() {
    // Simulation context
    let segment = Segment::new(1024 * 16); // 16KB Segment
    
    // Simulate allocation
    let ptr = unsafe { segment.alloc(64) };
    println!("Allocated ptr: {:?}", ptr);
    
    // Simulate remote free from another thread
    std::thread::scope(|s| {
        s.spawn(|| {
            unsafe { segment.dealloc_remote(ptr) };
            println!("Freed from remote thread");
        });
    });
}

设计权衡分析

为什么选择这种设计?

这种设计的最大收益在于局部性(Locality)。在大多数服务器应用中,对象通常由创建它的线程销毁。即便发生跨线程传递,"生产-消费"模式中的对象生命周期也往往较短。

通过让分配操作完全本地化,系统避开了昂贵的 L3 缓存一致性流量。CPU 核心只需要独占访问自己 L1/L2 缓存中的段元数据,这比任何原子指令都要快几个数量级。

代价是什么?

没有免费的午餐。这种隔离策略主要面临两个权衡:

  1. 内存碎片(Fragmentation):每个线程都独占一部分内存。如果系统有大量空闲线程,它们持有的段实际上是"搁浅"的内存,其他繁忙线程无法直接利用。这需要额外的平衡机制(如全局段回收)来缓解。
  2. 元数据开销:为了支持跨线程释放,每个内存块可能需要记录它所属的段信息(Header),或者通过地址对齐(如 jemalloc 的设计)来隐式计算归属。这增加了内存的额外消耗。

结语

内存分配器的设计,本质上是对"并发度"与"内存利用率"的平衡博弈。段隔离策略选择牺牲一部分内存利用率(碎片),换取了极致的并发吞吐能力。在当今内存相对廉价而 CPU 核心数不断增长的硬件背景下,这无疑是一个极具前瞻性的工程选择。