第三章 内存管理:从内存池到大页内存的实战之路

内存管理,说白了就是交易网关的命脉。我见过太多系统,算法写得再漂亮,一到高并发场景就卡在内存分配上。今天咱们就把这块硬骨头啃下来。

3.1 为什么内存管理如此重要?

你想想看,一个极速交易网关,每秒要处理几万笔订单。如果每笔订单都去调用 malloc/free,那性能损耗有多大?

我在项目中遇到过这样一个案例:某券商的生产环境,行情数据一来,系统延迟从 5 微秒直接飙升到 200 微秒。查了半天,发现是内存分配器在高并发下频繁加锁导致的。嗯,这就是典型的「内存分配瓶颈」。

内存管理的核心目标有三个:

  • 减少系统调用:避免频繁的 malloc/free
  • 避免内存碎片:防止长时间运行后内存利用率下降
  • 提高缓存命中率:让数据尽量靠近 CPU

3.2 内存池设计:从零开始造轮子

我个人习惯,在交易网关里一定会实现一个定制的内存池。为什么不用现成的?因为通用内存池要考虑各种场景,而我们只需要针对订单、行情、报单等固定大小的对象做优化。

3.2.1 固定大小内存池

最简单的内存池,就是预先分配一大块内存,切成固定大小的块。每次分配直接拿一块,释放就还回去。

// 一个简单的固定大小内存池
class FixedSizePool {
private:
    struct Block {
        Block* next;  // 空闲链表指针
    };
    
    Block* free_list_;
    char* pool_;
    size_t block_size_;
    size_t pool_size_;
    
public:
    FixedSizePool(size_t block_size, size_t block_count) 
        : block_size_(block_size), pool_size_(block_count) {
        // 一次性分配大块内存
        pool_ = new char[block_size * block_count];
        
        // 初始化空闲链表
        free_list_ = reinterpret_cast<Block*>(pool_);
        Block* current = free_list_;
        for (size_t i = 1; i < block_count; ++i) {
            current->next = reinterpret_cast<Block*>(pool_ + i * block_size);
            current = current->next;
        }
        current->next = nullptr;
    }
    
    void* allocate() {
        if (!free_list_) return nullptr;
        
        Block* block = free_list_;
        free_list_ = free_list_->next;
        return block;
    }
    
    void deallocate(void* ptr) {
        Block* block = reinterpret_cast<Block*>(ptr);
        block->next = free_list_;
        free_list_ = block;
    }
    
    ~FixedSizePool() {
        delete[] pool_;
    }
};

这个实现虽然简单,但已经能解决 90% 的问题了。分配和释放都是 O(1) 操作,没有锁竞争,没有系统调用。

我的经验: 实际项目中,我会把 block_size 对齐到 64 字节(缓存行大小),这样能避免伪共享问题。后面会详细讲。

3.2.2 多级内存池

交易网关里,不同对象的大小差异很大。订单可能 128 字节,行情快照可能 256 字节,报单回执可能 64 字节。怎么办?

我一般会设计一个多级内存池,按 2 的幂次划分大小:

class MultiLevelPool {
private:
    static const int MAX_LEVEL = 16;  // 最大支持 2^16 字节
    FixedSizePool* pools_[MAX_LEVEL];
    
    int get_level(size_t size) {
        // 计算需要的级别
        int level = 0;
        size_t s = 1;
        while (s < size) {
            s <<= 1;
            ++level;
        }
        return level;
    }
    
public:
    MultiLevelPool() {
        for (int i = 0; i < MAX_LEVEL; ++i) {
            size_t block_size = 1 << i;  // 2^i
            pools_[i] = new FixedSizePool(block_size, 1024);
        }
    }
    
    void* allocate(size_t size) {
        int level = get_level(size);
        return pools_[level]->allocate();
    }
    
    void deallocate(void* ptr, size_t size) {
        int level = get_level(size);
        pools_[level]->deallocate(ptr);
    }
};
注意: 多级内存池会有内部碎片。比如你申请 65 字节,实际分配 128 字节,浪费了 63 字节。这是空间换时间的典型 trade-off。

3.3 避免内存碎片:实战中的血泪教训

我曾经在一个高频交易系统里,连续运行了 72 小时后,内存碎片率达到了 40%。系统开始频繁分配失败,交易被迫中断。那次事故让我深刻认识到:内存碎片不是理论问题,是真会要命的。

内存碎片主要分两种:

  • 外部碎片:空闲内存总量够,但没有连续的大块
  • 内部碎片:分配的内存比实际需要的大

避免碎片的核心策略:

  1. 对象池化:所有对象从池中分配,不释放回系统
  2. 固定大小分配:避免大小不一的内存请求
  3. 批量回收:不是用完就释放,而是攒一批再回收

3.4 大页内存(HugePages):让 TLB 命中率飙升

为什么需要大页?

CPU 访问内存时,需要通过 TLB(页表缓存)把虚拟地址转成物理地址。默认的 4KB 页大小,意味着 2MB 的 L2 TLB 只能覆盖 512 个页。如果工作集超过 2MB,TLB 就会频繁 miss。

大页内存(2MB 或 1GB)能显著减少页表项数量。2MB 的大页,同样的 TLB 覆盖范围是原来的 512 倍。

3.4.1 配置大页内存

在 Linux 上配置大页:

# 查看当前大页配置
cat /proc/meminfo | grep Huge

# 配置 1024 个 2MB 的大页(总共 2GB)
echo 1024 > /proc/sys/vm/nr_hugepages

# 永久配置(在 /etc/sysctl.conf 中添加)
vm.nr_hugepages = 1024

3.4.2 在代码中使用大页

#include <sys/mman.h>
#include <fcntl.h>

void* allocate_huge_pages(size_t size) {
    // 使用 mmap 分配大页内存
    void* ptr = mmap(NULL, size, 
                     PROT_READ | PROT_WRITE,
                     MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB,
                     -1, 0);
    
    if (ptr == MAP_FAILED) {
        // 回退到普通内存
        ptr = malloc(size);
    }
    
    return ptr;
}

// 使用大页初始化内存池
class HugePagePool {
private:
    void* pool_;
    size_t pool_size_;
    
public:
    HugePagePool(size_t size) : pool_size_(size) {
        pool_ = allocate_huge_pages(size);
        if (!pool_) {
            throw std::bad_alloc();
        }
    }
    
    ~HugePagePool() {
        if (pool_) {
            munmap(pool_, pool_size_);
        }
    }
};
避坑指南: 我曾经在配置大页时,忘记设置 memlock 限制,导致 mmap 失败。记得在 /etc/security/limits.conf 中添加:
* soft memlock unlimited
* hard memlock unlimited

3.5 内存对齐与缓存行填充

现代 CPU 读取内存是以缓存行为单位的(通常是 64 字节)。如果两个线程频繁访问的数据在同一个缓存行里,就会产生「伪共享」问题——明明操作的是不同变量,却要互相等待缓存同步。

3.5.1 内存对齐

// 使用 alignas 指定对齐
struct alignas(64) CacheLineAligned {
    int32_t value;
    char padding[60];  // 填充到 64 字节
};

// 或者使用 C++17 的 std::hardware_destructive_interference_size
struct alignas(std::hardware_destructive_interference_size) 
    AtomicCounter {
    std::atomic<int64_t> counter;
};

3.5.2 缓存行填充实战

在交易网关中,每个订单的读写频率极高。如果多个订单的数据挤在一个缓存行里,性能会急剧下降。

// 订单结构体,每个订单独占一个缓存行
struct alignas(64) Order {
    int64_t order_id;
    int32_t price;
    int32_t quantity;
    int32_t status;
    char padding[40];  // 填充到 64 字节
};

// 订单数组,每个元素对齐到缓存行
Order orders[1024];  // 每个 Order 正好 64 字节
注意: 缓存行填充会浪费内存。如果订单数量很大(比如 100 万),每个订单浪费 40 字节,总共浪费 40MB。需要权衡。

3.6 知识体系总览

下面这张图总结了本章的核心内容:

交易网关内存管理知识体系 内存管理 内存池设计 避免内存碎片 大页内存 内存对齐与缓存 固定大小池 多级内存池 对象池化 外部碎片 内部碎片 批量回收 TLB 命中率 mmap 分配 memlock 配置 alignas 对齐 缓存行填充 伪共享避免 核心目标:减少延迟 + 提高吞吐 + 避免抖动 空间换时间,预分配代替动态分配

3.7 总结

内存管理这块,说白了就是「空间换时间」。预分配、池化、对齐、大页,每个技术都是在用内存空间换取执行效率。

我个人建议,在交易网关项目中,至少要做到:

  • 所有高频分配的对象都用内存池管理
  • 关键数据结构对齐到缓存行
  • 大页内存用于核心数据区
  • 定期监控内存碎片率

做到这几点,你的交易网关在内存方面就不会出大问题。嗯,今天就到这里。

专注资料整理