4. C++低延迟编程基础:内存管理
做高频交易的朋友都知道,延迟就是金钱。你比别人慢一微秒,可能单子就成交在别人后面了。今天咱们聊聊C++低延迟编程里最基础、也最关键的一块——内存管理。
我个人习惯把内存管理比作「战场后勤」。你算法再牛,策略再强,如果内存这块拖后腿,一切都白搭。咱们从三个角度切入:栈 vs 堆、对象池、缓存友好设计,最后再聊无锁数据结构。
4.1 栈 vs 堆:谁更快?
先问个问题:你写代码时,变量是分配在栈上还是堆上?
栈分配,说白了就是编译器帮你打理。函数调用时压栈,返回时弹栈。整个过程就是移动一下栈指针,几纳秒的事。我刚开始做量化时,有个同事把所有订单对象都new在堆上,结果回测跑得巨慢。后来改成栈分配,速度直接翻倍。
堆分配呢?需要调用malloc或new,操作系统得找一块空闲内存,还可能触发缺页中断。你想想看,这中间差了多少个数量级?
核心结论:高频场景下,能用栈就别用堆。栈分配延迟通常在10纳秒以内,堆分配可能到几百纳秒甚至微秒级。
但现实是,有些对象生命周期长,或者大小不确定,必须用堆。这时候怎么办?
4.2 对象池:堆分配的救星
我曾经在一个做市商项目里遇到个坑:每秒要处理几十万笔订单,每个订单都new/delete,结果GC(垃圾回收)把性能拖垮了。嗯,这里要注意——C++虽然没有GC,但频繁的堆分配释放会导致内存碎片,分配器越来越慢。
解决方案就是对象池。说白了,提前分配好一大块内存,用的时候从池子里拿,用完放回去。没有malloc,没有free,只有指针的移动。
// 一个简单的对象池实现
template<typename T>
class ObjectPool {
private:
char* buffer; // 预分配的内存块
T** free_list; // 空闲对象指针数组
size_t pool_size;
size_t free_count;
public:
ObjectPool(size_t size) : pool_size(size), free_count(size) {
buffer = new char[size * sizeof(T)];
free_list = new T*[size];
for (size_t i = 0; i < size; ++i) {
free_list[i] = reinterpret_cast<T*>(buffer + i * sizeof(T));
}
}
T* acquire() {
if (free_count == 0) return nullptr;
return free_list[--free_count];
}
void release(T* obj) {
free_list[free_count++] = obj;
}
~ObjectPool() {
delete[] buffer;
delete[] free_list;
}
};
这个实现虽然简单,但已经能解决大部分问题了。我在实际项目中,对象池的分配速度基本和栈分配持平,都在10纳秒以内。
个人经验:对象池的大小要提前估算好。太小了会频繁扩容,太大了浪费内存。我一般按峰值流量的1.5倍来预分配。
4.3 缓存友好设计:Cache Line对齐与预取
你想想看,CPU从内存读数据有多慢?大概100纳秒。但从L1缓存读呢?不到1纳秒。差了100倍!所以,让数据待在缓存里,是低延迟编程的核心。
Cache Line是CPU缓存的最小单位,通常是64字节。如果两个线程修改同一个Cache Line里的不同变量,就会发生「伪共享」——明明没共享数据,却因为缓存一致性协议互相拖累。
我曾经调试过一个诡异的问题:两个线程各自处理自己的订单队列,性能就是上不去。后来用perf一看,L1缓存miss率高达30%。原因就是两个队列的头部指针恰好在一个Cache Line里。
解决方案很简单:Cache Line对齐。
// 使用alignas强制对齐到64字节
struct alignas(64) OrderQueue {
int head;
char padding[60]; // 填充到64字节
int tail;
};
另一个技巧是预取。如果你知道接下来要访问某个内存地址,可以提前告诉CPU把它加载到缓存里。
// 预取下一个订单
__builtin_prefetch(&orders[i + 8], 0, 3);
process(orders[i]);
这里的参数含义:0表示读预取,3表示高优先级。我习惯在遍历大数组时,提前8-16个元素做预取,效果很明显。
避坑指南:预取不是万能的。预取太早,数据可能被踢出缓存;预取太晚,来不及加载。我曾经在循环里每步都预取,结果反而变慢了。后来改成每4步预取一次,效果最好。
4.4 无锁数据结构:Lock-Free Queue
锁是低延迟的天敌。一个线程拿锁,其他线程就得等着。在高频交易里,这种等待可能就是丢单。
无锁数据结构,说白了就是利用CPU的原子指令(CAS、FAA等)来实现线程安全,不用操作系统级别的锁。我最早接触无锁队列是在一个行情分发系统里,那时候用互斥锁,延迟在微秒级。换成无锁队列后,直接降到几百纳秒。
下面是一个经典的无锁队列实现——Michael-Scott队列的简化版:
template<typename T>
class LockFreeQueue {
private:
struct Node {
T data;
std::atomic<Node*> next;
Node(const T& val) : data(val), next(nullptr) {}
};
std::atomic<Node*> head;
std::atomic<Node*> tail;
public:
LockFreeQueue() {
Node* dummy = new Node(T());
head.store(dummy);
tail.store(dummy);
}
void push(const T& val) {
Node* new_node = new Node(val);
Node* old_tail;
while (true) {
old_tail = tail.load();
Node* next = old_tail->next.load();
if (old_tail == tail.load()) {
if (next == nullptr) {
if (old_tail->next.compare_exchange_weak(next, new_node)) {
break;
}
} else {
tail.compare_exchange_weak(old_tail, next);
}
}
}
tail.compare_exchange_weak(old_tail, new_node);
}
bool pop(T& result) {
Node* old_head;
while (true) {
old_head = head.load();
Node* old_tail = tail.load();
Node* next = old_head->next.load();
if (old_head == head.load()) {
if (old_head == old_tail) {
if (next == nullptr) return false;
tail.compare_exchange_weak(old_tail, next);
} else {
result = next->data;
if (head.compare_exchange_weak(old_head, next)) {
break;
}
}
}
}
delete old_head;
return true;
}
};
这个实现的核心是CAS(Compare-And-Swap)操作。注意看push和pop里的while循环——这就是无锁编程的典型模式:不断尝试,直到成功。
关键点:无锁队列的ABA问题。简单说,就是指针被重用导致CAS误判。解决方案是使用带标签的指针(如std::atomic<uint64_t>,高32位存指针,低32位存版本号)。
我在实际项目中,无锁队列的延迟通常在50-100纳秒之间,远低于互斥锁的微秒级。但要注意,无锁编程调试起来非常痛苦。我曾经花了一周时间,才找到一个ABA问题导致的偶发崩溃。
4.5 知识体系总览
下面这张图,是我对本章知识结构的总结:
这张图把本章的核心内容串起来了。从左到右,从内存分配到缓存优化,再到无锁数据结构,每一步都是为了降低延迟。
最后说一句:这些技术不是孤立的。在实际项目中,我经常把对象池和无锁队列结合起来用——对象池管理内存,无锁队列管理任务。两者配合,延迟能压到极致。
嗯,今天就聊到这儿。这些基础打牢了,后面讲更高级的算法时,你才能游刃有余。