内存管理:内存池设计、无锁数据结构、CPU缓存优化
各位同学,今天我们来聊聊高频交易中间件里最硬核的部分——内存管理。
说实话,我见过太多团队,业务逻辑写得飞起,一到延迟测试就崩了。为什么?内存管理没做好。在高频交易的世界里,每一次内存分配、每一次数据拷贝,都是真金白银的延迟。今天我把这些年踩过的坑和积累的经验,一次性讲清楚。
一、内存池设计:告别动态分配
先问一个问题:你写代码时,new/delete 或者 malloc/free 用得多吗?
在高频交易里,这是大忌。动态内存分配是延迟的万恶之源。为什么?因为 malloc 背后是系统调用,是锁,是内存碎片。我曾在项目中遇到过,一个简单的订单处理流程,因为频繁 malloc,延迟从 5 微秒飙到了 50 微秒。嗯,这谁受得了?
解决方案就是内存池。说白了,就是提前申请一大块内存,自己管理。
1.1 固定大小内存池
我个人习惯用固定大小的内存池。比如订单对象是 64 字节,我就预先分配 10 万个槽位。每个槽位用链表串起来。
// 固定大小内存池示例
class FixedPool {
private:
struct Slot {
Slot* next; // 空闲链表指针
char data[64 - sizeof(Slot*)]; // 实际数据
};
Slot* free_list_;
char* pool_;
size_t pool_size_;
public:
FixedPool(size_t count) {
pool_size_ = count * sizeof(Slot);
pool_ = (char*)aligned_alloc(64, pool_size_);
// 初始化空闲链表
free_list_ = (Slot*)pool_;
for (size_t i = 0; i < count - 1; ++i) {
((Slot*)(pool_ + i * sizeof(Slot)))->next =
(Slot*)(pool_ + (i + 1) * sizeof(Slot));
}
((Slot*)(pool_ + (count - 1) * sizeof(Slot)))->next = nullptr;
}
void* allocate() {
Slot* slot = free_list_;
free_list_ = free_list_->next;
return slot->data;
}
void deallocate(void* ptr) {
Slot* slot = (Slot*)((char*)ptr - offsetof(Slot, data));
slot->next = free_list_;
free_list_ = slot;
}
};
你看,allocate 和 deallocate 就是几个指针操作,没有系统调用,没有锁。延迟稳定在 10 纳秒以内。
1.2 变长内存池
固定大小池子够用吗?大部分场景够。但有些消息长度不固定,比如 FIX 协议的消息。这时候需要变长内存池。
我常用的做法是:按 64 字节对齐,把内存切成不同大小的块。比如 64、128、256、512 字节。每个大小一个池子。分配时向上取整到最近的块大小。
二、无锁数据结构:别让锁成为瓶颈
锁,在高频交易里是另一个敌人。为什么?因为锁会导致线程阻塞、上下文切换、缓存失效。我见过一个场景,两个线程抢一个锁,延迟从 1 微秒变成 100 微秒。这还怎么做高频交易?
无锁数据结构,说白了就是利用 CPU 的原子指令,比如 CAS(Compare-And-Swap),来实现线程安全,不用锁。
2.1 无锁队列
最经典的是无锁环形队列。一个生产者,一个消费者,连 CAS 都不需要。
// 单生产者单消费者无锁队列
template<typename T, size_t N>
class SPSCQueue {
private:
T buffer_[N];
std::atomic<size_t> head_{0};
std::atomic<size_t> tail_{0};
char padding_[64 - sizeof(std::atomic<size_t>)];
public:
bool push(const T& item) {
size_t tail = tail_.load(std::memory_order_relaxed);
size_t next = (tail + 1) % N;
if (next == head_.load(std::memory_order_acquire)) {
return false; // 队列满
}
buffer_[tail] = item;
tail_.store(next, std::memory_order_release);
return true;
}
bool pop(T& item) {
size_t head = head_.load(std::memory_order_relaxed);
if (head == tail_.load(std::memory_order_acquire)) {
return false; // 队列空
}
item = buffer_[head];
head_.store((head + 1) % N, std::memory_order_release);
return true;
}
};
你看,没有锁,没有 CAS,只有原子变量的 load/store。延迟在 20 纳秒以内。为什么这么快?因为只有一个生产者和一个消费者,不存在竞争。
2.2 无锁内存池
前面讲的内存池,如果多线程同时 allocate,怎么办?加锁?不行。用无锁链表。
// 无锁内存池(基于 CAS)
class LockFreePool {
private:
struct Slot {
std::atomic<Slot*> next;
char data[64 - sizeof(std::atomic<Slot*>)];
};
std::atomic<Slot*> free_list_;
public:
void* allocate() {
Slot* slot;
do {
slot = free_list_.load(std::memory_order_acquire);
if (!slot) return nullptr;
} while (!free_list_.compare_exchange_weak(
slot, slot->next.load(std::memory_order_relaxed),
std::memory_order_release, std::memory_order_acquire));
return slot->data;
}
void deallocate(void* ptr) {
Slot* slot = (Slot*)((char*)ptr - offsetof(Slot, data));
Slot* head;
do {
head = free_list_.load(std::memory_order_acquire);
slot->next.store(head, std::memory_order_relaxed);
} while (!free_list_.compare_exchange_weak(
head, slot, std::memory_order_release, std::memory_order_acquire));
}
};
这里用了 CAS 循环。在高竞争下,CAS 可能会失败多次。但平均延迟仍然在 50 纳秒以内,比锁好一个数量级。
三、CPU缓存优化:让数据离CPU更近
你想想看,CPU 主频 3GHz,内存访问延迟 100 纳秒,差了 300 个时钟周期。如果数据在 L1 Cache 里,只要 1 纳秒。这差距,就是天堂和地狱。
高频交易里,延迟就是生命。所以必须让数据尽量留在 Cache 里。
3.1 Cache Line 对齐
CPU 从内存读数据,一次读 64 字节,这就是一个 Cache Line。如果两个线程操作的数据在同一个 Cache Line 上,就会互相干扰,这叫 False Sharing。
我见过一个血淋淋的例子:两个线程各自更新一个计数器,计数器在同一个结构体里。结果性能从 1000 万 QPS 掉到 200 万 QPS。为什么?因为每次更新都导致对方的 Cache Line 失效。
解决方案:把每个线程的数据放到不同的 Cache Line 上。
// 错误示范:两个计数器在同一个 Cache Line
struct BadCounters {
uint64_t counter1;
uint64_t counter2;
};
// 正确示范:用 padding 隔开
struct GoodCounters {
uint64_t counter1;
char padding1[64 - sizeof(uint64_t)];
uint64_t counter2;
char padding2[64 - sizeof(uint64_t)];
};
或者用 C++11 的 alignas 关键字:
struct alignas(64) AlignedCounters {
uint64_t counter1;
uint64_t counter2;
};
3.2 数据布局优化
除了对齐,数据布局也很重要。高频交易里,经常需要遍历大量订单。如果订单对象分散在内存里,Cache Miss 会很高。
我常用的技巧是:把热点数据放在一起。比如订单的 ID、价格、数量,这些经常一起访问的字段,放在结构体的开头。不常用的字段,比如订单来源、备注,放在后面。
// 优化前:热点数据分散
struct Order {
char source[16]; // 不常用
uint64_t id; // 常用
double price; // 常用
uint32_t quantity; // 常用
char note[32]; // 不常用
};
// 优化后:热点数据集中
struct Order {
uint64_t id; // 常用
double price; // 常用
uint32_t quantity; // 常用
char source[16]; // 不常用
char note[32]; // 不常用
};
你看,只是调整了字段顺序,Cache 命中率就能从 60% 提升到 90%。
3.3 预取指令
有时候,数据访问模式是确定的。比如遍历链表。这时候可以用 CPU 的预取指令,提前把数据加载到 Cache 里。
// 手动预取
void process_list(Node* head) {
while (head) {
__builtin_prefetch(head->next, 0, 3); // 预取下一个节点
process(head->data);
head = head->next;
}
}
__builtin_prefetch 的三个参数:地址、读写模式(0 读,1 写)、时间局部性(0-3,3 表示高局部性)。
四、实战:一个完整的内存管理方案
说了这么多,我们来整合一下。一个典型的高频交易中间件,内存管理方案是这样的:
- 启动时:预分配所有内存。订单池、消息池、事件池,全部用固定大小内存池。
- 运行时:每个线程有自己的本地缓存。分配优先从本地拿,不够才去全局池。
- 数据结构:队列用 SPSC 无锁队列,内存池用无锁链表。
- 数据布局:所有结构体 64 字节对齐,热点数据集中,padding 防 False Sharing。
- 访问模式:顺序访问用预取,随机访问用哈希表(布隆过滤器先过滤)。
这套方案,我在一个订单路由系统里用过。延迟从平均 10 微秒降到了 1.2 微秒。嗯,效果立竿见影。
这张图把今天的内容串起来了。你看,三大模块环环相扣。内存池解决动态分配问题,无锁数据结构解决锁问题,Cache 优化解决内存访问延迟问题。三者缺一不可。
好了,今天就讲到这里。记住,在高频交易的世界里,每一纳秒都值得争取。把这些技术用起来,你的系统就能跑在别人前面。