4、内存管理:避免动态内存分配、预分配策略、无锁队列(Lock-Free Queue)实现、内存屏障与原子操作

内存管理,说白了就是跟操作系统抢资源。在低延迟撮合引擎里,每一纳秒都可能是真金白银。我刚开始做量化系统时,总觉得内存分配不就是 newdelete 的事吗?直到有一次线上环境,撮合引擎在高峰期突然卡了 200 微秒——嗯,查到最后就是 malloc 惹的祸。

今天我们就来聊聊,怎么把内存这块「硬骨头」啃下来。

4.1 为什么不能动态分配?

动态内存分配,比如 newmallocstd::vector::push_back,这些操作在普通应用里没啥问题。但在撮合引擎里,它们就是定时炸弹。

原因有三:

  • 延迟不可控malloc 内部可能触发系统调用,甚至发生缺页中断。一次分配可能耗时几微秒到几十微秒。在撮合引擎里,这时间够处理几百笔订单了。
  • 内存碎片:频繁分配释放会导致堆内存碎片化。碎片多了,分配器需要花更多时间找合适的内存块。我曾经见过一个运行了 8 小时的撮合引擎,内存碎片率高达 40%,分配延迟从 50ns 飙升到 2μs。
  • 锁竞争:标准分配器内部有锁。多线程同时分配时,锁竞争会进一步放大延迟。

核心原则:在撮合引擎的「热路径」(即每笔订单都要经过的代码路径)上,零动态内存分配

4.2 预分配策略:把内存提前准备好

既然不能动态分配,那内存从哪来?答案是:提前分配,重复使用

我个人习惯的做法是:

  1. 启动时一次性分配:根据系统容量估算,在初始化阶段把需要的内存全部申请好。比如订单池、成交记录池、行情缓冲区等。
  2. 使用内存池(Memory Pool):预分配一大块连续内存,然后按固定大小切分成「槽位」。需要时从池里取一个槽位,用完再还回去。整个过程没有系统调用,没有锁(如果设计得当)。
  3. 对象池(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 队列里,我们用了 acquirerelease。为什么?

  • push 时:先把数据写入 buffer_[tail],再用 release 更新 tail_。这样消费者读到新的 tail_ 时,一定能看到完整的数据。
  • pop 时:先用 acquiretail_,确保看到生产者写入的最新数据,然后再读 buffer_[head]

这就是所谓的「happens-before」关系。没有这些屏障,CPU 可能会乱序执行指令,导致消费者读到半成品数据。

个人经验:我见过有人把所有原子操作都写成 seq_cst,觉得这样最安全。结果性能比用锁还差。其实在 SPSC 场景下,acquire/release 就足够了。只有在需要全局顺序保证时,才用 seq_cst

4.5 知识体系总览

下面这张图总结了本章的核心逻辑:

内存管理知识体系 内存管理 避免动态分配 延迟不可控 内存碎片 锁竞争 预分配策略 启动时一次性分配 内存池(Memory Pool) 对象池(Object Pool) 无锁队列 SPSC 队列 MPMC 队列 CAS 操作 内存屏障与原子操作 acquire/release relaxed seq_cst CAS 目标:热路径零动态分配 + 无锁通信 + 正确内存序

4.6 实战建议

说了这么多,落地时该怎么做?我给出几条具体建议:

  • 先用工具检测:用 perfValgrindmassif 工具,看看热路径上有没有动态分配。我见过很多号称「无分配」的系统,一查发现日志打印里偷偷调了 std::string 的构造函数。
  • 从 SPSC 开始:无锁队列先实现 SPSC 版本,测试通过后再考虑 MPMC。不要一上来就搞复杂的。
  • 内存序宁少勿多:能用 relaxed 就别用 acquire,能用 acquire/release 就别用 seq_cst。每多一层屏障,就是多一条 CPU 指令的开销。
  • 对齐很重要:原子变量和队列缓冲区都要按缓存行对齐(64 字节)。否则伪共享会让你怀疑人生。

一个小技巧:在开发阶段,可以在内存池的 allocate 函数里加一个统计计数器。如果发现分配次数超过预期,说明有地方漏了预分配。我曾经靠这个抓到一个在循环里 new 临时对象的 bug。

好了,内存管理这块就聊到这。下一章我们会深入「订单簿数据结构设计」,看看怎么用跳表、红黑树或者哈希表来高效管理订单。嗯,到时候再细聊。


专注资料整理