4. 内存池核心数据结构:空闲链表、伙伴系统、slab分配器、红黑树的选择与对比

好,咱们进入第四章。这一章我打算聊聊内存池的“骨架”——核心数据结构。

你想想看,一个内存池,说白了就是一块大内存,怎么把它切碎、怎么管理、怎么快速分配和回收?这背后全靠数据结构撑着。我这些年做GPU内存管理,见过太多因为选错数据结构导致性能翻车的案例。今天就把四种主流方案掰开揉碎了讲清楚。

4.1 空闲链表(Free List)——最朴素的方案

空闲链表,这玩意儿最简单。就是把所有空闲内存块串成一个链表。分配时从头遍历,找到大小合适的块;释放时再插回去。

核心思路: 用链表管理空闲块,简单粗暴。

// 一个简单的空闲链表节点
struct FreeBlock {
    size_t size;        // 块大小
    FreeBlock* next;    // 下一个空闲块
    // 实际数据区紧随其后
};

我在项目里见过有人直接用这个做小规模内存池。说实话,代码量确实少,但坑也不少。

  • 优点: 实现简单,插入和删除都是O(1)操作。
  • 缺点: 分配时要遍历链表找合适块,最坏情况O(n)。碎片化严重,容易产生大量无法利用的小碎片。

我的经验: 空闲链表适合分配大小固定的场景,或者作为其他数据结构的辅助。比如在slab分配器里,就用空闲链表管理同尺寸的对象。

4.2 伙伴系统(Buddy System)——对抗碎片的好手

伙伴系统,这个名字挺有意思。它把内存按2的幂次分成块,每次分配都找刚好能装下请求的最小块。如果找不到,就分裂更大的块。释放时,如果相邻的“伙伴”也是空闲的,就合并回去。

核心思路: 以2的幂次为粒度,分裂与合并,减少外部碎片。

// 伙伴系统的核心:维护多个空闲链表,每个链表管理一种大小的块
struct BuddySystem {
    FreeBlock* free_lists[MAX_ORDER]; // 每个order对应一种大小
    // order 0: 最小块 (比如 4KB)
    // order 1: 8KB
    // order 2: 16KB
    // ...
};

为什么会选2的幂次?因为这样分裂和合并的地址计算特别快,用位运算就行。我当年在优化一个GPU驱动时,就用的伙伴系统管理显存。嗯,这里要注意:伙伴系统内部碎片(分配了比实际需求大的块)是它的硬伤。

  • 优点: 分配和释放速度快(O(log N)),外部碎片少。
  • 缺点: 内部碎片最多可达50%(比如你只要3KB,它给你4KB的块)。

避坑指南: 我曾经在一个项目中,用伙伴系统管理小对象(几十字节),结果内部碎片率高达70%!后来改用slab分配器才解决问题。记住:伙伴系统适合管理大块内存(比如页级别),不适合小对象。

4.3 Slab分配器——小对象的救星

Slab分配器,这玩意儿在Linux内核里用得特别多。它的思路很巧妙:预先为每种常用大小的对象创建专属缓存(slab),每个slab里是一堆固定大小的对象。分配时直接从slab里拿一个,释放时放回去。

核心思路: 为每种大小创建专属缓存,消除内部碎片和外部碎片。

// Slab分配器的简化结构
struct SlabCache {
    size_t object_size;    // 对象大小
    FreeBlock* free_list;  // 空闲对象链表
    void* slab_memory;     // 实际内存区
    int num_objects;       // 对象总数
    int num_free;          // 空闲对象数
};

说白了,slab分配器就是“专物专用”。你想想看,如果频繁分配和释放同一大小的对象(比如网络包、文件描述符),用slab分配器再合适不过了。

  • 优点: 分配和释放都是O(1),无内部碎片,无外部碎片。
  • 缺点: 需要预先知道对象大小,不适合大小变化很大的场景。

我的习惯: 在CUDA内存池里,我会用slab分配器管理固定大小的临时缓冲区。比如线程块共享内存、寄存器溢出缓冲区,这些大小都是固定的,用slab分配器效率极高。

4.4 红黑树(Red-Black Tree)——平衡的艺术

红黑树,这玩意儿是平衡二叉搜索树的一种。用它管理内存池,通常是把空闲块按地址或大小组织成树。分配时,可以快速找到最合适的块(比如最佳适配、最差适配)。

核心思路: 用平衡树维护空闲块,支持快速查找、插入、删除。

// 红黑树节点
struct RBNode {
    size_t start_addr;  // 起始地址
    size_t size;        // 块大小
    RBNode* left;
    RBNode* right;
    bool is_red;        // 红黑树颜色标记
};

红黑树的好处是,所有操作都是O(log N)。但代价是代码复杂度高,而且每个节点需要额外的指针和颜色标记,内存开销不小。

  • 优点: 查找、插入、删除都是O(log N),支持多种分配策略(最佳适配、最差适配等)。
  • 缺点: 实现复杂,内存开销大(每个节点需要额外指针和颜色标记)。

我曾经踩过的坑: 在GPU上实现红黑树内存池,结果发现树旋转操作在GPU上性能很差,因为GPU的线程调度和分支预测对树结构不友好。后来我改用跳表(Skip List)替代红黑树,性能提升明显。记住:在GPU上,尽量用数组和索引代替指针和树结构。

4.5 四种方案对比总结

好了,四种方案都讲完了。咱们来做个对比,方便你选型。

特性 空闲链表 伙伴系统 Slab分配器 红黑树
分配时间复杂度 O(N) O(log N) O(1) O(log N)
释放时间复杂度 O(1) O(log N) O(1) O(log N)
内部碎片 高(最多50%)
外部碎片
实现复杂度
适用场景 固定大小对象、辅助结构 大块内存、页级别管理 固定大小小对象 通用、需要灵活分配策略
GPU友好度

我的建议: 实际项目中,很少只用一种数据结构。我通常的做法是:用伙伴系统管理大块内存(比如4KB以上),用slab分配器管理小对象(比如64B、128B、256B等),再用空闲链表作为slab内部的空闲对象管理。至于红黑树,除非你需要非常灵活的分配策略,否则在GPU上尽量别用。

嗯,这一章就到这里。下一章我会讲如何把这些数据结构组合起来,设计一个完整的CUDA内存池。到时候会给出具体的代码实现,敬请期待。