3. 固定大小内存池设计与实现

内存池这东西,说白了就是提前向系统申请一大块内存,然后自己管理分配和释放。为什么要这么干?因为 mallocfree 在交易系统这种高频场景下,实在太慢了。我早年在一个期货高频项目里,光是在行情解码路径上做内存分配,就吃掉了 30% 的 CPU 时间。嗯,后来改成固定大小内存池,延迟直接砍半。

3.1 为什么是固定大小?

交易系统里很多对象大小是固定的。比如订单结构体、行情 tick、撮合结果。这些对象生命周期极短,创建销毁极其频繁。如果每个都走系统堆分配,碎片问题和性能问题会让你痛不欲生。

固定大小内存池的核心思路:

  • 预分配一大块连续内存
  • 切成等大小的槽位(slot)
  • 用空闲链表串联所有可用槽位
  • 分配时从链表头部取一个,释放时插回链表头部

你想想看,整个过程就是几个指针操作,没有系统调用,没有锁竞争(单线程场景下)。

3.2 核心数据结构

我个人习惯用这样的结构:

struct MemoryPool {
    void*  pool_start;    // 内存池起始地址
    void*  pool_end;      // 内存池结束地址
    void*  free_head;     // 空闲链表头
    size_t slot_size;     // 每个槽位大小
    size_t slot_count;    // 槽位总数
    size_t free_count;    // 当前空闲槽位数
};

这里有个关键点:slot_size 必须至少能存放一个指针(8字节),因为空闲链表要利用槽位本身的内存来存储 next 指针。我在项目中遇到过有人把 slot_size 设成 4 字节,结果链表指针写不进去,直接崩溃。

注意: slot_size 必须 >= sizeof(void*),否则空闲链表无法正常工作。

3.3 初始化流程

初始化其实就三步:

  1. mmapmalloc 申请一大块连续内存
  2. 把每个槽位当作链表节点,串联起来
  3. 记录元数据

代码实现如下:

MemoryPool* pool_create(size_t slot_size, size_t slot_count) {
    // 对齐到 8 字节
    slot_size = (slot_size + 7) & ~7;
    if (slot_size < sizeof(void*)) slot_size = sizeof(void*);
    
    size_t total_size = slot_size * slot_count;
    // 用 mmap 申请大页内存,减少 TLB miss
    void* mem = mmap(NULL, total_size, 
                     PROT_READ | PROT_WRITE,
                     MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB,
                     -1, 0);
    if (mem == MAP_FAILED) {
        // 大页申请失败,回退到普通 mmap
        mem = mmap(NULL, total_size,
                   PROT_READ | PROT_WRITE,
                   MAP_PRIVATE | MAP_ANONYMOUS,
                   -1, 0);
    }
    
    MemoryPool* pool = (MemoryPool*)malloc(sizeof(MemoryPool));
    pool->pool_start = mem;
    pool->pool_end = (char*)mem + total_size;
    pool->slot_size = slot_size;
    pool->slot_count = slot_count;
    pool->free_count = slot_count;
    
    // 构建空闲链表
    char* ptr = (char*)mem;
    for (size_t i = 0; i < slot_count - 1; i++) {
        void** next = (void**)(ptr + i * slot_size);
        *next = ptr + (i + 1) * slot_size;
    }
    void** last = (void**)(ptr + (slot_count - 1) * slot_size);
    *last = NULL;
    pool->free_head = mem;
    
    return pool;
}
小技巧: 用 mmap 申请大页内存(HugeTLB)能显著减少 TLB miss。我在做行情网关时,大页内存让内存访问延迟降低了 15% 左右。

3.4 分配与释放

分配和释放的代码极其简洁:

void* pool_alloc(MemoryPool* pool) {
    if (pool->free_head == NULL) {
        return NULL;  // 内存耗尽
    }
    
    void* slot = pool->free_head;
    pool->free_head = *(void**)slot;
    pool->free_count--;
    
    return slot;
}

void pool_free(MemoryPool* pool, void* ptr) {
    if (ptr == NULL) return;
    
    // 检查指针是否属于本池
    if (ptr < pool->pool_start || ptr >= pool->pool_end) {
        // 非法指针,可以加 assert 或日志
        return;
    }
    
    *(void**)ptr = pool->free_head;
    pool->free_head = ptr;
    pool->free_count++;
}

你看,分配就是取链表头,释放就是插回链表头。时间复杂度 O(1),没有任何分支预测失败的风险。我曾经在撮合引擎里用这个模式,每秒处理 50 万笔订单的内存分配开销几乎可以忽略不计。

3.5 避坑指南

这里有几个我踩过的坑,分享给你:

  • 内存对齐问题: 如果 slot_size 不对齐到 8 字节,在 ARM 架构上会触发 SIGBUS。我曾在 ARM 服务器上栽过这个跟头。
  • 线程安全问题: 上面的实现是单线程的。多线程场景需要加锁或用 CAS 操作。我个人建议每个线程独立一个内存池,避免锁竞争。
  • 内存泄漏检测: 可以在 pool 结构里加一个 bitmap,记录每个 slot 的分配状态。调试时打开,线上关闭。
  • 碎片问题: 固定大小内存池不会产生外部碎片,但内部碎片取决于 slot_size 和实际对象大小的匹配度。
核心要点: 固定大小内存池的本质是用空间换时间。预分配 + 空闲链表 = 零系统调用 + O(1) 分配释放。这是交易系统低延迟的基石之一。

3.6 性能对比

我拿一个实际项目的数据给你看:

操作 malloc/free 固定大小内存池 提升倍数
分配 64 字节 120 ns 8 ns 15x
释放 64 字节 95 ns 6 ns 16x
分配 256 字节 150 ns 8 ns 19x
释放 256 字节 110 ns 6 ns 18x

数据是在 Intel Xeon Gold 6248 上测的,单线程,预热后取中位数。差距就是这么明显。

3.7 本章小结

固定大小内存池是交易系统里最基础、最常用的内存管理手段。它简单、高效、可靠。我建议你在自己的项目里先把这个组件写好、测透,因为后面很多高级技巧(比如无锁队列、零拷贝)都要依赖它。

嗯,下一节我们会聊怎么把这个内存池做成线程安全的,以及如何用 CAS 操作实现无锁分配。但那是后话了,先把今天的代码跑通再说。