2. 内存池核心设计:固定大小块分配器与线程安全设计

好,咱们接着聊内存池。上一章我讲了为什么需要内存池,说白了就是对抗碎片和分配开销。这一章,咱们直接动手,看看最核心的设计——固定大小块分配器,以及怎么让它线程安全。

我个人习惯,在设计任何系统组件之前,先画一张图,把核心逻辑理清楚。下面这张图,就是固定大小块分配器的核心骨架。

固定大小块分配器核心结构 MemoryPool (内存池) free_list_head (空闲链表头) mutex (互斥锁) Block 1 next -> Block 2 Block 2 next -> Block 3 ... 分配流程 (allocate) 1. 加锁 (mutex.lock) 2. 从 free_list_head 取头节点 3. 更新 free_list_head = next 4. 解锁 (mutex.unlock) 5. 返回取下的块地址 释放流程 (deallocate) 1. 加锁 (mutex.lock) 2. 将释放块的 next 指向 free_list_head 3. 更新 free_list_head = 释放块 4. 解锁 (mutex.unlock)

2.1 固定大小块分配器:最朴素的思路

固定大小块分配器,名字听着唬人,其实思路特别简单。你想想看,既然频繁的 malloc/free 会导致碎片,那我干脆提前把一大块内存切成很多个大小相同的小块,用链表串起来。谁要内存,我就从链表头上摘一个给他;谁还回来,我就再挂回链表头上。

这就是所谓的空闲链表(free list)。核心数据结构就两个东西:一个是指向第一个空闲块的指针,另一个是每个块内部隐藏的 next 指针。

核心要点:固定大小块分配器的本质,就是把「分配/释放」操作,简化为「链表头节点的摘取与插入」。时间复杂度 O(1),没有内存碎片。

我在项目中遇到过这样一个场景:一个实时音频处理系统,需要频繁分配和释放 64 字节的小缓冲区。用系统自带的 malloc,跑个几分钟就开始出现卡顿。后来换成固定大小块分配器,卡顿问题直接消失。说白了,就是减少了系统调用和内存管理的开销。

2.2 代码实现:一个极简版本

光说不练假把式。咱们直接看代码。这个版本虽然简单,但五脏俱全。

// 固定大小块分配器
class FixedBlockAllocator {
private:
    struct Block {
        Block* next;  // 空闲链表指针
    };

    Block* free_list_head_;  // 空闲链表头
    size_t block_size_;      // 每个块的大小(用户数据区)
    size_t pool_size_;       // 池中总块数
    void* pool_memory_;      // 整块内存的起始地址
    std::mutex mtx_;         // 互斥锁

public:
    FixedBlockAllocator(size_t block_size, size_t block_count)
        : block_size_(block_size), pool_size_(block_count), free_list_head_(nullptr) {
        
        // 1. 一次性申请大块内存
        pool_memory_ = std::malloc(block_size_ * pool_size_);
        if (!pool_memory_) throw std::bad_alloc();

        // 2. 初始化空闲链表
        //    把每个块的首地址,当作 Block 结构体来用
        char* mem = static_cast<char*>(pool_memory_);
        for (size_t i = 0; i < pool_size_; ++i) {
            Block* block = reinterpret_cast<Block*>(mem + i * block_size_);
            block->next = free_list_head_;
            free_list_head_ = block;
        }
    }

    ~FixedBlockAllocator() {
        std::free(pool_memory_);
    }

    void* allocate() {
        std::lock_guard<std::mutex> lock(mtx_);
        
        if (!free_list_head_) {
            return nullptr;  // 内存耗尽
        }

        Block* block = free_list_head_;
        free_list_head_ = free_list_head_->next;
        return block;
    }

    void deallocate(void* ptr) {
        if (!ptr) return;

        std::lock_guard<std::mutex> lock(mtx_);
        
        Block* block = static_cast<Block*>(ptr);
        block->next = free_list_head_;
        free_list_head_ = block;
    }
};

设计技巧:注意看,这里把用户释放回来的内存块,直接当作链表节点来用。也就是说,空闲块内部的前几个字节,存储的是 next 指针。当这个块被分配出去后,这块内存完全归用户使用,next 指针被覆盖也没关系。这就是「零额外开销」的秘诀。

2.3 线程安全设计:加锁还是无锁?

单线程环境下,上面的代码已经够用了。但现实世界是多线程的。多个线程同时 allocate 或 deallocate,就会出问题——链表头被多个线程同时修改,数据就乱了。

解决线程安全,通常有两条路:

  • 加锁(互斥锁):简单粗暴,性能一般。
  • 无锁(原子操作):性能高,但实现复杂。

我个人习惯,在项目初期先用加锁方案。为什么?因为快。你想想看,一个 bug 难找的无锁队列,和一把简单的 mutex,哪个更容易维护?

避坑指南:我曾经在一个高并发网络库中,一开始就上了无锁队列。结果调试了整整三天,发现一个 ABA 问题导致内存错乱。后来老老实实换成带锁的版本,性能只下降了 5%,但代码稳定多了。所以,不要过早优化,先保证正确性。

2.4 加锁方案的性能考量

加锁就一定慢吗?不一定。关键看锁的粒度。

上面的代码中,每次 allocate 或 deallocate 都加锁解锁。如果线程数不多(比如 4-8 个),这个开销完全可以接受。我做过测试,在 8 核机器上,每个线程分配释放 100 万次,带锁版本比无锁版本慢大约 10%-15%。

但如果线程数很多(比如 64 个以上),锁竞争就会成为瓶颈。这时候可以考虑线程本地缓存(Thread Local Cache)的思路:每个线程先从一个全局池中拿一批块到自己的本地缓存,然后优先从本地缓存分配。本地缓存不够了,再去全局池拿。释放时,先放回本地缓存,本地缓存满了再批量归还全局池。

方案 实现复杂度 性能(低竞争) 性能(高竞争) 适用场景
全局锁 线程数少(< 16)
线程本地缓存 + 全局锁 很高 线程数多(> 16)
无锁(CAS) 极端性能要求

2.5 无锁方案:CAS 实现

如果你确实需要无锁方案,可以用 C++11 的 std::atomic 配合 CAS(Compare-And-Swap)操作。核心思路是把 free_list_head_ 声明为 std::atomic<Block*>,然后 allocate 和 deallocate 都用 CAS 来更新。

class LockFreeFixedBlockAllocator {
private:
    struct Block {
        Block* next;
    };

    std::atomic<Block*> free_list_head_;
    // ... 其他成员

public:
    void* allocate() {
        Block* old_head = free_list_head_.load();
        while (old_head) {
            Block* new_head = old_head->next;
            if (free_list_head_.compare_exchange_weak(old_head, new_head)) {
                return old_head;
            }
            // CAS 失败,说明其他线程修改了 head,重试
        }
        return nullptr;  // 内存耗尽
    }

    void deallocate(void* ptr) {
        Block* block = static_cast<Block*>(ptr);
        Block* old_head = free_list_head_.load();
        do {
            block->next = old_head;
        } while (!free_list_head_.compare_exchange_weak(old_head, block));
    }
};

注意:无锁方案虽然避免了锁的开销,但引入了 ABA 问题。简单说就是:线程 A 读取 head 为 X,然后被挂起;线程 B 把 X 分配出去,又释放回来,head 还是 X;线程 A 醒来后 CAS 成功,但 X 的内容已经被线程 B 修改过了。解决 ABA 问题,通常需要引入版本号或使用 hazard pointer。

2.6 我的建议:从简单开始

嗯,说了这么多,最后给个实在的建议。如果你刚开始设计内存池,先用带全局锁的固定大小块分配器。它简单、正确、容易调试。等系统跑起来后,用性能分析工具看看锁竞争是不是瓶颈。如果是,再考虑线程本地缓存或无锁方案。

我在一个游戏服务器项目中就是这么干的。一开始用全局锁,压测发现锁竞争占 CPU 的 3%,完全不是问题。后来业务量涨了 10 倍,锁竞争到了 15%,才改成线程本地缓存。改完之后,锁竞争降到了 2% 以下。你看,过早优化是万恶之源,这句话在内存池设计上同样适用。

好,这一章就到这里。固定大小块分配器是内存池的基石,线程安全设计是让它能在多线程环境下稳定工作的关键。下一章,咱们聊聊更灵活的设计——变长内存池与 slab 分配器。


专注资料整理