前言
在 KVStore 项目中,每次 SET/DEL/HASH 操作都会产生大量小对象分配。如果每次都直接调用malloc(),轻则延迟抖动,重则内存碎片化导致系统变慢甚至 OOM。
本文基于一个实战级内存池实现,逐层剖析它的设计思路、核心数据结构、多线程优化,以及实际使用中遇到的坑。
项目源码:KVStore with MemPool
适用场景:高并发短生命周期小对象(KV存储、协议处理、游戏服务器)
一、整体架构
pool_alloc(size_t n) │ ├── n > 512(大块)→ 直接 malloc + 链表管理 │ └── n ≤ 512(小块)→ 从 Size Class 分配 │ ├── 线程本地缓存(TLC)命中?→ 直接返回,无锁! ├── 全局自由链表有空闲?→ 加锁取出 └── 都没有?→ 分配新 Chunk,头插到 free_list内存池的核心思想:一次批量申请一大块(Chunk),切分成 N 个小Block,按需分配/回收。
二、核心数据结构
2.1 Size Class(大小分级)
staticconstsize_tsize_classes[]={64,128,256,512};#defineNUM_CLASSES(sizeof(size_classes)/sizeof(size_classes[0]))只管理 4 个档位:
| Class 0 | Class 1 | Class 2 | Class 3 |
|---|---|---|---|
| ≤64B | ≤128B | ≤256B | ≤512B |
超过 512 字节的请求直接回退到malloc,不参与池化管理。
2.2 Chunk(物理块)
typedefstructchunk{structchunk*next;void*mem;// malloc 申请的大块内存起始地址size_tblocks;// 这个大块包含多少个小块}chunk;每次class_add_chunk申请blocks × block_size大小的内存,然后切成小块串成链表。
2.3 Class Meta(管理结构)
typedefstructclass_meta{size_tblock_size;// 该 class 的块大小chunk*chunks;// 所有物理块链表free_node*global_free;// 全局空闲链表(头插法)pthread_mutex_tlock;// 保护该 class 的并发访问size_ttotal_bytes;// 已申请总字节}class_meta;staticclass_meta g_classes[NUM_CLASSES];2.4 线程本地缓存(Thread-Local Cache)
typedefstructtlc_bucket{free_node*items[THREAD_LOCAL_CACHE_LIMIT];// 栈,存空闲块指针inttop;}tlc_bucket;typedefstructtlc{tlc_bucket buckets[NUM_CLASSES];// 每个 class 一个桶}tlc;static__thread tlc*thread_cache=NULL;// TLS,每个线程独立这是无锁分配的关键:分配时先查本地缓存,只有本地缓存满了才访问全局链表(加锁)。
2.5 大块管理链表
typedefstructlarge_node{structlarge_node*next;void*ptr;size_tsize;}large_node;超过 512 字节的分配走malloc,用这个链表记录,释放时能找到并正确free。
三、最关键的设计:Size 追踪表
这是本实现最有技术含量的部分。
问题
pool_free(p, n)要求调用者传入当初分配时的大小。但应用层代码往往不知道/记不住这个大小,很容易传错:
// 应用层pool_free(node->key,512);// 实际分配的是 strlen(key)+1,可能不是 512!传错大小会导致:
class_index_for_size(512)算出的 class 索引不对- 块被放错链表,后续取出来用会越界
- 最严重:堆损坏 → malloc assertion failure
解决方案:分配记录表(Hash Table)
#defineHT_SIZE4096typedefstruct{void*ptr;size_tsize;intin_use;// 0=空, 1=使用中}alloc_record;staticalloc_record*g_alloc_table=NULL;// 全局哈希表staticintht_hash(void*ptr){return((uintptr_t)ptr)%HT_SIZE;// 简单的取模哈希}分配时记录:
void*pool_alloc(size_tn){// ...ht_insert(node,g_classes[idx].block_size);// 记录真实分配大小return(void*)node;}释放时查找:
voidpool_free(void*p,size_tn){size_tactual_size=ht_lookup(p);// 查表获取真实大小if(actual_size>0){n=actual_size;// 用真实大小覆盖传入的大小ht_remove(p);// 移除记录}// ... 正常处理}即使应用层传入错误的大小,也能靠这张表"自我纠正",这就是为什么之前
kvs_hash.c里写pool_free(node, sizeof(hashnode_t))不会马上崩溃——只是浪费了表的空间。
四、核心 API 详解
4.1 pool_init
intpool_init(size_tinitial_bytes){g_alloc_table=calloc(HT_SIZE,sizeof(alloc_record));// 初始化哈希表// 初始化每个 class 的互斥锁和元数据for(inti=0;i<NUM_CLASSES;++i){g_classes[i].block_size=size_classes[i];g_classes[i].chunks=NULL;g_classes[i].global_free=NULL;pthread_mutex_init(&g_classes[i].lock,NULL);}// 默认分配:BLOCKS_PER_CHUNK(4096) × 最大块(512) ≈ 2MB 初始池if(initial_bytes==0)initial_bytes=BLOCKS_PER_CHUNK_DEFAULT*g_classes[NUM_CLASSES-1].block_size;// 为每个 class 预分配一个 chunksize_tper_class=initial_bytes/NUM_CLASSES;for(inti=0;i<NUM_CLASSES;++i){size_tblocks=per_class/g_classes[i].block_size;if(blocks==0)blocks=BLOCKS_PER_CHUNK_DEFAULT;class_add_chunk(i,blocks);}return0;}4.2 pool_alloc(三层分发)
void*pool_alloc(size_tn){if(n==0)returnNULL;// 第一层:超过 512,直接走 mallocintidx=class_index_for_size(n);if(idx==-1){void*p=malloc(n);register_large(p,n);// 记录到大块链表ht_insert(p,n);// 追踪大小returnp;}// 第二层:查线程本地缓存(完全无锁,极快)tlc_bucket*bucket=&thread_cache->buckets[idx];if(bucket->top>0){bucket->top--;free_node*node=bucket->items[bucket->top];memset(node,0,block_size);ht_insert(node,block_size);return(void*)node;}// 第三层:查全局自由链表(需要加锁)pthread_mutex_lock(&g_classes[idx].lock);free_node*node=g_classes[idx].global_free;if(node){g_classes[idx].global_free=node->next;pthread_mutex_unlock(&g_classes[idx].lock);ht_insert(node,block_size);return(void*)node;}// 第四层:全局也没有,申请新 Chunkif(class_add_chunk(idx,BLOCKS_PER_CHUNK_DEFAULT)!=0){pthread_mutex_unlock(&g_classes[idx].lock);returnNULL;}node=g_classes[idx].global_free;g_classes[idx].global_free=node->next;pthread_mutex_unlock(&g_classes[idx].lock);ht_insert(node,block_size);return(void*)node;}分配路径总结:
pool_alloc(100) → class_index_for_size(100) → idx=1 (128B class) → TLC 有空块?→ 直接返回(无锁!) → 全局 free_list 有空块?→ 加锁取出 → 都没有?→ 分配新 Chunk → 取出一块返回4.3 pool_free(智能回收)
voidpool_free(void*p,size_tn){if(!p)return;// 关键:从追踪表获取真实大小(自我纠正)size_tactual=ht_lookup(p);if(actual>0)n=actual,ht_remove(p);intidx=class_index_for_size(n);if(idx==-1){unregister_and_free_large(p);return;}tlc_bucket*bucket=&thread_cache->buckets[idx];// 本地缓存没满 → 直接压入本地栈(无锁!)if(bucket->top<THREAD_LOCAL_CACHE_LIMIT){bucket->items[bucket->top++]=(free_node*)p;return;}// 本地缓存满了 → 批量将一半块放回全局 free_listinthalf=THREAD_LOCAL_CACHE_LIMIT/2;pthread_mutex_lock(&g_classes[idx].lock);for(inti=0;i<half;++i){free_node*node=bucket->items[--bucket->top];node->next=g_classes[idx].global_free;g_classes[idx].global_free=node;}// 当前要释放的块也加入全局链表free_node*node_cur=(free_node*)p;node_cur->next=g_classes[idx].global_free;g_classes[idx].global_free=node_cur;pthread_mutex_unlock(&g_classes[idx].lock);}五、在 KVStore 中的应用
5.1 切换开关
#defineNONPOOL0#defineMEMPOOL1#definePOOL_SELECTMEMPOOL// 改成 NONPOOL 就用系统 malloc5.2 典型使用模式
以 Hash 表节点创建为例:
hashnode_t*node=(hashnode_t*)kvs_malloc(sizeof(hashnode_t));// node 始终用系统 malloc(固定大小,管理简单)#if(POOL_SELECT==MEMPOOL)char*kcopy=(char*)pool_alloc(klen);char*kvalue=(char*)pool_alloc(vlen);// 分配失败回退:if(!kcopy){kvs_free(node);returnNULL;}if(!kvalue){pool_free(kcopy,klen);kvs_free(node);returnNULL;}#elsechar*kcopy=kvs_malloc(strlen(key)+1);// ...#endif5.3 致命陷阱:混用内存分配器
以下代码会导致堆损坏(曾经的真实 bug):
// ❌ 错误:node 用系统 malloc 分配,却用 pool_free 释放hashnode_t*node=(hashnode_t*)kvs_malloc(sizeof(hashnode_t));// ...pool_free(node,sizeof(hashnode_t));// ← 灾难!pool 不知道这块是 malloc 的// ✅ 正确:系统分配 → 系统释放,池分配 → 池释放kvs_free(node);// 系统 malloc → 系统 freepool_free(node,sizeof(hashnode_t));// 池分配 → 池释放最佳实践:结构体节点本身用kvs_malloc(固定大小),key/value 内容用pool_alloc(变长)。释放时 node 结构体kvs_free,key/valuepool_free。
六、锁竞争优化
内存池的最大瓶颈是全局锁。本实现通过两层缓存将锁竞争降到最低:
线程 A: pool_alloc() → TLC 命中 → 0 次加锁 线程 B: pool_alloc() → TLC 命中 → 0 次加锁 线程 C: pool_alloc() → TLC 满 → 全局加锁 1 次 线程 D: pool_free() → TLC 满 → 全局加锁 1 次TLC 本地缓存充当了"隔离层",大多数分配完全无锁。
七、调试与排查
7.1 常见崩溃原因
pool_free传入大小与实际不符→ ht_lookup 查不到,块放错链表 → 下次取用越界- 混用分配器→ 系统 malloc 的块传给 pool_free → 堆元数据彻底破坏
- 重复释放→ ht_remove 标记为空后再次释放同一指针 → 未定义行为
7.2 调试技巧
// 1. 打印池状态printf("Pool capacity: %zu bytes\n",pool_capacity());printf("Total allocated: %zu bytes\n",pool_total_allocated());// 2. 内存池关闭时全量检测// pool_destroy() 会遍历所有 chunk 和 large_node 释放,// 如果有遗漏会直接暴露八、总结
| 特性 | 实现方式 |
|---|---|
| 小对象分配(≤512B) | 固定-size class + Chunk 切分 |
| 大对象(>512B) | 直接 malloc + 链表管理 |
| 无锁分配 | TLS 线程本地缓存 |
| 大块并发安全 | 独立互斥锁 + 大块专用链表 |
| 大小自我纠正 | Hash 表追踪 (ptr→size) |
| 批量回收 | 本地缓存满时批量头插回全局 |
这个内存池在 KVStore 的 4 种存储引擎(Array / RBTREE / Hash / SkipList)中运行稳定,经受了高并发和大量数据反复增删的考验。核心设计理念:小事不走系统 malloc,大事不走池——各司其职,井水不犯河水。