内存布局与性能基础

做高频交易引擎,说白了就是跟纳秒级别较劲。我刚开始接触这个领域时,总觉得算法牛逼就完事了。直到有一次,一个看似简单的数据结构改动,让整个系统的延迟直接降了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能最快地拿到它。别小看这些细节,在高频交易的世界里,每一个纳秒都值钱。


专注资料整理