← Back to Blog
EN中文

你的内存池真的需要锁吗?:拆解一个无锁分段页池的设计细节

在构建高性能系统时,内存分配器往往是那个 "Invisible Elephant"——平时看不见,一旦出问题就是系统瓶颈。标准库的 malloc/free 足够通用,但在高并发、高吞吐的场景下,锁竞争和内存碎片化会让 CPU 缓存失效,导致性能断崖式下跌。

最近在分析某工业级分布式系统的底层库时,我发现了一个非常有趣的内存池设计。它不像 jemalloc 那样试图解决所有问题,而是专注于一个特定场景:高效地管理大块内存页的分发,同时最小化锁竞争

这个设计最精妙的地方在于它引入了一个 "分段页表" 的结构,并配合了一种 "投机性分配" 的策略。今天,我们就来拆解这个设计,看看它是如何在无锁(Lock-Free)和有锁之间找到那个微妙的平衡点的。

1. 核心挑战:大块内存管理的痛点

通常我们设计内存池,是为了切分小对象。但在此之前,我们需要先从操作系统拿到大块内存(比如 2MB 的大页或 4KB 的普通页)。这个"批发"过程如果在高并发下处理不好,会有两个问题:

  1. 锁竞争:如果所有线程都抢一把全局锁来申请新页,这把锁就是系统的热点。
  2. 元数据开销:如果用链表串起所有页,指针跳转对 CPU 缓存极不友好;如果用数组,扩容时的数据拷贝又是灾难。

这个系统给出的答案是:分段式页表链表(Segmented Page List)

2. 设计解剖:分段与隔离

在这个设计中,内存池并不是一个简单的链表,而是一组 "PageListElement"。

分段结构

每个 PageListElement 是一个固定大小的容器(在代码中,它管理着 340 个页)。

  • 批量管理:它不是管理一个页,而是管理一批页。这样可以显著减少元数据的内存占用。
  • 数组化访问:在段内部,页指针是存储在数组里的。这意味着 CPU 可以通过索引直接定位,而不需要像遍历链表那样一次次解引用。

这种 "数组 + 链表" 的混合结构(类似 C++ 的 std::deque 实现原理),在内存连续性和动态扩展性之间做了一个很好的权衡。

细粒度切分与原子操作

更精彩的是它对页内空间的管理。它没有使用复杂的空闲链表(Free List),而是采用了一种类似 "Bump Pointer"(指针碰撞)的线性分配方式,但加了原子操作的魔法。

它定义了两个维度的切分:

  • 小块(Small Chunk):4KB
  • 大块(Large Chunk):32KB

在分配时,它使用 fetch_add 原子地移动游标。这意味着多个线程可以在同一个页内并发地切分内存,而不需要互斥锁。只有当一个页用完了,才需要去拿下一个页。

3. 投机性分配:预判你的预判

这个设计中最让我眼前一亮的是它的 投机分配(Speculative Allocation) 策略。

在多线程环境下,"分配新页" 是一个相对耗时的操作(可能涉及系统调用或锁)。如果等到当前页完全用光了再去申请下一页,那么不幸撞上"空窗期"的那个线程就会被阻塞,产生尾延迟(Tail Latency)。

这个分配器引入了一个 "水位线" 概念。

当一个线程在当前页分配内存时,它会检查当前的分配进度。如果进度超过了一个特定的阈值(比如 87.5%),它就会投机性地去触发下一页的分配任务。

"既然你已经用到这儿了,下一页迟早是要用的,不如我现在就去准备好。"

这种策略将耗时的分配操作从 "关键路径" 上移除了。绝大多数时候,当线程需要切换到下一页时,下一页已经在后台准备就绪了。这是一种典型的 "用吞吐量换延迟" 的设计哲学。

4. 净室重构:Rust 演示

为了更好地理解这个机制,我使用 Rust 进行了一次净室重构。这里我们略去了系统调用的细节,着重展示 分段管理投机分配 的逻辑。

注意:为了演示清晰,我们简化了部分内存序(Memory Ordering)的处理,但在生产环境中,这里需要极度严谨的 Acquire/Release 语义。

use std::sync::atomic::{AtomicPtr, AtomicU32, Ordering};
use std::ptr;
use std::sync::Mutex;

// 定义常量,模拟工业级参数
const PAGES_PER_LIST: usize = 340; // 一个段管理的页数
const SMALL_CHUNK_SIZE: usize = 4096; // 4K 基础块
const LARGE_CHUNK_SIZE: usize = 32 * 1024; // 32K 大块分配
// 假设每页 2MB
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;

// 分段页表元素:管理一批页
struct PageListElement {
    // 页内存指针数组,原子操作保证线程安全
    page_memory: [AtomicPtr<u8>; PAGES_PER_LIST],
    // 记录每页已分配的 chunk 数量
    allocated_chunks: [AtomicU32; PAGES_PER_LIST],
    // 当前正在使用的页索引
    active_chunk: AtomicU32,
    // 下一个段
    next: AtomicPtr<PageListElement>,
}

impl PageListElement {
    fn new() -> Self {
        // 初始化逻辑... (略去繁琐的 Vec 到 Array 转换)
        // 关键是所有 Atomic 指针初始化为 null/0
        // ...
        PageListElement {
             // ... 伪代码初始化
            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>, // 链表头
    pages_remaining: AtomicU32,       // 全局剩余页数配额
    lock: Mutex<()>,                  // 慢路径(分配新页)使用的锁
}

impl PagePool {
    // 核心分配逻辑
    fn pop_internal(&self, list: &PageListElement) -> Option<*mut u8> {
        // 1. 获取当前活跃的页索引
        let active_chunk = list.active_chunk.load(Ordering::Acquire) as usize;

        // 2. 如果当前段已用完,尝试跳转到下一段
        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 });
            } 
            // 如果没下一段且还有配额,尝试分配新段(慢路径)
            if self.pages_remaining.load(Ordering::Acquire) > 0 {
                self.allocate_page(list);
                return self.pop_internal(list);
            }
            return None;
        }

        // 3. 获取当前页的内存基址
        let chunk_mem = list.page_memory[active_chunk].load(Ordering::Acquire);
        if chunk_mem.is_null() {
            // 页还未分配,触发分配(慢路径)
            if self.pages_remaining.load(Ordering::Acquire) > 0 {
                self.allocate_page(list);
                return self.pop_internal(list);
            }
            return None;
        }

        // 4. 在当前页内原子地抢占空间
        // fetch_add 返回旧值,相当于移动游标
        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 {
            // 当前页空间不足,CAS 尝试推进 active_chunk 到下一页
            // 失败也没关系,说明别人已经推了
            let _ = list.active_chunk.compare_exchange(
                active_chunk as u32,
                (active_chunk + 1) as u32,
                Ordering::Release,
                Ordering::Relaxed,
            );
            // 递归重试
            return self.pop_internal(list);
        }

        // === 关键点:投机性预分配 ===
        // 如果当前页已经用了 7/8 (112/128),不仅处理本次分配,
        // 还要触发后台分配下一页,避免下一个线程卡顿
        let barrier = (CHUNKS_PER_PAGE * 112) / 128;
        if prev_idx < barrier && end_idx >= barrier {
            // 注意:这里通常会异步去做,或者由当前线程承担这个一次性成本
            self.allocate_page(list);
        }

        // 计算实际内存地址返回
        let offset = prev_idx as usize * SMALL_CHUNK_SIZE;
        Some(unsafe { chunk_mem.add(offset) })
    }

    // 分配新页的逻辑(简化版)
    fn allocate_page(&self, list: &PageListElement) {
        // 使用 try_lock,如果有人在分配了,我们就不用管了
        // 这是一种很棒的防抖动机制
        let _guard = match self.lock.try_lock() {
            Ok(g) => g,
            Err(_) => return, 
        };
        
        // ... 具体的内存申请逻辑 (mmap 等)
        // ... 更新 list.page_memory 或 list.next
    }
}

代码解读

  1. 无锁快路径pop_internal 中的大部分操作(读取索引、原子增加偏移量)都是无锁的。只有在需要分配新页这个低频操作时,才可能触碰锁。
  2. try_lock 的智慧:在 allocate_page 中使用了 try_lock。这意味着如果有其他线程正在分配,当前线程不会阻塞等待,而是直接返回(可能重试或失败)。这避免了惊群效应。
  3. Barrier Checkif prev_idx < barrier && end_idx >= barrier 这一行代码实现了投机分配。它精准地捕捉到了 "即将耗尽" 的状态变化瞬间。

5. 总结:权衡的艺术

这个分段页池设计并不是万能的。

它的代价是什么?

  • 内存碎片:如果每个页最后都剩一点点不够分,那是无法回收利用的(Internal Fragmentation)。
  • 复杂性:维护分段索引和原子状态比简单的 malloc 复杂得多。

它换来了什么?

  • 极高的并发吞吐:在绝大多数情况下,分配内存只是一个原子加法指令。
  • 低延迟抖动:通过投机分配,消除了因扩容导致的延迟尖峰。

在系统设计中,没有完美的方案,只有最适合场景的权衡。当你的系统通过了早期的野蛮生长,开始追求极致的性能和稳定性时,这种 "为了 1% 的尾延迟而重写核心分配器" 的精神,正是工程的魅力所在。


注:本文演示代码仅为阐述设计思想的简化版本,并未包含完整的错误处理和内存回收逻辑,请勿直接用于生产环境。