内存布局与性能基础
做高频交易引擎,说白了就是跟纳秒级别较劲。我刚开始接触这个领域时,总觉得算法牛逼就完事了。直到有一次,一个看似简单的数据结构改动,让整个系统的延迟直接降了40%。嗯,那次经历让我彻底明白了——内存布局,才是真正的性能基石。
基本数据类型的内存占用与对齐
先聊聊最基础的东西。C++里的基本数据类型,每个都有固定大小。但你真的了解它们在内存里是怎么排布的吗?
| 类型 | 32位系统 | 64位系统 | 对齐要求 |
|---|---|---|---|
| char | 1字节 | 1字节 | 1字节 |
| short | 2字节 | 2字节 | 2字节 |
| int | 4字节 | 4字节 | 4字节 |
| long | 4字节 | 8字节 | 8字节 |
| double | 8字节 | 8字节 | 8字节 |
对齐是什么意思?CPU读取内存不是按字节来的,它一次读4字节或8字节。如果数据没对齐,CPU就得读两次,再拼起来。你想想看,高频交易里每笔订单都在抢时间,多一次内存读取,可能就慢了几个纳秒。
核心原则:结构体成员按对齐要求最大的那个成员来对齐。总大小必须是对齐值的整数倍。
举个例子:
struct Order {
char type; // 1字节
// 这里会填充3字节
int price; // 4字节
short volume; // 2字节
// 这里会填充6字节
double timestamp; // 8字节
};
// sizeof(Order) = 24,而不是1+4+2+8=15
我建议你写代码时,把大的成员往前放。这样能减少填充,节省内存。别小看这几字节,在百万级订单流里,差距就出来了。
缓存行与伪共享
现代CPU不是直接跟内存打交道的。它有个缓存系统,分L1、L2、L3三级。其中L1最快,但容量最小,一般32KB。缓存的最小单位叫缓存行(Cache Line),通常是64字节。
为什么会这样设计?因为局部性原理。你访问了一个地址,附近的数据很可能也会被用到。CPU干脆一次加载64字节,省得来回跑。
但这里有个坑——伪共享(False Sharing)。
伪共享:两个线程各自操作不同的变量,但这两个变量恰好在同一个缓存行里。结果每次修改都会导致对方的缓存行失效,性能直接崩盘。
我在项目中遇到过这个坑。当时写了一个订单簿,用两个线程分别处理买单和卖单。数据结构是这样的:
struct OrderBook {
int bid_price; // 线程A操作
int bid_volume; // 线程A操作
int ask_price; // 线程B操作
int ask_volume; // 线程B操作
};
看起来没问题对吧?但bid和ask在同一个缓存行里。线程A改bid,线程B的缓存行就失效了。线程B改ask,线程A的缓存行又失效了。两个线程互相拖后腿,性能还不如单线程。
怎么解决?加填充,让它们不在同一个缓存行里:
struct OrderBook {
int bid_price;
int bid_volume;
char padding[56]; // 填充到64字节
int ask_price;
int ask_volume;
};
std::hardware_destructive_interference_size
C++17引入了一个好东西——std::hardware_destructive_interference_size。它直接告诉你当前CPU的缓存行大小,省得你硬编码64。
#include <new>
struct alignas(std::hardware_destructive_interference_size) OrderBook {
int bid_price;
int bid_volume;
char padding[std::hardware_destructive_interference_size - 2 * sizeof(int)];
int ask_price;
int ask_volume;
};
我个人习惯用这个常量,而不是写死64。因为不同CPU的缓存行大小可能不一样,比如有些ARM芯片是128字节。写死的话,换平台就出问题了。
小技巧:用alignas让整个结构体按缓存行对齐,这样能保证结构体起始地址也是对齐的。我曾经因为没注意起始地址对齐,排查了整整两天。
内存池的简单实现
高频交易里,频繁new/delete是大忌。为什么?因为malloc和free是系统调用,慢得要命。而且会导致内存碎片,分配越来越慢。
解决方案就是内存池。提前申请一大块内存,自己管理分配和释放。
最简单的实现——固定大小对象池:
template<typename T>
class MemoryPool {
private:
union Slot {
T data;
Slot* next; // 空闲链表指针
};
Slot* free_list_;
std::vector<Slot*> blocks_;
public:
MemoryPool() : free_list_(nullptr) {}
~MemoryPool() {
for (auto block : blocks_) {
::operator delete(block);
}
}
void* allocate() {
if (!free_list_) {
// 一次申请64个对象
const size_t block_size = sizeof(Slot) * 64;
Slot* block = static_cast<Slot*>(::operator new(block_size));
blocks_.push_back(block);
// 构建空闲链表
for (size_t i = 0; i < 64 - 1; ++i) {
block[i].next = &block[i + 1];
}
block[63].next = nullptr;
free_list_ = block;
}
Slot* slot = free_list_;
free_list_ = slot->next;
return slot;
}
void deallocate(void* ptr) {
Slot* slot = static_cast<Slot*>(ptr);
slot->next = free_list_;
free_list_ = slot;
}
};
这个实现的核心思路:
- 用union让Slot既能存数据,又能存指针
- 空闲对象用链表串起来,分配时取头节点
- 一次申请多个对象,减少系统调用次数
- 释放只是把对象放回链表,不真正还给系统
我曾经用这个模式处理订单对象。每秒几十万笔订单,如果用new/delete,光内存分配就占了30%的CPU时间。换成内存池后,这部分开销几乎降到了零。
性能对比:在我的测试中,内存池分配比malloc快10-20倍。释放更是快了几百倍,因为只是指针操作。
当然,这个实现有个限制——只能分配固定大小的对象。如果要支持不同大小,就得用更复杂的伙伴系统或slab分配器。但对于高频交易来说,大部分场景都是固定大小的消息或订单,这个简单实现完全够用。
嗯,内存布局这块,说白了就是让数据在合适的位置,让CPU能最快地拿到它。别小看这些细节,在高频交易的世界里,每一个纳秒都值钱。