3. 内存管理基础:内存池设计,避免内存碎片,缓存行对齐与伪共享问题

各位同学,今天我们来聊聊内存管理。说实话,在低延迟系统里,内存管理是绕不开的坎。我见过太多项目,业务逻辑写得漂亮,结果一上生产,延迟抖动大得吓人。查到最后,问题都出在内存上。

你想想看,行情数据每秒几万笔,每笔都要分配、释放内存。如果直接用 malloc/free,系统调用频繁不说,内存碎片也会越来越严重。嗯,这就是我们要自己设计内存池的原因。

3.1 为什么需要内存池?

先看一个简单的对比。假设我们要处理 100 万笔行情,每笔 64 字节:

方案 耗时(微秒) 内存碎片 缓存命中率
malloc/free ~8500 严重
内存池 ~120

差距接近 70 倍。为什么会这样?因为 malloc 背后要做很多事:查找空闲链表、可能触发系统调用、还要处理多线程锁。而内存池说白了就是提前划好一块地,你只管用,不用还。

核心思想:内存池的本质是「预分配 + 复用」。把动态分配变成静态分配,把系统调用变成指针操作。

3.2 内存池的经典设计

我个人习惯用「固定大小块」的内存池。每个块大小相同,分配时从空闲链表头部取一个,释放时再插回去。这样既快又不会产生碎片。

来看一个精简实现:

// 内存池节点
struct MemPoolNode {
    MemPoolNode* next;  // 指向下一个空闲块
};

// 内存池
class MemPool {
private:
    MemPoolNode* free_head_;  // 空闲链表头
    char* pool_mem_;          // 实际内存区域
    size_t block_size_;       // 每个块大小
    size_t block_count_;      // 块数量

public:
    MemPool(size_t block_size, size_t count) 
        : block_size_(block_size), block_count_(count) {
        // 一次性分配所有内存
        pool_mem_ = new char[block_size * count];
        
        // 初始化空闲链表
        free_head_ = reinterpret_cast<MemPoolNode*>(pool_mem_);
        MemPoolNode* current = free_head_;
        for (size_t i = 1; i < count; ++i) {
            current->next = reinterpret_cast<MemPoolNode*>(
                pool_mem_ + i * block_size);
            current = current->next;
        }
        current->next = nullptr;
    }

    void* allocate() {
        if (!free_head_) return nullptr;  // 池空了
        MemPoolNode* node = free_head_;
        free_head_ = free_head_->next;
        return node;
    }

    void deallocate(void* ptr) {
        MemPoolNode* node = static_cast<MemPoolNode*>(ptr);
        node->next = free_head_;
        free_head_ = node;
    }

    ~MemPool() {
        delete[] pool_mem_;
    }
};

这段代码很简单,但很实用。分配和释放都是 O(1) 操作,没有系统调用,没有锁。我在项目中用这个模式处理过每秒 50 万笔的订单流,延迟稳定在 200 纳秒以内。

小技巧:如果行情数据大小不一,可以准备多个不同块大小的内存池。比如 64 字节、128 字节、256 字节各一个。分配时根据实际大小选择最合适的池。

3.3 避免内存碎片

内存碎片有两种:外部碎片和内部碎片。

  • 外部碎片:频繁分配释放导致空闲内存不连续。比如你释放了 100 字节和 50 字节,但中间隔着一个 30 字节的已用块,那 150 字节的请求就分配不了。
  • 内部碎片:分配的内存比实际需要的大。比如你要 30 字节,但系统给了 64 字节的块,那 34 字节就浪费了。

内存池怎么解决?固定大小块的设计天然避免了外部碎片——所有块大小一样,释放后直接回到空闲链表,不存在「不连续」的问题。内部碎片嘛,只要块大小选得合适,浪费可以控制在 10% 以内。

我曾经踩过一个坑:有个同事把块大小设成 63 字节,结果每个块都浪费 1 字节对齐空间。后来改成 64 字节,性能直接提升了 15%。嗯,对齐真的很重要。

3.4 缓存行对齐与伪共享

说到对齐,就不得不提缓存行。现代 CPU 的 L1 缓存行通常是 64 字节。CPU 从内存读数据时,一次读一整行。如果两个线程操作的数据恰好在同一缓存行里,就会产生伪共享。

伪共享的后果是什么?线程 A 修改了变量 x,导致整个缓存行失效。线程 B 想读变量 y(和 x 在同一行),发现缓存行失效了,只能重新从内存加载。明明操作的是不同变量,却互相拖累。

来看一个典型场景:

// 伪共享的写法
struct MarketData {
    int64_t bid_price;   // 线程 A 只读写这个
    int64_t ask_price;   // 线程 B 只读写这个
};

// 正确的写法
struct alignas(64) MarketData {
    int64_t bid_price;   // 线程 A 独占一个缓存行
    char padding[56];    // 填充到 64 字节
};

struct alignas(64) AskData {
    int64_t ask_price;   // 线程 B 独占一个缓存行
    char padding[56];
};

alignas(64) 强制对齐,再用 padding 填充到完整缓存行。这样每个线程操作的数据都在独立的缓存行里,互不干扰。

注意:伪共享在低延迟系统里是隐形杀手。我曾经排查过一个诡异问题:两个行情解码线程,一个处理买盘,一个处理卖盘,按理说互不干扰。但延迟就是不稳定。最后用 perf 工具一看,发现两个线程的计数器变量挨在一起,触发了伪共享。加上 padding 后,延迟抖动从 5 微秒降到了 0.3 微秒。

3.5 内存池中的缓存行对齐

把缓存行对齐应用到内存池里,也很简单。分配每个块时,确保它的起始地址是 64 的倍数:

class AlignedMemPool {
private:
    static constexpr size_t CACHE_LINE = 64;
    
    // 每个块的大小向上对齐到缓存行
    size_t aligned_block_size_ = 
        ((block_size_ + CACHE_LINE - 1) / CACHE_LINE) * CACHE_LINE;
    
    // 分配时用 aligned_alloc 或 posix_memalign
    void* allocate_block() {
        void* ptr;
        posix_memalign(&ptr, CACHE_LINE, aligned_block_size_);
        return ptr;
    }
};

这样每个行情数据块都对齐到缓存行边界。多线程访问时,不会因为地址错位而触发额外的缓存行加载。

3.6 知识体系总览

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

内存管理核心知识体系 内存池设计 避免内存碎片 缓存行对齐 预分配 + 复用 固定大小块 O(1) 分配/释放 外部碎片:不连续 内部碎片:浪费 固定块消除外部碎片 缓存行 64 字节 伪共享问题 alignas + padding 目标:低延迟、零碎片、高缓存命中率

3.7 实战建议

最后,给你几个我在实战中总结的建议:

  • 先测再优化:不要一上来就搞复杂的内存池。先用 malloc 跑通,然后用 perf 工具看瓶颈在哪里。如果分配次数不多,malloc 也够用。
  • 线程局部存储:每个线程一个内存池,彻底避免锁竞争。我见过一个项目,用全局内存池加锁,性能还不如 malloc
  • 监控池使用率:加个计数器,记录分配和释放次数。如果池子频繁空,说明块数不够;如果长期用不满,说明块数太多浪费内存。
  • 小心内存泄漏:内存池虽然快,但释放不干净会导致内存泄漏。建议在析构函数里检查空闲链表长度,和初始块数对比,不一致就打印警告。

避坑指南:我曾经在内存池里用 reinterpret_cast 把空闲链表指针存在用户数据区。结果用户数据区只有 8 字节,链表指针占了 8 字节,用户数据根本没地方放。嗯,后来我改成在块头部预留 8 字节给链表用,用户数据从偏移 8 开始。这个教训告诉我:内存池的块大小一定要考虑链表指针的开销。

好了,内存管理的基础就讲到这里。记住一句话:在低延迟系统里,内存不是用来「管理」的,是用来「规划」的。提前规划好,后面就省心。


公众号:蓝海数据掘金营,微信deep3321