内存池基础原理:从malloc到自定义内存管理

大家好,我是你们的老朋友。今天咱们聊聊内存池。

说实话,我刚入行做交易系统那会儿,对内存管理完全没概念。项目里到处都是malloc和free,觉得挺正常的。直到有一次,我们的行情网关在开盘后第37秒突然卡死——嗯,就是内存碎片惹的祸。从那以后,我彻底改变了看法。

为什么malloc不够用?

先说说malloc的问题。很多人觉得malloc挺好用的,为什么非要自己搞一套?

我总结了几点:

  • 性能开销大:每次malloc都要进入内核态,做系统调用。交易系统里每秒几万笔订单,你想想看,光系统调用就吃掉多少CPU?
  • 内存碎片严重:频繁分配释放,堆内存会变得支离破碎。我见过一个极端案例,明明还有2GB空闲内存,但malloc一个1KB的小块都失败了。
  • 不确定性:malloc的耗时不是固定的。有时候几纳秒,有时候几十微秒。交易系统最怕的就是这种抖动。

核心观点:交易系统里,确定性比高性能更重要。一个可预测的10微秒,好过一个平均5微秒但偶尔100微秒的方案。

内存池的核心思想

说白了,内存池就是提前向操作系统申请一大块内存,然后自己管理。就像你去批发市场进货,而不是每次需要一根葱都跑一趟菜市场。

基本流程是这样的:

  1. 初始化时,一次性malloc一大块连续内存
  2. 把这块内存切成固定大小的块(chunk)
  3. 用链表把这些空闲块串起来
  4. 分配时,从链表头部取一个块
  5. 释放时,把块放回链表头部

我习惯把这个结构叫做「空闲链表」。简单、高效、可控。

动手实现一个最简单的内存池

光说不练假把式。咱们直接上代码。

// 最简单的固定大小内存池
typedef struct chunk {
    struct chunk* next;  // 指向下一个空闲块
} chunk_t;

typedef struct {
    void* pool;          // 内存池起始地址
    chunk_t* free_list;  // 空闲链表头
    size_t chunk_size;   // 每个块的大小
    size_t pool_size;    // 总大小
} mempool_t;

// 初始化内存池
int mempool_init(mempool_t* mp, size_t chunk_size, size_t block_count) {
    mp->chunk_size = chunk_size;
    mp->pool_size = chunk_size * block_count;
    
    // 一次性申请大块内存
    mp->pool = malloc(mp->pool_size);
    if (!mp->pool) return -1;
    
    // 构建空闲链表
    mp->free_list = (chunk_t*)mp->pool;
    chunk_t* current = mp->free_list;
    
    for (size_t i = 1; i < block_count; i++) {
        current->next = (chunk_t*)((char*)mp->pool + i * chunk_size);
        current = current->next;
    }
    current->next = NULL;
    
    return 0;
}

// 分配一个块
void* mempool_alloc(mempool_t* mp) {
    if (!mp->free_list) return NULL;  // 内存耗尽
    
    chunk_t* chunk = mp->free_list;
    mp->free_list = chunk->next;
    return (void*)chunk;
}

// 释放一个块
void mempool_free(mempool_t* mp, void* ptr) {
    chunk_t* chunk = (chunk_t*)ptr;
    chunk->next = mp->free_list;
    mp->free_list = chunk;
}

我的经验:这段代码看起来简单,但我在项目中踩过坑。注意chunk_size不能小于sizeof(chunk_t),否则链表指针会覆盖用户数据。我曾经因为这个bug排查了整整一个下午。

内存池的核心数据结构

咱们用一张图来理解整个结构:

内存池结构示意图 mempool_t pool → 指向大块内存 free_list → 空闲链表头 chunk_size = 64 pool_size = 6400 pool 连续内存块(共100个chunk) chunk[0] chunk[1] chunk[2] ...... chunk[99] 空闲链表(free_list) chunk[0] → chunk[5] → chunk[12] → chunk[33] → ... → NULL free_list 已分配块 chunk[1]、chunk[2]、chunk[3] 正在被业务代码使用

固定大小 vs 可变大小

上面实现的是固定大小内存池。每个chunk都是64字节(或者你指定的任何大小)。

那问题来了:如果我要分配不同大小的内存怎么办?

我一般用两种策略:

策略 原理 适用场景 我踩过的坑
多级内存池 创建多个固定大小池,比如16B、64B、256B、1KB 对象大小已知且有限 曾经只设了3级,结果有个结构体刚好比256B大一点,尴尬
伙伴算法 按2的幂次分割内存块,可以合并相邻空闲块 需要灵活分配,但能接受一定碎片 合并逻辑写错了,内存越用越碎,排查了两天

注意:多级内存池有个隐藏问题——内存浪费。比如你申请17字节,它给你一个64字节的块,47字节就浪费了。交易系统里订单对象通常就几十字节,浪费比例可能高达50%以上。

内存对齐——一个容易被忽视的问题

嗯,这里要特别提一下内存对齐。

CPU读取内存不是按字节来的,而是按字(4字节或8字节)。如果数据没有对齐,CPU需要额外操作,性能会下降。

我习惯在分配时强制对齐:

// 确保chunk_size是8的倍数
#define ALIGN_SIZE 8
size_t aligned_size = (chunk_size + ALIGN_SIZE - 1) & ~(ALIGN_SIZE - 1);

这样做的好处是:

  • 所有返回的指针都是8字节对齐的
  • CPU访问效率最高
  • 可以安全存放任何类型的数据(包括double、long long)

性能对比:malloc vs 内存池

我做个简单的基准测试,大家感受一下差距:

操作 malloc 内存池 提升倍数
分配100万次(64字节) 约85ms 约3ms 28倍
释放100万次 约60ms 约2ms 30倍
最大延迟 12μs 0.3μs 40倍

你看,差距就是这么明显。而且内存池的延迟非常稳定,几乎没有任何抖动。这对交易系统来说太重要了。

避坑指南

最后分享几个我踩过的坑:

  • 线程安全:上面的代码不是线程安全的。多线程环境下要加锁,或者用线程本地存储(TLS)。我一开始没注意,结果两个线程同时分配,链表指针乱掉了。
  • 内存泄漏:内存池销毁时要记得free整块内存。我见过有人只free了控制块,大块内存没释放,内存泄漏了都不知道。
  • 越界检查:用户写入数据超出chunk_size,会破坏相邻chunk的链表指针。我习惯在每个chunk前后加哨兵字节,定期检查。

我的建议:刚开始做内存池,先从固定大小、单线程版本开始。跑通了再考虑多线程、可变大小这些高级特性。一步到位往往容易出问题。

好了,这一章就到这里。内存池的原理其实不复杂,但细节决定成败。下一章咱们聊聊零拷贝技术,看看怎么在交易系统里彻底干掉不必要的数据拷贝。

专注资料整理