核心数据结构设计:空闲链表与块管理

好,咱们开始动手了。上一章我们聊了显存池的整体思路,说白了就是「先借一大块,再自己慢慢分」。那怎么分?用什么数据结构来记录哪些内存是空闲的、哪些被占用了?这就是本章要解决的核心问题。

我个人习惯,在设计任何内存管理器之前,先把数据结构想清楚。数据结构定好了,代码写起来就是水到渠成的事。你想想看,如果连「空闲块怎么存」都没想明白,后面那些分配、释放的逻辑根本没法写。

空闲链表(FreeList)的基本思想

最简单的方案,就是用链表把空闲块串起来。每个空闲块的开头放一个节点,节点里记录两个信息:这块内存的起始地址和这块内存的大小。然后把这些节点串成一个单向链表。

为什么用链表?因为显存分配和释放是动态的,频繁地创建和销毁块。链表天然支持 O(1) 的插入和删除,非常适合这种场景。数组的话,你想想看,中间插入一个元素要移动后面所有数据,那性能就炸了。

核心要点:空闲链表中的每个节点,代表一块连续的空闲显存。节点本身就存放在这块显存的开头位置——这叫「原地存储」,省去了额外的内存开销。

我在项目中遇到过一个问题:有人把节点数据单独用 malloc 分配在系统内存里。结果显存池本身管理的是 GPU 显存,节点却放在 CPU 内存里,每次分配释放都要跨 PCIe 总线去读写节点信息。那性能,惨不忍睹。所以记住:管理数据要跟被管理的数据放在同一个存储介质里。

块的数据结构定义

好,我们直接看代码。一个空闲链表节点长什么样?

// 空闲链表节点
struct FreeBlock {
    size_t size;        // 当前空闲块的大小(字节数)
    size_t offset;      // 当前空闲块在显存池中的偏移量
    FreeBlock* next;    // 指向下一个空闲块节点
};

嗯,这里要注意:offset 是相对于显存池基地址的偏移量,不是绝对地址。为什么用偏移量?因为显存池的基地址可能在运行时才能确定,用偏移量更灵活,也方便做地址重定位。

那分配出去的内存块呢?我们也需要一个头部信息,记录这块内存的大小和所有者信息。这样在释放时才能正确归还给空闲链表。

// 已分配块头部(位于每个分配块的最前面)
struct AllocHeader {
    size_t size;        // 用户数据区的大小
    uint32_t magic;     // 魔数,用于校验(防止野指针释放)
    AllocHeader* next;  // 指向下一个已分配块(用于遍历)
};

小技巧:魔数(magic number)是个好东西。我习惯用 0xDEADBEEF 或者 0xCAFEBABE 这种有特征的数值。释放时检查魔数,能快速发现「重复释放」或者「释放了不属于自己的内存」这类 bug。

空闲链表的初始化

显存池刚创建时,整块显存都是空闲的。所以空闲链表里只有一个节点,它代表整块显存。

void free_list_init(FreeBlock* head, size_t pool_size) {
    head->size   = pool_size - sizeof(FreeBlock);
    head->offset = sizeof(FreeBlock);  // 第一个节点占用了开头
    head->next   = nullptr;
}

注意看,offset 从 sizeof(FreeBlock) 开始。为什么?因为节点本身要占空间,这个节点就放在显存池的最开头。所以真正可用的空闲空间,要从节点后面开始算。

我曾经犯过一个错误:初始化时把 offset 设成了 0,结果分配出去的内存跟节点数据重叠了。调试了整整一个下午才找到原因。嗯,从那以后我每次初始化都会多看一眼偏移量的计算。

分配时的块分裂

当用户请求一块大小为 N 的显存时,我们需要在空闲链表里找到一块足够大的空闲块。如果找到的块比 N 大,就要把它分裂成两块:一块大小为 N 分配给用户,剩下的部分作为新的空闲块留在链表中。

这个过程用图来表示更清楚:

空闲块分裂示意图 分裂前: 空闲块(大小 = 640) ↓ 分配 200 字节 分裂后: 已分配块(大小 = 200) 剩余空闲块(大小 = 440) 分裂点 注意:已分配块头部(AllocHeader)也占用空间,实际用户可用 < 200

代码实现大致是这样的:

FreeBlock* split_block(FreeBlock* block, size_t request_size) {
    // 计算分裂后剩余块的大小
    size_t remaining = block->size - request_size - sizeof(AllocHeader);
    
    if (remaining < sizeof(FreeBlock)) {
        // 剩余空间太小,不够放一个空闲节点,干脆整块分配出去
        return nullptr;  // 表示不分裂
    }
    
    // 创建新的空闲块,放在已分配块的后面
    FreeBlock* new_block = (FreeBlock*)((char*)pool_base + block->offset 
                                        + sizeof(AllocHeader) + request_size);
    new_block->size   = remaining;
    new_block->offset = block->offset + sizeof(AllocHeader) + request_size;
    new_block->next   = block->next;
    
    // 更新当前块为已分配块
    block->size = request_size;
    block->next = (FreeBlock*)new_block;  // 这里复用 next 指针指向剩余块
    
    return new_block;
}

注意:分裂时一定要检查剩余空间是否足够放下一个 FreeBlock 节点。如果剩余空间太小,强行分裂会导致后续无法管理这块碎片。我见过有些实现直接忽略这个检查,结果碎片越积越多,最后整个池子废了。

释放时的块合并

释放操作比分配更麻烦。因为释放后,我们要把这块内存重新插入到空闲链表中。而且,如果它跟相邻的空闲块是连续的,还要合并成一块更大的空闲块——这叫「合并(coalescing)」。

为什么要合并?你想想看,如果不合并,频繁分配释放会产生大量小碎片。比如你分配了 A、B、C 三块,释放了 B 和 A,但 A 和 B 中间隔着一个 C,它们没法合并。等 C 也释放了,A、B、C 才能合并成一块大的。如果不做合并,这三块就会一直以三个小碎片的形式存在,后续大块分配就失败了。

合并的逻辑其实不复杂:

void free_and_merge(FreeBlock* freelist, AllocHeader* alloc) {
    // 1. 把已分配块转换成空闲块
    FreeBlock* freed = (FreeBlock*)alloc;
    freed->size   = alloc->size;
    freed->offset = /* 计算偏移量 */;
    
    // 2. 在空闲链表中找到合适的位置插入(按地址排序)
    FreeBlock* prev = nullptr;
    FreeBlock* curr = freelist;
    while (curr && curr->offset < freed->offset) {
        prev = curr;
        curr = curr->next;
    }
    
    // 3. 检查是否能与前一个块合并
    if (prev && prev->offset + prev->size == freed->offset) {
        prev->size += freed->size;
        freed = prev;  // 合并后的块以 prev 为准
    } else {
        // 不能合并,直接插入
        freed->next = curr;
        if (prev) prev->next = freed;
    }
    
    // 4. 检查是否能与后一个块合并
    if (curr && freed->offset + freed->size == curr->offset) {
        freed->size += curr->size;
        freed->next = curr->next;
    }
}

这段代码有个关键点:按地址排序插入。为什么?因为只有按地址排序,才能快速判断相邻块是否可以合并。如果不排序,每次释放都要遍历整个链表去检查相邻关系,那时间复杂度就变成 O(n²) 了。

空闲链表的遍历策略

分配时怎么选块?常见的有三种策略:

策略 描述 优点 缺点
First Fit 从头遍历,找到第一个足够大的块 速度快,简单 容易产生碎片
Best Fit 遍历所有块,找到最接近请求大小的块 碎片最少 速度慢,O(n) 遍历
Next Fit 从上一次分配的位置继续找 避免头部碎片堆积 实现稍复杂

我个人习惯在 GPU 显存池中用 First Fit。为什么?因为 GPU 显存分配通常是大块为主,First Fit 的碎片问题没那么严重。而且 GPU 程序对延迟敏感,Best Fit 的遍历开销有时候得不偿失。

不过,如果你的场景是频繁分配释放小块(比如每个线程分配几百字节),那 Best Fit 可能更合适。我在做某个深度学习框架的内存优化时,就遇到过这种情况——First Fit 导致碎片率高达 30%,换成 Best Fit 后降到了 5% 以下。

本章小结

核心数据结构就这么简单:一个空闲链表,每个节点记录偏移量和大小。分配时做块分裂,释放时做块合并。再配合合适的遍历策略,就能实现一个可用的显存池了。

当然,这只是最基础的版本。实际工程中还要考虑线程安全、对齐要求、大块内存的专门处理等等。但数据结构是根基,根基稳了,后面加功能就是锦上添花的事。

下一章我们会在这些数据结构的基础上,真正写出分配器和释放器的代码。到时候你会发现,只要数据结构设计得好,代码写起来就像填空一样简单。

一句话记住本章:空闲链表是显存池的骨架,块分裂和块合并是它的两个核心动作。设计时多花点心思在数据结构上,后面能省十倍的时间。

想要更多资料,联系可可