前言
在 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(大小分级)
c
static const size_t size_classes[] = {64, 128, 256, 512};
#define NUM_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(物理块)
c
typedef struct chunk {
struct chunk *next;
void *mem; // malloc 申请的大块内存起始地址
size_t blocks; // 这个大块包含多少个小块
} chunk;
每次 class_add_chunk 申请 blocks × block_size 大小的内存,然后切成小块串成链表。
2.3 Class Meta(管理结构)
c
typedef struct class_meta {
size_t block_size; // 该 class 的块大小
chunk *chunks; // 所有物理块链表
free_node *global_free; // 全局空闲链表(头插法)
pthread_mutex_t lock; // 保护该 class 的并发访问
size_t total_bytes; // 已申请总字节
} class_meta;
static class_meta g_classes[NUM_CLASSES];
2.4 线程本地缓存(Thread-Local Cache)
c
typedef struct tlc_bucket {
free_node *items[THREAD_LOCAL_CACHE_LIMIT]; // 栈,存空闲块指针
int top;
} tlc_bucket;
typedef struct tlc {
tlc_bucket buckets[NUM_CLASSES]; // 每个 class 一个桶
} tlc;
static __thread tlc *thread_cache = NULL; // TLS,每个线程独立
这是无锁分配的关键:分配时先查本地缓存,只有本地缓存满了才访问全局链表(加锁)。
2.5 大块管理链表
c
typedef struct large_node {
struct large_node *next;
void *ptr;
size_t size;
} large_node;
超过 512 字节的分配走 malloc,用这个链表记录,释放时能找到并正确 free。
三、最关键的设计:Size 追踪表
这是本实现最有技术含量的部分。
问题
pool_free(p, n) 要求调用者传入当初分配时的大小。但应用层代码往往不知道/记不住这个大小,很容易传错:
c
// 应用层
pool_free(node->key, 512); // 实际分配的是 strlen(key)+1,可能不是 512!
传错大小会导致:
class_index_for_size(512)算出的 class 索引不对- 块被放错链表,后续取出来用会越界
- 最严重:堆损坏 → malloc assertion failure
解决方案:分配记录表(Hash Table)
c
#define HT_SIZE 4096
typedef struct {
void *ptr;
size_t size;
int in_use; // 0=空, 1=使用中
} alloc_record;
static alloc_record *g_alloc_table = NULL; // 全局哈希表
static int ht_hash(void *ptr) {
return ((uintptr_t)ptr) % HT_SIZE; // 简单的取模哈希
}
分配时记录:
c
void *pool_alloc(size_t n) {
// ...
ht_insert(node, g_classes[idx].block_size); // 记录真实分配大小
return (void *)node;
}
释放时查找:
c
void pool_free(void *p, size_t n) {
size_t actual_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
c
int pool_init(size_t initial_bytes) {
g_alloc_table = calloc(HT_SIZE, sizeof(alloc_record)); // 初始化哈希表
// 初始化每个 class 的互斥锁和元数据
for (int i = 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 预分配一个 chunk
size_t per_class = initial_bytes / NUM_CLASSES;
for (int i = 0; i < NUM_CLASSES; ++i) {
size_t blocks = per_class / g_classes[i].block_size;
if (blocks == 0) blocks = BLOCKS_PER_CHUNK_DEFAULT;
class_add_chunk(i, blocks);
}
return 0;
}
4.2 pool_alloc(三层分发)
c
void *pool_alloc(size_t n) {
if (n == 0) return NULL;
// 第一层:超过 512,直接走 malloc
int idx = class_index_for_size(n);
if (idx == -1) {
void *p = malloc(n);
register_large(p, n); // 记录到大块链表
ht_insert(p, n); // 追踪大小
return p;
}
// 第二层:查线程本地缓存(完全无锁,极快)
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;
}
// 第四层:全局也没有,申请新 Chunk
if (class_add_chunk(idx, BLOCKS_PER_CHUNK_DEFAULT) != 0) {
pthread_mutex_unlock(&g_classes[idx].lock);
return NULL;
}
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(智能回收)
c
void pool_free(void *p, size_t n) {
if (!p) return;
// 关键:从追踪表获取真实大小(自我纠正)
size_t actual = ht_lookup(p);
if (actual > 0) n = actual, ht_remove(p);
int idx = 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_list
int half = THREAD_LOCAL_CACHE_LIMIT / 2;
pthread_mutex_lock(&g_classes[idx].lock);
for (int i = 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 切换开关
c
#define NONPOOL 0
#define MEMPOOL 1
#define POOL_SELECT MEMPOOL // 改成 NONPOOL 就用系统 malloc
5.2 典型使用模式
以 Hash 表节点创建为例:
c
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); return NULL; }
if (!kvalue) { pool_free(kcopy, klen); kvs_free(node); return NULL; }
#else
char *kcopy = kvs_malloc(strlen(key) + 1);
// ...
#endif
5.3 致命陷阱:混用内存分配器
以下代码会导致堆损坏(曾经的真实 bug):
c
// ❌ 错误: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 → 系统 free
pool_free(node, sizeof(hashnode_t)); // 池分配 → 池释放
最佳实践: 结构体节点本身用 kvs_malloc(固定大小),key/value 内容用 pool_alloc(变长)。释放时 node 结构体 kvs_free,key/value pool_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 调试技巧
c
// 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,大事不走池------各司其职,井水不犯河水。