文章 · 2026-02-27

你的内存池真的需要锁吗?拆解分段页池设计

在构建高性能系统时,内存分配器往往是隐藏的瓶颈。标准的 malloc/free 足够通用,但在高并发下,锁竞争和缓存碎片化会导致性能急剧下降。

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

这个设计结合了 分段式页表投机分配 策略。让我拆解其中的机制。

核心挑战:大块内存管理

在内存池切分小对象之前,必须先从操作系统获取大块内存——通常是 2MB 的大页或 4KB 的普通页。在高并发下处理这个过程会产生两个问题:

  1. 锁竞争:如果所有线程都抢一把全局锁来申请新页,这把锁就成为系统的热点。
  2. 元数据开销:链表串联会导致指针跳转,对缓存极不友好;数组则需要昂贵的扩容操作。

这个系统的答案是 分段式页表

分段结构与原子级细粒度分配

内存池不是简单的列表,而是由 PageListElement 结构组成。每个元素是固定大小的容器,管理一批页——在这个实现中是 340 个页。

批量管理:不追踪单个页,而是追踪一批页。这显著降低了元数据开销。

数组寻址:页指针存储在数组中,线程通过索引直接定位,避免了链表的指针跳转。对 CPU 缓存友好得多。

这种 "数组 + 链表" 混合结构——类似 C++ 的 std::deque 实现——在内存连续性和动态扩展之间找到了平衡,避免了扩容时的拷贝开销。

页内空间管理使用线性分配配合原子操作。定义两个分配粒度:

分配时使用 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
    }
}

代码解读

  1. 无锁快路径pop_internal 中的大多数操作——读取索引、原子加法——都是无锁的。锁只在分配新页的低频慢路径上被访问。

  2. try_lock 避免阻塞allocate_page 中,try_lock 意味着如果其他线程正在处理分配,当前线程不会等待,而是直接返回。这避免了惊群效应。

  3. 水位线转换if prev_idx < barrier && end_idx >= barrier 这一行捕捉到页进入 "即将耗尽" 状态的准确时刻,触发投机分配。

系统设计中的权衡

这个设计不是通用方案。

代价是什么?

收益是什么?

当系统追求极致性能时,为了那最后 1% 的尾延迟而重写核心分配器,这是工程判断力的体现。


注:本演示代码为简化版本。生产使用需要完整的错误处理、内存回收逻辑和严格的内存序控制。

© 2026 Yuxu Ge ·