第4章:变长内存池设计与实现
内存池这东西,做交易系统的兄弟都不陌生。但大多数教材讲的是定长内存池——每个块大小固定,分配释放都很快。可现实呢?你想想看,我们的消息结构体、订单数据、市场快照,哪个是固定长度的?
我在早期做高频交易系统时,就踩过这个坑。当时用了定长池,结果为了适配不同大小的消息,要么浪费大量内存,要么频繁触发后备分配器。嗯,后来我彻底重构了一版变长内存池,才算把这个问题解决。
4.1 为什么需要变长内存池
先说说痛点。交易系统里,数据包的长度千差万别:
- 一个简单的行情Tick可能只有几十字节
- 深度快照可能达到几KB甚至几十KB
- 订单消息、成交回报,长度也各不相同
如果用定长池,你得按最大长度来分配。这会导致什么?内存碎片率飙升,缓存命中率下降。说白了,就是拿空间换时间,但空间换得太亏了。
核心思路:变长内存池的核心,是把不同大小的内存请求分类管理。小请求走小池,大请求走大池,超大请求直接走系统分配。这样既保证了小对象的分配速度,又避免了大对象的浪费。
4.2 设计思路
我个人习惯把变长池设计成「分级桶」结构。什么意思呢?就是预先定义几个大小级别:
| 级别 | 大小范围 | 对齐方式 | 典型用途 |
|---|---|---|---|
| 0 | 8-64字节 | 8字节 | 小消息头、原子操作 |
| 1 | 65-256字节 | 16字节 | 订单结构体 |
| 2 | 257-1024字节 | 32字节 | 行情快照 |
| 3 | 1025-4096字节 | 64字节 | 深度数据 |
| 4 | 4096以上 | 系统页对齐 | 超大消息,走mmap |
每个级别内部,其实就是一个定长内存池。但这里有个关键点:向上取整。比如你申请100字节,系统会把它归到256字节的桶里。这看起来浪费了156字节,但换来的是O(1)的分配速度。
我的经验:级别划分不是拍脑袋定的。我一般会先做一次内存分配日志,统计实际业务中所有分配请求的大小分布。然后根据P50、P90、P99来设定桶的边界。这样既能覆盖绝大多数场景,又不会浪费太多。
4.3 核心数据结构
变长池的核心,其实就是一个「桶数组」加一个「后备分配器」。每个桶里维护一个空闲链表:
// 变长内存池的核心结构
struct VarPool {
// 每个桶对应一个定长池
FixedPool* buckets[MAX_BUCKETS];
// 桶的大小阈值(字节)
size_t bucket_sizes[MAX_BUCKETS];
// 后备分配器(处理超大请求)
Allocator* fallback;
// 统计信息
atomic<uint64_t> alloc_count;
atomic<uint64_t> free_count;
atomic<uint64_t> fallback_count;
};
// 分配接口
void* var_pool_alloc(VarPool* pool, size_t size) {
// 1. 找到合适的桶
int idx = find_bucket(pool, size);
// 2. 如果在桶范围内,走定长池
if (idx < MAX_BUCKETS) {
pool->alloc_count++;
return fixed_pool_alloc(pool->buckets[idx]);
}
// 3. 超大请求,走后备
pool->fallback_count++;
return pool->fallback->alloc(size);
}
这里有个细节:find_bucket 函数必须快。我一般用二分查找,因为桶的数量很少(通常不超过10个),所以性能损耗可以忽略。
4.4 零拷贝整合
变长池和零拷贝技术是天生一对。为什么?因为零拷贝要求数据在内存中连续且对齐,而变长池正好能保证这一点。
我在项目中做过一个优化:把网络收包直接映射到变长池的缓冲区里。流程是这样的:
- 网卡DMA把数据写入预分配的变长池块
- 应用层直接从池里读取,无需拷贝
- 处理完后,把块归还给池
这样做的好处是,整个数据路径上只有一次内存分配,没有多余的memcpy。对于高频交易来说,这能省下几百纳秒——别小看这几百纳秒,在纳秒级竞争中,这就是胜负手。
我曾经踩过的坑:一开始我让所有线程共享同一个变长池,结果发现锁竞争严重。后来改成每个线程一个本地缓存池(Thread Local Cache),只有本地池空了才去全局池取。这样锁粒度大大降低,性能提升了3倍以上。
4.5 内存碎片控制
变长池最怕什么?内存碎片。虽然分级桶能缓解,但时间长了还是会有问题。我的做法是:
- 定期整理:每个桶内部维护一个「空闲块数量」计数器。当某个桶的空闲率超过70%时,触发整理操作,把连续的空闲块合并成大块。
- 水位预警:当后备分配器的调用次数超过总分配次数的5%时,说明桶的划分不合理,需要动态调整。
- 内存池扩容:每个桶可以动态增加新的内存页,而不是一次性分配全部内存。这样能避免预分配过多导致的浪费。
// 内存整理示例
void var_pool_defrag(VarPool* pool, int bucket_idx) {
FixedPool* bp = pool->buckets[bucket_idx];
// 如果空闲率超过阈值,触发整理
if (bp->free_ratio() > 0.7) {
// 1. 暂停分配(用spinlock保护)
// 2. 扫描所有块,标记连续空闲区域
// 3. 合并相邻空闲块
// 4. 重建空闲链表
bp->coalesce();
}
}
4.6 性能对比
最后,我拿一组实测数据来说明变长池的效果。测试环境是:Intel Xeon Gold 6248,DDR4-2933,CentOS 7.9:
| 分配方式 | 平均延迟 | P99延迟 | 内存碎片率 | 吞吐量 |
|---|---|---|---|---|
| 系统malloc | 120ns | 350ns | 15-25% | 800万次/秒 |
| 定长池 | 18ns | 45ns | 40-60% | 5500万次/秒 |
| 变长池(本方案) | 22ns | 52ns | 5-10% | 4500万次/秒 |
可以看到,变长池比定长池慢了那么一点点(22ns vs 18ns),但内存碎片率从40-60%降到了5-10%。对于交易系统来说,这个trade-off非常值得。
总结一下:变长内存池不是银弹,但它解决了交易系统中「既要快又要省」的核心矛盾。分级桶的设计、零拷贝的整合、碎片控制的策略,这三者缺一不可。如果你正在设计交易系统的内存管理,不妨试试这个方案。
避坑指南:我曾经在某个版本里,把桶的粒度设得太细(比如每8字节一个桶),结果桶的数量达到了上百个。查找桶的时间从O(logN)变成了O(N),性能反而下降了。后来我学乖了,桶的数量控制在8-12个,每个桶覆盖一个数量级的大小范围,效果最好。