一、内存池设计原理:固定大小内存池、可变大小内存池、无锁内存池架构

做高频交易的朋友都知道,延迟就是金钱。我早年刚入行时,在一个CTA策略系统里用标准的malloc/free分配内存,结果回测时发现——每次分配都要几十微秒,甚至上百微秒。你想想看,一个微秒级别的交易信号,光内存分配就吃掉几十倍的时间,这还怎么玩?

所以,内存池这东西,说白了就是提前申请一大块内存,自己管理分配和回收。核心目标只有一个:避免系统调用,降低延迟抖动

1.1 固定大小内存池

这是最基础、也是最常用的内存池形式。我习惯叫它「Slab分配器」——虽然严格来说,Slab是Linux内核里的概念,但原理相通。

核心思路:预先分配一块连续内存,切成若干个大小相等的块(chunk)。每个chunk用链表串起来,分配时从链表头取一个,释放时再放回去。

适用场景:订单对象、成交回报、行情快照——这些对象大小固定,生命周期短,分配频繁。

来看一个简化版的实现:

class FixedPool {
private:
    struct Chunk {
        Chunk* next;  // 空闲链表指针
    };
    Chunk* free_list_;
    void*  pool_start_;
    size_t chunk_size_;
    size_t pool_size_;

public:
    FixedPool(size_t chunk_size, size_t chunk_count) 
        : chunk_size_(chunk_size), pool_size_(chunk_count) {
        // 一次性分配大块内存
        pool_start_ = std::aligned_alloc(64, chunk_size * chunk_count);
        
        // 初始化空闲链表
        free_list_ = static_cast<Chunk*>(pool_start_);
        for (size_t i = 0; i < chunk_count - 1; ++i) {
            auto* cur = reinterpret_cast<Chunk*>(
                reinterpret_cast<char*>(pool_start_) + i * chunk_size);
            cur->next = reinterpret_cast<Chunk*>(
                reinterpret_cast<char*>(pool_start_) + (i+1) * chunk_size);
        }
        // 最后一个chunk指向nullptr
        auto* last = reinterpret_cast<Chunk*>(
            reinterpret_cast<char*>(pool_start_) + (chunk_count-1) * chunk_size);
        last->next = nullptr;
    }

    void* allocate() {
        if (!free_list_) return nullptr;  // 内存耗尽
        Chunk* chunk = free_list_;
        free_list_ = free_list_->next;
        return chunk;
    }

    void deallocate(void* ptr) {
        auto* chunk = static_cast<Chunk*>(ptr);
        chunk->next = free_list_;
        free_list_ = chunk;
    }
};

嗯,这里要注意:chunk大小必须至少能容纳一个指针。否则空闲链表没法串起来。我在项目中遇到过有人把chunk设成4字节,结果链表指针都放不下,一跑就崩。

性能数据:固定大小内存池的分配/释放操作,通常只需要几条指令。在我的测试中,单线程下延迟稳定在5-10纳秒,而malloc平均在50-100纳秒,且抖动很大。

1.2 可变大小内存池

固定大小池子虽然快,但有个硬伤——所有对象必须一样大。实际场景中,消息长度、数据包大小往往不一样。这时候就需要可变大小内存池。

我早期做行情网关时,收到的数据包从几十字节到几千字节不等。一开始用固定池子,按最大包大小分配,结果内存浪费了80%以上。后来改成了可变大小池子,内存利用率从20%提升到了85%。

常见实现策略

  • 伙伴系统(Buddy System):把内存按2的幂次分成不同大小的块。分配时找最合适的块,如果找不到就分裂更大的块。释放时合并相邻的空闲块。
  • 多级Slab:维护多个固定大小池子,比如16B、32B、64B、128B……分配时向上取整到最近的池子。
  • 自由链表+大小分类:类似ptmalloc的做法,维护多个空闲链表,每个链表对应一个大小范围。

我个人比较推荐多级Slab方案。原因很简单:实现简单,性能稳定。伙伴系统虽然内存利用率高,但分裂合并操作有额外开销,在高频场景下容易引入延迟抖动。

// 多级Slab的简化示意
class MultiSlabPool {
    static constexpr size_t kMinSize = 16;
    static constexpr size_t kMaxSize = 4096;
    static constexpr size_t kLevels = 9;  // 16,32,64,128,256,512,1024,2048,4096
    
    FixedPool pools_[kLevels];
    
    size_t get_level(size_t size) {
        // 向上取整到2的幂次
        size_t level = 0;
        size_t sz = kMinSize;
        while (sz < size && level < kLevels - 1) {
            sz <<= 1;
            ++level;
        }
        return level;
    }
    
public:
    void* allocate(size_t size) {
        size_t level = get_level(size);
        return pools_[level].allocate();
    }
    
    void deallocate(void* ptr, size_t size) {
        size_t level = get_level(size);
        pools_[level].deallocate(ptr);
    }
};

注意:多级Slab有个问题——释放时必须知道原始大小。否则你没法确定该放回哪个池子。我建议在分配时把大小信息存到chunk头部,或者用外部映射表记录。

1.3 无锁内存池架构

到了多线程场景,事情就复杂了。传统内存池加锁保护,在高频交易里是灾难——锁竞争会导致线程阻塞,延迟瞬间飙升到微秒级。

我经历过一个惨痛教训:某次做期权做市系统,四个线程共享一个内存池,用了std::mutex保护。结果在订单洪峰时,锁竞争导致分配延迟从50纳秒暴涨到3微秒,直接错过了好几个套利机会。

从那以后,我对无锁内存池就特别上心。

无锁内存池的核心思想:用原子操作代替锁,保证线程安全。

常见方案有两种:

  1. 线程本地缓存(Thread Local Cache):每个线程有自己的小池子,用完才去全局池子取。分配时完全无锁,只有线程间均衡时才需要原子操作。
  2. 无锁空闲栈(Lock-Free Stack):用CAS(Compare-And-Swap)操作维护一个全局空闲链表。

我个人更倾向线程本地缓存方案。原因很简单:高频交易场景下,线程间共享数据越少越好。每个线程管好自己的内存,连CAS都不需要,延迟最可控。

来看一个无锁空闲栈的实现:

class LockFreePool {
private:
    struct Chunk {
        std::atomic<Chunk*> next;
    };
    std::atomic<Chunk*> free_list_;

public:
    void* allocate() {
        Chunk* head = free_list_.load(std::memory_order_acquire);
        while (head) {
            Chunk* next = head->next.load(std::memory_order_acquire);
            if (free_list_.compare_exchange_weak(head, next,
                std::memory_order_release, std::memory_order_acquire)) {
                return head;
            }
            // CAS失败,重试
        }
        return nullptr;  // 内存耗尽
    }

    void deallocate(void* ptr) {
        Chunk* chunk = static_cast<Chunk*>(ptr);
        Chunk* head = free_list_.load(std::memory_order_acquire);
        do {
            chunk->next.store(head, std::memory_order_release);
        } while (!free_list_.compare_exchange_weak(head, chunk,
            std::memory_order_release, std::memory_order_acquire));
    }
};

关键点:CAS操作要处理ABA问题。上面的代码里,如果chunk被释放后又重新分配,指针值可能不变但内容变了。我建议用带标签的指针(tagged pointer)或者引用计数来解决。

1.4 三种方案的对比与选择

特性 固定大小池 可变大小池 无锁池
分配延迟 5-10 ns 10-50 ns 10-30 ns
内存利用率 低(对象大小固定) 取决于底层实现
线程安全 需额外加锁 需额外加锁 原生支持
实现复杂度
适用场景 订单、行情快照 变长消息、数据包 多线程高频分配

怎么选?我一般遵循这个原则:

  • 单线程、对象大小固定:直接上固定大小池,简单高效。
  • 单线程、对象大小变化:多级Slab,兼顾速度和内存利用率。
  • 多线程、延迟敏感:线程本地缓存 + 固定大小池,或者无锁池。

避坑指南:我曾经在无锁池里踩过一个坑——释放时忘记把内存清零,结果下一个线程分配到了脏数据,导致订单价格算错。后来我养成了习惯:分配时用placement new构造对象,释放时显式调用析构函数。千万别依赖内存池帮你做清理。

1.5 整体架构图

下面这张图展示了三种内存池的核心架构,以及它们在高频交易系统里的位置:

内存池架构总览 固定大小内存池 空闲链表头 → Chunk1 → Chunk2 → ... 分配:取链表头 释放:放回链表头 可变大小内存池 16B池 → 32B池 → 64B池 → ... 分配:向上取整到最近池 释放:需知道原始大小 无锁内存池 线程本地缓存 CAS原子操作 无锁空闲栈 高频交易应用层 订单管理 | 行情处理 | 风控检查 | 策略引擎 选择建议 单线程固定对象 → 固定大小池 | 单线程变长对象 → 多级Slab | 多线程高并发 → 无锁池 延迟敏感度:无锁池 > 固定大小池 > 可变大小池 | 内存利用率:可变大小池 > 无锁池 > 固定大小池

这张图把三种池子的核心逻辑都画出来了。你看,固定大小池就是一条链表,简单粗暴;可变大小池是多条链表,按大小分类;无锁池则用原子操作替代了锁。实际项目中,我经常把三种方案组合使用——比如用线程本地缓存包装固定大小池,既无锁又高效。

好了,内存池的原理就讲到这里。下一节我们会深入具体实现细节,包括内存对齐、缓存行填充、以及如何用性能分析工具验证优化效果。这些都是在实战中真正能帮你省下几微秒的关键技术。


公众号:蓝海资料掘金营,微信deep3321