4、内存管理:避免动态内存分配、预分配策略、无锁队列(Lock-Free Queue)实现、内存屏障与原子操作
内存管理,说白了就是跟操作系统抢资源。在低延迟撮合引擎里,每一纳秒都可能是真金白银。我刚开始做量化系统时,总觉得内存分配不就是 new 和 delete 的事吗?直到有一次线上环境,撮合引擎在高峰期突然卡了 200 微秒——嗯,查到最后就是 malloc 惹的祸。
今天我们就来聊聊,怎么把内存这块「硬骨头」啃下来。
4.1 为什么不能动态分配?
动态内存分配,比如 new、malloc、std::vector::push_back,这些操作在普通应用里没啥问题。但在撮合引擎里,它们就是定时炸弹。
原因有三:
- 延迟不可控:
malloc内部可能触发系统调用,甚至发生缺页中断。一次分配可能耗时几微秒到几十微秒。在撮合引擎里,这时间够处理几百笔订单了。 - 内存碎片:频繁分配释放会导致堆内存碎片化。碎片多了,分配器需要花更多时间找合适的内存块。我曾经见过一个运行了 8 小时的撮合引擎,内存碎片率高达 40%,分配延迟从 50ns 飙升到 2μs。
- 锁竞争:标准分配器内部有锁。多线程同时分配时,锁竞争会进一步放大延迟。
核心原则:在撮合引擎的「热路径」(即每笔订单都要经过的代码路径)上,零动态内存分配。
4.2 预分配策略:把内存提前准备好
既然不能动态分配,那内存从哪来?答案是:提前分配,重复使用。
我个人习惯的做法是:
- 启动时一次性分配:根据系统容量估算,在初始化阶段把需要的内存全部申请好。比如订单池、成交记录池、行情缓冲区等。
- 使用内存池(Memory Pool):预分配一大块连续内存,然后按固定大小切分成「槽位」。需要时从池里取一个槽位,用完再还回去。整个过程没有系统调用,没有锁(如果设计得当)。
- 对象池(Object Pool):对于订单对象这种频繁创建销毁的场景,用对象池复用实例。避免构造/析构的开销。
举个例子,一个简单的固定大小内存池:
template<typename T, size_t Capacity>
class FixedPool {
alignas(64) char buffer[sizeof(T) * Capacity];
size_t free_list[Capacity];
size_t head;
public:
FixedPool() : head(0) {
for (size_t i = 0; i < Capacity; ++i)
free_list[i] = i;
}
T* allocate() {
if (head >= Capacity) return nullptr;
size_t idx = free_list[head++];
return reinterpret_cast<T*>(buffer + idx * sizeof(T));
}
void deallocate(T* ptr) {
size_t idx = (reinterpret_cast<char*>(ptr) - buffer) / sizeof(T);
free_list[--head] = idx;
}
};
避坑指南:我曾经在预分配时忽略了缓存行对齐。结果两个线程频繁操作相邻的槽位,导致严重的「伪共享」(False Sharing)问题。后来所有槽位都按 64 字节对齐,性能直接翻倍。
4.3 无锁队列(Lock-Free Queue)实现
撮合引擎里,多个线程需要传递数据——比如行情线程把订单推给撮合线程,撮合线程把成交结果推给风控线程。如果用互斥锁保护队列,延迟会高得离谱。
无锁队列,就是不用锁也能保证线程安全的数据结构。它的核心思想是:用原子操作代替锁。
一个经典的单生产者单消费者(SPSC)无锁队列:
template<typename T, size_t Capacity>
class SPSCQueue {
alignas(64) std::atomic<size_t> head_{0};
alignas(64) std::atomic<size_t> tail_{0};
alignas(64) T buffer_[Capacity];
public:
bool push(const T& item) {
size_t tail = tail_.load(std::memory_order_relaxed);
size_t next = (tail + 1) % Capacity;
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) % Capacity, std::memory_order_release);
return true;
}
};
你想想看,这个队列为什么不需要锁?因为生产者和消费者各自只操作一个原子变量。生产者写 tail_,消费者读 tail_;消费者写 head_,生产者读 head_。没有竞争点。
注意:多生产者多消费者(MPMC)的无锁队列要复杂得多。我建议先从 SPSC 开始,大部分撮合场景用 SPSC 就够了。如果非要上 MPMC,可以考虑用 boost::lockfree::queue 或者自己实现基于 CAS 的链表队列。
4.4 内存屏障与原子操作
无锁队列能工作的前提,是正确使用了内存屏障(Memory Barrier)和原子操作。说白了,就是告诉编译器和 CPU:「别给我乱优化,顺序很重要」。
C++11 提供了六种内存序:
| 内存序 | 含义 | 典型用途 |
|---|---|---|
memory_order_relaxed |
只保证原子性,不保证顺序 | 计数器、统计 |
memory_order_consume |
依赖关系排序(基本不用) | 很少用 |
memory_order_acquire |
之后的读写不能重排到之前 | 读锁、pop 操作 |
memory_order_release |
之前的读写不能重排到之后 | 写锁、push 操作 |
memory_order_acq_rel |
acquire + release | CAS 操作 |
memory_order_seq_cst |
全局顺序一致(最严格) | 默认值,但性能最差 |
在 SPSC 队列里,我们用了 acquire 和 release。为什么?
- push 时:先把数据写入
buffer_[tail],再用release更新tail_。这样消费者读到新的tail_时,一定能看到完整的数据。 - pop 时:先用
acquire读tail_,确保看到生产者写入的最新数据,然后再读buffer_[head]。
这就是所谓的「happens-before」关系。没有这些屏障,CPU 可能会乱序执行指令,导致消费者读到半成品数据。
个人经验:我见过有人把所有原子操作都写成 seq_cst,觉得这样最安全。结果性能比用锁还差。其实在 SPSC 场景下,acquire/release 就足够了。只有在需要全局顺序保证时,才用 seq_cst。
4.5 知识体系总览
下面这张图总结了本章的核心逻辑:
4.6 实战建议
说了这么多,落地时该怎么做?我给出几条具体建议:
- 先用工具检测:用
perf或Valgrind的massif工具,看看热路径上有没有动态分配。我见过很多号称「无分配」的系统,一查发现日志打印里偷偷调了std::string的构造函数。 - 从 SPSC 开始:无锁队列先实现 SPSC 版本,测试通过后再考虑 MPMC。不要一上来就搞复杂的。
- 内存序宁少勿多:能用
relaxed就别用acquire,能用acquire/release就别用seq_cst。每多一层屏障,就是多一条 CPU 指令的开销。 - 对齐很重要:原子变量和队列缓冲区都要按缓存行对齐(64 字节)。否则伪共享会让你怀疑人生。
一个小技巧:在开发阶段,可以在内存池的 allocate 函数里加一个统计计数器。如果发现分配次数超过预期,说明有地方漏了预分配。我曾经靠这个抓到一个在循环里 new 临时对象的 bug。
好了,内存管理这块就聊到这。下一章我们会深入「订单簿数据结构设计」,看看怎么用跳表、红黑树或者哈希表来高效管理订单。嗯,到时候再细聊。