内存池设计:固定大小分配器、无锁内存池、线程本地缓存、伙伴系统

大家好,我是你们的讲师。今天我们来聊聊内存池设计。说实话,在低延迟交易系统里,内存管理是绕不开的硬骨头。标准库的 mallocnew 虽然方便,但它们的性能波动和锁竞争,在高频交易场景下简直就是灾难。我自己就踩过这个坑——有一次系统延迟突然飙升,排查了半天,最后发现是内存分配器在搞鬼。

所以,这一章我们专门来攻克内存池。我会从最简单的固定大小分配器讲起,再到无锁设计、线程本地缓存,最后是伙伴系统。每个方案都有它的适用场景,咱们一个一个来看。

1. 固定大小分配器:最朴素的方案

先说说固定大小分配器。说白了,就是预先分配一大块内存,然后切成固定大小的块。每次分配就取一个块,释放就还回去。这玩意儿实现起来特别简单,但效率极高。

我个人的习惯是,在交易引擎里,像订单、成交回报这类对象,它们的大小是固定的。用固定大小分配器,分配和释放都是 O(1) 的复杂度,而且没有内存碎片。

来看一个简单的实现:

class FixedAllocator {
    char* pool;
    size_t blockSize;
    size_t blockCount;
    void* freeList;  // 空闲链表头

public:
    FixedAllocator(size_t blockSize, size_t blockCount) 
        : blockSize(blockSize), blockCount(blockCount) {
        pool = (char*)malloc(blockSize * blockCount);
        // 初始化空闲链表
        freeList = pool;
        for (size_t i = 0; i < blockCount - 1; ++i) {
            char* current = pool + i * blockSize;
            char* next = pool + (i + 1) * blockSize;
            *(void**)current = next;
        }
        *(void**)(pool + (blockCount - 1) * blockSize) = nullptr;
    }

    void* allocate() {
        if (!freeList) return nullptr;
        void* ptr = freeList;
        freeList = *(void**)freeList;
        return ptr;
    }

    void deallocate(void* ptr) {
        *(void**)ptr = freeList;
        freeList = ptr;
    }
};

嗯,这里要注意一点:空闲链表里每个块的前几个字节被用来存放下一个块的地址。所以块大小至少得能放得下一个指针。我在项目中遇到过有人把块设成 4 字节,结果链表指针都存不下,直接崩了。

小提示:固定大小分配器最适合对象大小一致、生命周期短的场景。比如订单处理流水线里的临时对象,用这个就特别合适。

2. 无锁内存池:告别锁竞争

固定大小分配器虽然快,但如果有多个线程同时分配,还是得加锁。在高频交易里,锁的代价太大了。你想想看,一个锁争抢可能就让你的延迟从微秒级跳到毫秒级。

无锁内存池的核心思路,就是利用原子操作来管理空闲链表。我常用的方案是基于 CAS(Compare-And-Swap)的无锁栈。

class LockFreePool {
    struct Node {
        Node* next;
    };
    std::atomic<Node*> head;

public:
    LockFreePool() : head(nullptr) {}

    void* allocate() {
        Node* oldHead = head.load();
        while (oldHead) {
            if (head.compare_exchange_weak(oldHead, oldHead->next)) {
                return oldHead;
            }
        }
        return nullptr;
    }

    void deallocate(void* ptr) {
        Node* node = (Node*)ptr;
        Node* oldHead = head.load();
        do {
            node->next = oldHead;
        } while (!head.compare_exchange_weak(oldHead, node));
    }
};

这里有个坑,我必须要提醒你:ABA 问题。CAS 操作里,如果某个线程把 A 取出来,另一个线程又把 A 放回去,第一个线程的 CAS 还是会成功,但此时 A 的内容可能已经变了。解决办法?加一个版本号标签,或者用 hazard pointer。

避坑指南:我曾经在无锁队列上吃过 ABA 的亏,排查了整整两天。后来老老实实加了 64 位版本号标签,问题才解决。无锁编程,细节决定成败。

3. 线程本地缓存:减少跨线程交互

无锁方案虽然避免了锁,但原子操作本身还是有开销的。而且多个线程竞争同一个空闲链表,CAS 失败重试的次数会增多。这时候,线程本地缓存(Thread Local Cache)就派上用场了。

思路很简单:每个线程维护一个本地的小缓存。分配时先从本地拿,本地不够了再去全局池取一批。释放时先放回本地,本地满了再还给全局池。

我一般这样设计:

  • 每个线程有一个 thread_local 的缓存,容量大概 64 或 128 个块
  • 分配时,如果本地缓存不为空,直接取;否则从全局池批量取 32 个
  • 释放时,如果本地缓存未满,直接放回;否则把本地的一半还给全局池

这样做的好处是,大部分分配和释放操作都在线程内部完成,完全不需要同步。只有跨线程的批量操作才需要加锁或使用原子操作。

我记得有一次优化一个订单簿模块,用了线程本地缓存后,延迟直接降了 40%。效果立竿见影。

4. 伙伴系统:应对可变大小分配

前面讲的都是固定大小分配。但实际场景中,我们有时需要分配不同大小的内存块。比如网络缓冲区,消息大小可能从几十字节到几 KB 不等。这时候伙伴系统就登场了。

伙伴系统的核心思想是:把内存分成 2 的幂次大小的块。分配时,找到最小的满足需求的块;释放时,如果相邻的块也是空闲的,就合并成更大的块。

我画了一张图,帮你理解伙伴系统的结构:

64 KB(根块) 32 KB(伙伴 A) 32 KB(伙伴 B) 16 KB 16 KB 16 KB 16 KB 8K 8K 8K 8K 8K 8K 8K 8K

伙伴系统的实现要点:

  • 用数组管理不同大小的空闲链表,每个链表对应一种 2 的幂次大小
  • 分配时,从最小满足需求的链表取块;如果链表为空,从更大一级的链表分裂
  • 释放时,检查伙伴是否空闲,如果是则合并,递归向上

伙伴系统的优点是内存利用率高,外部碎片少。但缺点是内部碎片依然存在——比如你申请 10 KB,它给你 16 KB 的块,浪费了 6 KB。另外,合并操作可能比较耗时。

实战建议:在交易引擎里,我通常把伙伴系统用于网络缓冲区的管理。消息大小变化大,但生命周期短,伙伴系统正好能平衡灵活性和性能。

5. 四种方案的对比与选择

最后,我整理了一个表格,方便你对比这四种方案:

方案 分配/释放复杂度 线程安全 适用场景 缺点
固定大小分配器 O(1) 需加锁 对象大小固定、生命周期短 无法处理变长分配
无锁内存池 O(1)(CAS 可能重试) 无锁 多线程高频分配 ABA 问题、实现复杂
线程本地缓存 O(1)(本地操作) 线程局部 线程间隔离、减少竞争 内存占用略高
伙伴系统 O(log N) 需加锁 变长分配、内存利用率高 内部碎片、合并开销

说实话,没有银弹。实际项目中,我经常是组合使用:固定大小分配器处理订单对象,线程本地缓存减少竞争,伙伴系统处理网络缓冲区。每种方案各司其职,才能把性能压榨到极致。

好了,这一章的内容就到这里。内存池设计是低延迟系统的基石,希望你能动手实现一遍,踩踩坑,才能真正理解其中的门道。


专注资料整理