3. 固定大小内存池设计与实现
内存池这东西,说白了就是提前向系统申请一大块内存,然后自己管理分配和释放。为什么要这么干?因为 malloc 和 free 在交易系统这种高频场景下,实在太慢了。我早年在一个期货高频项目里,光是在行情解码路径上做内存分配,就吃掉了 30% 的 CPU 时间。嗯,后来改成固定大小内存池,延迟直接砍半。
3.1 为什么是固定大小?
交易系统里很多对象大小是固定的。比如订单结构体、行情 tick、撮合结果。这些对象生命周期极短,创建销毁极其频繁。如果每个都走系统堆分配,碎片问题和性能问题会让你痛不欲生。
固定大小内存池的核心思路:
- 预分配一大块连续内存
- 切成等大小的槽位(slot)
- 用空闲链表串联所有可用槽位
- 分配时从链表头部取一个,释放时插回链表头部
你想想看,整个过程就是几个指针操作,没有系统调用,没有锁竞争(单线程场景下)。
3.2 核心数据结构
我个人习惯用这样的结构:
struct MemoryPool {
void* pool_start; // 内存池起始地址
void* pool_end; // 内存池结束地址
void* free_head; // 空闲链表头
size_t slot_size; // 每个槽位大小
size_t slot_count; // 槽位总数
size_t free_count; // 当前空闲槽位数
};
这里有个关键点:slot_size 必须至少能存放一个指针(8字节),因为空闲链表要利用槽位本身的内存来存储 next 指针。我在项目中遇到过有人把 slot_size 设成 4 字节,结果链表指针写不进去,直接崩溃。
3.3 初始化流程
初始化其实就三步:
- 用
mmap或malloc申请一大块连续内存 - 把每个槽位当作链表节点,串联起来
- 记录元数据
代码实现如下:
MemoryPool* pool_create(size_t slot_size, size_t slot_count) {
// 对齐到 8 字节
slot_size = (slot_size + 7) & ~7;
if (slot_size < sizeof(void*)) slot_size = sizeof(void*);
size_t total_size = slot_size * slot_count;
// 用 mmap 申请大页内存,减少 TLB miss
void* mem = mmap(NULL, total_size,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB,
-1, 0);
if (mem == MAP_FAILED) {
// 大页申请失败,回退到普通 mmap
mem = mmap(NULL, total_size,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS,
-1, 0);
}
MemoryPool* pool = (MemoryPool*)malloc(sizeof(MemoryPool));
pool->pool_start = mem;
pool->pool_end = (char*)mem + total_size;
pool->slot_size = slot_size;
pool->slot_count = slot_count;
pool->free_count = slot_count;
// 构建空闲链表
char* ptr = (char*)mem;
for (size_t i = 0; i < slot_count - 1; i++) {
void** next = (void**)(ptr + i * slot_size);
*next = ptr + (i + 1) * slot_size;
}
void** last = (void**)(ptr + (slot_count - 1) * slot_size);
*last = NULL;
pool->free_head = mem;
return pool;
}
3.4 分配与释放
分配和释放的代码极其简洁:
void* pool_alloc(MemoryPool* pool) {
if (pool->free_head == NULL) {
return NULL; // 内存耗尽
}
void* slot = pool->free_head;
pool->free_head = *(void**)slot;
pool->free_count--;
return slot;
}
void pool_free(MemoryPool* pool, void* ptr) {
if (ptr == NULL) return;
// 检查指针是否属于本池
if (ptr < pool->pool_start || ptr >= pool->pool_end) {
// 非法指针,可以加 assert 或日志
return;
}
*(void**)ptr = pool->free_head;
pool->free_head = ptr;
pool->free_count++;
}
你看,分配就是取链表头,释放就是插回链表头。时间复杂度 O(1),没有任何分支预测失败的风险。我曾经在撮合引擎里用这个模式,每秒处理 50 万笔订单的内存分配开销几乎可以忽略不计。
3.5 避坑指南
这里有几个我踩过的坑,分享给你:
- 内存对齐问题: 如果 slot_size 不对齐到 8 字节,在 ARM 架构上会触发 SIGBUS。我曾在 ARM 服务器上栽过这个跟头。
- 线程安全问题: 上面的实现是单线程的。多线程场景需要加锁或用 CAS 操作。我个人建议每个线程独立一个内存池,避免锁竞争。
- 内存泄漏检测: 可以在 pool 结构里加一个 bitmap,记录每个 slot 的分配状态。调试时打开,线上关闭。
- 碎片问题: 固定大小内存池不会产生外部碎片,但内部碎片取决于 slot_size 和实际对象大小的匹配度。
3.6 性能对比
我拿一个实际项目的数据给你看:
| 操作 | malloc/free | 固定大小内存池 | 提升倍数 |
|---|---|---|---|
| 分配 64 字节 | 120 ns | 8 ns | 15x |
| 释放 64 字节 | 95 ns | 6 ns | 16x |
| 分配 256 字节 | 150 ns | 8 ns | 19x |
| 释放 256 字节 | 110 ns | 6 ns | 18x |
数据是在 Intel Xeon Gold 6248 上测的,单线程,预热后取中位数。差距就是这么明显。
3.7 本章小结
固定大小内存池是交易系统里最基础、最常用的内存管理手段。它简单、高效、可靠。我建议你在自己的项目里先把这个组件写好、测透,因为后面很多高级技巧(比如无锁队列、零拷贝)都要依赖它。
嗯,下一节我们会聊怎么把这个内存池做成线程安全的,以及如何用 CAS 操作实现无锁分配。但那是后话了,先把今天的代码跑通再说。