一、内存池设计原理:固定大小内存池、可变大小内存池、无锁内存池架构
做高频交易的朋友都知道,延迟就是金钱。我早年刚入行时,在一个CTA策略系统里用标准的malloc/free分配内存,结果回测时发现——每次分配都要几十微秒,甚至上百微秒。你想想看,一个微秒级别的交易信号,光内存分配就吃掉几十倍的时间,这还怎么玩?
所以,内存池这东西,说白了就是提前申请一大块内存,自己管理分配和回收。核心目标只有一个:避免系统调用,降低延迟抖动。
1.1 固定大小内存池
这是最基础、也是最常用的内存池形式。我习惯叫它「Slab分配器」——虽然严格来说,Slab是Linux内核里的概念,但原理相通。
核心思路:预先分配一块连续内存,切成若干个大小相等的块(chunk)。每个chunk用链表串起来,分配时从链表头取一个,释放时再放回去。
适用场景:订单对象、成交回报、行情快照——这些对象大小固定,生命周期短,分配频繁。
来看一个简化版的实现:
class FixedPool {
private:
struct Chunk {
Chunk* next; // 空闲链表指针
};
Chunk* free_list_;
void* pool_start_;
size_t chunk_size_;
size_t pool_size_;
public:
FixedPool(size_t chunk_size, size_t chunk_count)
: chunk_size_(chunk_size), pool_size_(chunk_count) {
// 一次性分配大块内存
pool_start_ = std::aligned_alloc(64, chunk_size * chunk_count);
// 初始化空闲链表
free_list_ = static_cast<Chunk*>(pool_start_);
for (size_t i = 0; i < chunk_count - 1; ++i) {
auto* cur = reinterpret_cast<Chunk*>(
reinterpret_cast<char*>(pool_start_) + i * chunk_size);
cur->next = reinterpret_cast<Chunk*>(
reinterpret_cast<char*>(pool_start_) + (i+1) * chunk_size);
}
// 最后一个chunk指向nullptr
auto* last = reinterpret_cast<Chunk*>(
reinterpret_cast<char*>(pool_start_) + (chunk_count-1) * chunk_size);
last->next = nullptr;
}
void* allocate() {
if (!free_list_) return nullptr; // 内存耗尽
Chunk* chunk = free_list_;
free_list_ = free_list_->next;
return chunk;
}
void deallocate(void* ptr) {
auto* chunk = static_cast<Chunk*>(ptr);
chunk->next = free_list_;
free_list_ = chunk;
}
};
嗯,这里要注意:chunk大小必须至少能容纳一个指针。否则空闲链表没法串起来。我在项目中遇到过有人把chunk设成4字节,结果链表指针都放不下,一跑就崩。
性能数据:固定大小内存池的分配/释放操作,通常只需要几条指令。在我的测试中,单线程下延迟稳定在5-10纳秒,而malloc平均在50-100纳秒,且抖动很大。
1.2 可变大小内存池
固定大小池子虽然快,但有个硬伤——所有对象必须一样大。实际场景中,消息长度、数据包大小往往不一样。这时候就需要可变大小内存池。
我早期做行情网关时,收到的数据包从几十字节到几千字节不等。一开始用固定池子,按最大包大小分配,结果内存浪费了80%以上。后来改成了可变大小池子,内存利用率从20%提升到了85%。
常见实现策略:
- 伙伴系统(Buddy System):把内存按2的幂次分成不同大小的块。分配时找最合适的块,如果找不到就分裂更大的块。释放时合并相邻的空闲块。
- 多级Slab:维护多个固定大小池子,比如16B、32B、64B、128B……分配时向上取整到最近的池子。
- 自由链表+大小分类:类似ptmalloc的做法,维护多个空闲链表,每个链表对应一个大小范围。
我个人比较推荐多级Slab方案。原因很简单:实现简单,性能稳定。伙伴系统虽然内存利用率高,但分裂合并操作有额外开销,在高频场景下容易引入延迟抖动。
// 多级Slab的简化示意
class MultiSlabPool {
static constexpr size_t kMinSize = 16;
static constexpr size_t kMaxSize = 4096;
static constexpr size_t kLevels = 9; // 16,32,64,128,256,512,1024,2048,4096
FixedPool pools_[kLevels];
size_t get_level(size_t size) {
// 向上取整到2的幂次
size_t level = 0;
size_t sz = kMinSize;
while (sz < size && level < kLevels - 1) {
sz <<= 1;
++level;
}
return level;
}
public:
void* allocate(size_t size) {
size_t level = get_level(size);
return pools_[level].allocate();
}
void deallocate(void* ptr, size_t size) {
size_t level = get_level(size);
pools_[level].deallocate(ptr);
}
};
注意:多级Slab有个问题——释放时必须知道原始大小。否则你没法确定该放回哪个池子。我建议在分配时把大小信息存到chunk头部,或者用外部映射表记录。
1.3 无锁内存池架构
到了多线程场景,事情就复杂了。传统内存池加锁保护,在高频交易里是灾难——锁竞争会导致线程阻塞,延迟瞬间飙升到微秒级。
我经历过一个惨痛教训:某次做期权做市系统,四个线程共享一个内存池,用了std::mutex保护。结果在订单洪峰时,锁竞争导致分配延迟从50纳秒暴涨到3微秒,直接错过了好几个套利机会。
从那以后,我对无锁内存池就特别上心。
无锁内存池的核心思想:用原子操作代替锁,保证线程安全。
常见方案有两种:
- 线程本地缓存(Thread Local Cache):每个线程有自己的小池子,用完才去全局池子取。分配时完全无锁,只有线程间均衡时才需要原子操作。
- 无锁空闲栈(Lock-Free Stack):用CAS(Compare-And-Swap)操作维护一个全局空闲链表。
我个人更倾向线程本地缓存方案。原因很简单:高频交易场景下,线程间共享数据越少越好。每个线程管好自己的内存,连CAS都不需要,延迟最可控。
来看一个无锁空闲栈的实现:
class LockFreePool {
private:
struct Chunk {
std::atomic<Chunk*> next;
};
std::atomic<Chunk*> free_list_;
public:
void* allocate() {
Chunk* head = free_list_.load(std::memory_order_acquire);
while (head) {
Chunk* next = head->next.load(std::memory_order_acquire);
if (free_list_.compare_exchange_weak(head, next,
std::memory_order_release, std::memory_order_acquire)) {
return head;
}
// CAS失败,重试
}
return nullptr; // 内存耗尽
}
void deallocate(void* ptr) {
Chunk* chunk = static_cast<Chunk*>(ptr);
Chunk* head = free_list_.load(std::memory_order_acquire);
do {
chunk->next.store(head, std::memory_order_release);
} while (!free_list_.compare_exchange_weak(head, chunk,
std::memory_order_release, std::memory_order_acquire));
}
};
关键点:CAS操作要处理ABA问题。上面的代码里,如果chunk被释放后又重新分配,指针值可能不变但内容变了。我建议用带标签的指针(tagged pointer)或者引用计数来解决。
1.4 三种方案的对比与选择
| 特性 | 固定大小池 | 可变大小池 | 无锁池 |
|---|---|---|---|
| 分配延迟 | 5-10 ns | 10-50 ns | 10-30 ns |
| 内存利用率 | 低(对象大小固定) | 高 | 取决于底层实现 |
| 线程安全 | 需额外加锁 | 需额外加锁 | 原生支持 |
| 实现复杂度 | 低 | 中 | 高 |
| 适用场景 | 订单、行情快照 | 变长消息、数据包 | 多线程高频分配 |
怎么选?我一般遵循这个原则:
- 单线程、对象大小固定:直接上固定大小池,简单高效。
- 单线程、对象大小变化:多级Slab,兼顾速度和内存利用率。
- 多线程、延迟敏感:线程本地缓存 + 固定大小池,或者无锁池。
避坑指南:我曾经在无锁池里踩过一个坑——释放时忘记把内存清零,结果下一个线程分配到了脏数据,导致订单价格算错。后来我养成了习惯:分配时用placement new构造对象,释放时显式调用析构函数。千万别依赖内存池帮你做清理。
1.5 整体架构图
下面这张图展示了三种内存池的核心架构,以及它们在高频交易系统里的位置:
这张图把三种池子的核心逻辑都画出来了。你看,固定大小池就是一条链表,简单粗暴;可变大小池是多条链表,按大小分类;无锁池则用原子操作替代了锁。实际项目中,我经常把三种方案组合使用——比如用线程本地缓存包装固定大小池,既无锁又高效。
好了,内存池的原理就讲到这里。下一节我们会深入具体实现细节,包括内存对齐、缓存行填充、以及如何用性能分析工具验证优化效果。这些都是在实战中真正能帮你省下几微秒的关键技术。
公众号:蓝海资料掘金营,微信deep3321