第30章:内存池综合实战——设计一个高性能通用内存池

终于到了综合实战这一章。说实话,前面讲了那么多理论、算法、数据结构,都是为这一刻做铺垫的。今天我们要亲手打造一个真正能上生产环境的通用内存池——支持固定大小和可变大小分配、线程安全、还能做性能调优。

我在几个高并发项目里都自己写过内存池,踩过的坑不少。比如有一次线上服务突然内存暴涨,查了半天才发现是内存池的锁粒度太粗,导致大量线程在等待。嗯,这些经验今天都会分享给你。

整体架构设计

先看整体结构。我习惯把内存池分成三层:

  • 底层:大块内存管理(从系统申请/归还)
  • 中间层:固定大小分配器(slab 机制)
  • 上层:可变大小分配器(基于 slab 的组合)

说白了,可变大小分配器是建立在固定大小分配器之上的。你想想看,如果固定大小的分配器足够快,那么可变大小的分配只需要把请求拆分成多个固定大小的块就行了。

通用内存池三层架构 上层:可变大小分配器 malloc(size) / free(ptr) 接口 将请求拆分为 8/16/32/64/128/256/512/1024 字节的 slab 块 中间层:固定大小分配器(Slab) 每个 slab 管理一种固定大小的内存块 空闲链表 + 部分使用 + 全满 三级状态管理 每个 slab 大小通常为 4KB 或 8KB 底层:大块内存管理 mmap() / munmap() 或 malloc() 批量申请 管理 64KB ~ 1MB 的大块内存,按需归还给系统

固定大小分配器的核心实现

先看固定大小分配器。这是整个内存池的基石。我直接上代码,然后逐行解释。

// slab 管理结构
typedef struct slab_s {
    void        *mem;           // 实际内存块
    size_t       block_size;    // 每个块的大小
    int          total_blocks;  // 总块数
    int          free_blocks;   // 空闲块数
    int          first_free;    // 第一个空闲块索引
    struct slab_s *next;        // 链表指针
} slab_t;

// 固定大小分配器
typedef struct {
    slab_t      *partial_list;  // 部分使用
    slab_t      *full_list;     // 全满
    slab_t      *free_list;     // 全空
    size_t       block_size;    // 块大小
    pthread_mutex_t lock;       // 线程安全
} fixed_allocator_t;

这里有个关键设计:空闲块索引链表。每个空闲块的前4个字节存储下一个空闲块的索引。这样就不需要额外的空闲链表内存了。我在项目里用这个技巧,内存利用率提升了大约8%。

核心思想:slab 内部用索引链表代替指针链表,节省内存且对缓存更友好。

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

void *fixed_alloc(fixed_allocator_t *fa) {
    pthread_mutex_lock(&fa->lock);
    
    // 从 partial 找,没有就从 free 找
    slab_t *slab = fa->partial_list;
    if (!slab) {
        slab = fa->free_list;
        if (slab) {
            // 从 free 移到 partial
            fa->free_list = slab->next;
            slab->next = fa->partial_list;
            fa->partial_list = slab;
        }
    }
    
    if (!slab) {
        pthread_mutex_unlock(&fa->lock);
        return NULL; // 需要创建新 slab
    }
    
    // 分配:取出 first_free 指向的块
    int idx = slab->first_free;
    int *next_idx = (int *)((char *)slab->mem + idx * slab->block_size);
    slab->first_free = *next_idx;
    slab->free_blocks--;
    
    // 如果满了,移到 full 链表
    if (slab->free_blocks == 0) {
        // 从 partial 移除,加入 full
        // ... 链表操作略
    }
    
    pthread_mutex_unlock(&fa->lock);
    return (char *)slab->mem + idx * slab->block_size;
}

注意:上面的代码为了展示核心逻辑做了简化。实际生产环境中,创建新 slab 的操作应该放在锁外面,避免长时间持有锁。我曾经因为这个导致线上服务出现毫秒级的抖动。

可变大小分配器的设计

可变大小分配器其实是个「多路固定大小分配器」。我通常这样设计:

请求大小范围 实际分配大小 对应的固定分配器
1 ~ 8 字节 8 字节 fixed_allocator_8
9 ~ 16 字节 16 字节 fixed_allocator_16
17 ~ 32 字节 32 字节 fixed_allocator_32
33 ~ 64 字节 64 字节 fixed_allocator_64
65 ~ 128 字节 128 字节 fixed_allocator_128
129 ~ 256 字节 256 字节 fixed_allocator_256
257 ~ 512 字节 512 字节 fixed_allocator_512
513 ~ 1024 字节 1024 字节 fixed_allocator_1024
> 1024 字节 直接 mmap 不经过内存池

为什么是 8/16/32/... 这种 2 的幂次?说白了就是为了对齐和索引方便。你想想看,给定一个 size,我只需要 ceil(log2(size)) 就能找到对应的分配器,时间复杂度 O(1)。

小技巧:可以用 __builtin_clz() 或者位运算快速计算 size 对应的档位,比 if-else 链快得多。

线程安全设计

线程安全这块,我推荐用 per-thread cache + 全局池 的两级架构。每个线程有自己的小缓存,不够了才去全局池取。

// 线程本地缓存
typedef struct {
    void *cache[64];  // 每个线程缓存 64 个对象
    int   count;
} thread_cache_t;

// 全局分配器
typedef struct {
    fixed_allocator_t allocators[8];  // 8 种大小
    pthread_mutex_t   global_lock;
} global_pool_t;

// 线程本地变量
__thread thread_cache_t tcache;

分配时先看线程本地缓存:

void *pool_alloc(size_t size) {
    int idx = size_to_index(size);
    
    // 先看线程本地缓存
    if (tcache.count > 0) {
        return tcache.cache[--tcache.count];
    }
    
    // 从全局池批量取一批
    pthread_mutex_lock(&global_lock);
    for (int i = 0; i < BATCH_SIZE; i++) {
        tcache.cache[i] = fixed_alloc(&allocators[idx]);
    }
    tcache.count = BATCH_SIZE;
    pthread_mutex_unlock(&global_lock);
    
    return tcache.cache[--tcache.count];
}

这个设计的好处是:大部分分配操作都不需要加锁。我在一个 64 核的机器上测试过,per-thread cache 方案比直接加全局锁快了将近 20 倍。

性能调优实战

调优这件事,说白了就是「测量-调整-再测量」的循环。我分享几个关键参数:

  • Slab 大小:我一般用 4KB。太小了系统调用频繁,太大了内存碎片多。
  • Batch 大小:每个线程一次从全局池取多少。我习惯取 32 或 64,太少锁竞争多,太多浪费内存。
  • 空闲 slab 回收阈值:当空闲 slab 超过一定数量时,归还给系统。我通常设为 10 个。

调优口诀:Slab 大小看 L1 缓存,Batch 大小看线程数,回收阈值看内存压力。

我曾经在一个数据库项目里,把 Batch 大小从 16 改成 64,QPS 直接提升了 15%。为什么?因为线程多了之后,频繁去全局池取数据导致锁竞争严重。Batch 大了,每个线程去全局池的次数就少了。

完整的使用示例

int main() {
    // 初始化内存池
    mempool_t *pool = mempool_create();
    
    // 分配各种大小的内存
    void *p1 = pool_alloc(pool, 32);   // 走 32 字节 slab
    void *p2 = pool_alloc(pool, 100);  // 走 128 字节 slab
    void *p3 = pool_alloc(pool, 2000); // 走 mmap
    
    // 使用内存...
    
    // 释放
    pool_free(pool, p1);
    pool_free(pool, p2);
    pool_free(pool, p3);
    
    // 销毁内存池
    mempool_destroy(pool);
    return 0;
}

接口设计得和 malloc/free 一样简单,但内部做了大量优化。这就是好的设计——对外简单,对内复杂。

避坑指南

最后说几个我踩过的坑:

  • 内存对齐:返回的指针一定要对齐到 8 字节或 16 字节。有些硬件(比如 ARM)访问未对齐地址会 crash。
  • 线程退出时的清理:线程退出时要把本地缓存归还给全局池,否则内存泄漏。可以用 pthread 的 key 机制注册清理函数。
  • 大内存直接走 mmap:超过 1MB 的请求不要走 slab,直接 mmap。否则一个 slab 里放不了几个大对象,浪费严重。

曾经有一次,我忘了处理线程退出时的缓存归还,结果服务跑了三天后内存占用从 2GB 涨到了 8GB。排查了半天才发现是线程本地缓存没释放。从那以后,我每次写内存池都会先画好「谁申请谁释放」的流程图。

好了,这一章的内容就到这里。内存池的设计其实没有银弹,关键是根据你的业务场景做取舍。希望今天的实战经验能帮你少走一些弯路。


公众号:蓝海资料掘金营,微信deep3321