你的内存池真的需要锁吗?拆解分段页池设计
在构建高性能系统时,内存分配器往往是隐藏的瓶颈。标准的 malloc/free 足够通用,但在高并发下,锁竞争和缓存碎片化会导致性能急剧下降。
分析某工业级分布式系统的底层库时,我发现了一个有趣的内存池设计。它不像 jemalloc 那样试图解决所有分配模式,而是专注于一个具体场景:高效地管理大块内存页的分发,同时最小化锁竞争。
这个设计结合了 分段式页表 和 投机分配 策略。让我拆解其中的机制。
核心挑战:大块内存管理
在内存池切分小对象之前,必须先从操作系统获取大块内存——通常是 2MB 的大页或 4KB 的普通页。在高并发下处理这个过程会产生两个问题:
- 锁竞争:如果所有线程都抢一把全局锁来申请新页,这把锁就成为系统的热点。
- 元数据开销:链表串联会导致指针跳转,对缓存极不友好;数组则需要昂贵的扩容操作。
这个系统的答案是 分段式页表。
分段结构与原子级细粒度分配
内存池不是简单的列表,而是由 PageListElement 结构组成。每个元素是固定大小的容器,管理一批页——在这个实现中是 340 个页。
批量管理:不追踪单个页,而是追踪一批页。这显著降低了元数据开销。
数组寻址:页指针存储在数组中,线程通过索引直接定位,避免了链表的指针跳转。对 CPU 缓存友好得多。
这种 "数组 + 链表" 混合结构——类似 C++ 的 std::deque 实现——在内存连续性和动态扩展之间找到了平衡,避免了扩容时的拷贝开销。
页内空间管理使用线性分配配合原子操作。定义两个分配粒度:
- 小块(Small Chunk):4KB
- 大块(Large Chunk):32KB
分配时使用 fetch_add 原子地推进游标。多个线程可以在同一个页内并发地切分内存,无需互斥锁。只有当页耗尽时,线程才需要获取下一个页。
投机性分配:提前准备
在多线程环境下,分配新页是昂贵的操作——可能涉及系统调用或锁。如果等到当前页完全用尽才申请下一页,撞上空窗期的线程会被阻塞,导致延迟尖峰。
这个分配器引入了水位线。当线程从当前页分配时,它检查使用进度。如果超过了阈值——比如 87.5%——分配器会投机性地触发下一页的分配,而不阻塞当前线程:
已经消耗了大部分当前页,系统就主动准备下一个。这将昂贵的分配操作从关键路径上移除。到了线程真正需要切换页时,下一页通常已经就绪。
这是典型的 "用吞吐量换延迟",把负担转移到后台。
实现:Rust 演示
为了阐明机制,这是一个简化的 Rust 实现,重点展示分段管理和投机分配的逻辑。(生产代码需要严格的 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
}
}
代码解读
无锁快路径:
pop_internal中的大多数操作——读取索引、原子加法——都是无锁的。锁只在分配新页的低频慢路径上被访问。try_lock避免阻塞:allocate_page中,try_lock意味着如果其他线程正在处理分配,当前线程不会等待,而是直接返回。这避免了惊群效应。水位线转换:
if prev_idx < barrier && end_idx >= barrier这一行捕捉到页进入 "即将耗尽" 状态的准确时刻,触发投机分配。
系统设计中的权衡
这个设计不是通用方案。
代价是什么?
- 内部碎片:页末尾的未使用空间无法回收。
- 复杂性:维护分段索引和原子状态远比简单的
malloc复杂。
收益是什么?
- 极高吞吐:分配通常只是一个原子加法指令。
- 低延迟抖动:投机分配平抑了扩容导致的延迟尖峰。
当系统追求极致性能时,为了那最后 1% 的尾延迟而重写核心分配器,这是工程判断力的体现。
注:本演示代码为简化版本。生产使用需要完整的错误处理、内存回收逻辑和严格的内存序控制。