内存池基础原理:从malloc到自定义内存管理
大家好,我是你们的老朋友。今天咱们聊聊内存池。
说实话,我刚入行做交易系统那会儿,对内存管理完全没概念。项目里到处都是malloc和free,觉得挺正常的。直到有一次,我们的行情网关在开盘后第37秒突然卡死——嗯,就是内存碎片惹的祸。从那以后,我彻底改变了看法。
为什么malloc不够用?
先说说malloc的问题。很多人觉得malloc挺好用的,为什么非要自己搞一套?
我总结了几点:
- 性能开销大:每次malloc都要进入内核态,做系统调用。交易系统里每秒几万笔订单,你想想看,光系统调用就吃掉多少CPU?
- 内存碎片严重:频繁分配释放,堆内存会变得支离破碎。我见过一个极端案例,明明还有2GB空闲内存,但malloc一个1KB的小块都失败了。
- 不确定性:malloc的耗时不是固定的。有时候几纳秒,有时候几十微秒。交易系统最怕的就是这种抖动。
核心观点:交易系统里,确定性比高性能更重要。一个可预测的10微秒,好过一个平均5微秒但偶尔100微秒的方案。
内存池的核心思想
说白了,内存池就是提前向操作系统申请一大块内存,然后自己管理。就像你去批发市场进货,而不是每次需要一根葱都跑一趟菜市场。
基本流程是这样的:
- 初始化时,一次性malloc一大块连续内存
- 把这块内存切成固定大小的块(chunk)
- 用链表把这些空闲块串起来
- 分配时,从链表头部取一个块
- 释放时,把块放回链表头部
我习惯把这个结构叫做「空闲链表」。简单、高效、可控。
动手实现一个最简单的内存池
光说不练假把式。咱们直接上代码。
// 最简单的固定大小内存池
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排查了整整一个下午。
内存池的核心数据结构
咱们用一张图来理解整个结构:
固定大小 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前后加哨兵字节,定期检查。
我的建议:刚开始做内存池,先从固定大小、单线程版本开始。跑通了再考虑多线程、可变大小这些高级特性。一步到位往往容易出问题。
好了,这一章就到这里。内存池的原理其实不复杂,但细节决定成败。下一章咱们聊聊零拷贝技术,看看怎么在交易系统里彻底干掉不必要的数据拷贝。