文章 · 2026-02-27

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

在构建高性能并发系统时,内存分配往往是潜伏在深处的性能杀手。当数百个线程同时申请小对象时,传统的互斥锁(Mutex)会瞬间成为热点,导致 CPU 大量周期浪费在上下文切换和等待上。但锁竞争只是问题的一半。即便实现了完美的无锁分配,仍然可能面临第二道、来自硬件层面的瓶颈:TLB 压力。

CPU 的 TLB(Translation Lookaside Buffer)缓存虚拟地址到物理页的映射关系。一个占用 64GB 内存、页大小为 4KB 的应用,需要超过 1600 万个页表项——远超任何 TLB 的容量。由此导致的 TLB Miss 迫使 CPU 在每次未命中时执行页表遍历,消耗大量时钟周期,这与锁竞争无关。

段隔离策略可以同时解决这两个问题。通过将堆内存分割为固定大小、对齐的段并赋予线程独占所有权,这一设计在热路径上消除了锁竞争,同时将 TLB 友好的访问模式集中在足够大的段内,从而受益于巨型页(Huge Page)。

核心设计:所有权与隔离

设计哲学很直接:"让数据靠近计算,让竞争远离热点"。

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

这个 Arena 如何建立是一个基础性的实现细节。在 64 位系统下,虚拟地址空间几乎是无限的。一个实用策略是:通过单次 mmap 调用预先预留数百 GB 的虚拟地址空间,而不立即提交任何物理内存——只有当页面被真正写入时,才会分配物理内存。这将指针算术简化为简单的"基址加偏移"计算,确保所有段都处于连续地址范围内,并使得所有权查找可以纯粹通过地址算术完成。

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

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

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

2. 段边界与 TLB

将段边界设置在 2MB 处,恰好与 x86_64 的巨型页边界对齐。严格按 2MB 对齐的内存块永远不会跨越巨型页边界,这意味着 CPU 只需一个 TLB 条目就能覆盖整个段的访问。对于扫描大数组或遍历密集哈希表的工作负载,这能将 TLB Miss 减少多达 512 倍——这一收益在每个核心上叠加。

段内分配通过两种不同的机制处理。指针碰撞(Bump Pointer)负责段内全新的连续分配:只要段的前沿还有未使用的空间,每次 alloc 就是一次指针移动。一旦段内某些部分已被释放,位掩码(Bitmask)则追踪哪些子块可以复用。寻找第一个可用子块简化为寻找第一个为零的位:在现代 CPU 上,@ctz(Count Trailing Zeros)硬件指令能在几个时钟周期内完成定位,比遍历链表或树结构快几个数量级。

3. 跨线程释放的难题

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

简单的方案是让 B 直接操作 A 的段数据结构。但这瞬间破坏了"无锁"的前提,引入了竞争。

工业级方案则引入一个远程释放队列(Remote Free List)。每个段都有一个与之关联的无锁链表。当线程 B 想要释放属于线程 A 的内存块时,它并不真正归还内存,而是通过原子操作(CAS)将这个块挂到 A 的段的远程释放链表上。

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

实现示例

为了更具体地理解这些机制,考虑一个 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");
        });
    });
}

在底层的巨型页层,段预留本身也受益于显式对齐。以下 Zig 原型演示了通过 mmap 申请巨型页,并用简单位图管理其可用性——与上面相同的 @ctz 技巧,应用在段级别而非子块级别。

const std = @import("std");
const os = std.os;
const mem = std.mem;

// 定义 Huge Page 大小为 2MB
const HUGE_PAGE_SIZE = 2 * 1024 * 1024;

const HugePageAllocator = struct {
    base_addr: [*]u8,
    total_size: usize,
    // 简化演示:一个简单的位图跟踪已被使用的 2MB 页
    // 在生产级代码中,这里会是更复杂的层级结构
    page_bitmap: u64, 

    pub fn init(size: usize) !HugePageAllocator {
        // 确保请求大小对齐
        if (size % HUGE_PAGE_SIZE != 0) return error.InvalidSize;
        
        // 使用 mmap 申请内存
        // MAP_HUGETLB (0x40000) 指示内核使用 Huge Pages
        // 注意:这通常需要系统配置 nr_hugepages
        const flags = os.MAP.PRIVATE | os.MAP.ANONYMOUS;
        
        // 在 Zig 标准库中,mmap 的封装可能不直接暴露 HUGETLB,
        // 这里为了演示逻辑,假设我们可以通过 flags 传递或系统已配置透明大页(THP)。
        // 关键在于对齐。
        
        const ptr = try os.mmap(
            null,
            size,
            os.PROT.READ | os.PROT.WRITE,
            flags,
            -1,
            0,
        );

        return HugePageAllocator{
            base_addr: ptr,
            total_size: size,
            page_bitmap: 0,
        };
    }

    pub fn alloc_segment(self: *HugePageAllocator) ![]u8 {
        // 查找空闲位 (简单实现:仅支持 64 个 segment)
        const index = @ctz(~self.page_bitmap);
        if (index >= 64 or index * HUGE_PAGE_SIZE >= self.total_size) {
            return error.OutOfMemory;
        }

        // 标记为已用
        self.page_bitmap |= (@as(u64, 1) << @intCast(index));

        const offset = index * HUGE_PAGE_SIZE;
        return self.base_addr[offset .. offset + HUGE_PAGE_SIZE];
    }

    pub fn free_segment(self: *HugePageAllocator, segment: []u8) void {
        const ptr_val = @intFromPtr(segment.ptr);
        const base_val = @intFromPtr(self.base_addr);
        
        const diff = ptr_val - base_val;
        const index = diff / HUGE_PAGE_SIZE;

        // 标记为可用
        self.page_bitmap &= ~(@as(u64, 1) << @intCast(index));
    }
    
    pub fn deinit(self: *HugePageAllocator) void {
        os.munmap(self.base_addr[0..self.total_size]);
    }
};

pub fn main() !void {
    // 初始化分配器,管理 10 个 Huge Pages (20MB)
    var allocator = try HugePageAllocator.init(10 * HUGE_PAGE_SIZE);
    defer allocator.deinit();

    const seg1 = try allocator.alloc_segment();
    std.debug.print("Allocated segment 1 at: {*}\n", .{seg1.ptr});
    
    // 写入数据测试
    mem.set(u8, seg1, 0xAA);
    std.debug.print("Memory is writable.\n", .{});

    allocator.free_segment(seg1);
    std.debug.print("Segment 1 freed.\n", .{});
}

设计权衡

为什么选择这种设计?

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

通过让分配操作完全本地化,系统避开了昂贵的 L3 缓存一致性流量。CPU 核心只需要独占访问自己 L1/L2 缓存中的段元数据,这比任何原子指令都要快几个数量级。当段按巨型页大小对齐时,这种局部性从软件层面一直延伸到 TLB 硬件层面。

代价是什么?

这种隔离策略主要面临两个权衡:

  1. 内存碎片(Fragmentation):每个线程都独占一部分内存。如果系统有大量空闲线程,它们持有的内存保持保留但利用率低下。回收一个物理段只需对其物理页调用 madvise(MADV_FREE),而虚拟地址槽保持保留,随时准备好供下一个认领该段的线程使用。
  2. 元数据开销:为了支持跨线程释放,每个内存块可能需要记录它所属的段信息(Header),或者通过地址对齐(如 jemalloc 的设计)来隐式计算归属。这增加了每个内存块的额外消耗。

结语

段隔离策略舍内存利用率而取并发吞吐。按巨型页大小对齐段边界后,还能用虚拟地址空间预留换取 TLB 条目计数的减少——在现代 64 位硬件上,这笔交易几乎没有成本。面对当今硬件现实——内存廉价、核心数倍增、虚拟地址空间几近无限——这是一个从调度器到硅片每一层都高效运作的务实工程选择。

© 2026 Yuxu Ge ·