3. 内存分配策略:首次适应(First Fit)与最佳适应(Best Fit)算法

好,咱们继续。上一章我们把显存池的骨架搭好了——一个连续的大块内存,加上空闲链表来管理。但链表有了,怎么从里面挑一块合适的出来?这就是分配策略要解决的问题。

说白了,分配策略就是回答一个问题:当用户要一块 N 字节的内存时,我该从哪个空闲块下手?

我刚开始写显存池时,觉得这还不简单?从头遍历,找到第一个够大的就给它。后来发现,事情远没那么单纯。不同的选择策略,会直接影响内存碎片的多寡和分配效率。今天我们就来掰扯掰扯两种最经典的策略:首次适应(First Fit)和最佳适应(Best Fit)。

3.1 首次适应(First Fit)

首次适应的逻辑非常直白:从链表头开始,找到第一个大小 >= 请求大小的空闲块,就分配出去。

你想想看,这就像你去停车场找车位,看到第一个空位就停进去,不管它是在入口还是深处。简单粗暴,效率高。

3.1.1 算法流程

  1. 从空闲链表头部开始遍历
  2. 对每个空闲块,检查其大小是否 >= 请求大小
  3. 如果找到,从该块中切出所需大小,剩余部分作为新空闲块插回链表
  4. 如果遍历完都没找到,返回分配失败

3.1.2 代码实现

// 首次适应分配
void* first_fit_alloc(MemoryPool* pool, size_t size) {
    // 对齐到8字节(GPU内存通常要求对齐)
    size = align_up(size, 8);
    
    FreeBlock* curr = pool->free_list;
    FreeBlock* prev = NULL;
    
    while (curr != NULL) {
        if (curr->size >= size) {
            // 找到了!从当前块切分
            if (curr->size > size + sizeof(FreeBlock)) {
                // 剩余空间足够形成一个新的空闲块
                FreeBlock* new_block = (FreeBlock*)((char*)curr + sizeof(FreeBlock) + size);
                new_block->size = curr->size - size - sizeof(FreeBlock);
                new_block->next = curr->next;
                
                // 更新当前块大小(只分配需要的部分)
                curr->size = size;
                
                // 将新空闲块插入链表
                if (prev == NULL) {
                    pool->free_list = new_block;
                } else {
                    prev->next = new_block;
                }
            } else {
                // 剩余空间太小,整块分配出去
                if (prev == NULL) {
                    pool->free_list = curr->next;
                } else {
                    prev->next = curr->next;
                }
            }
            
            // 标记为已分配
            curr->next = NULL;
            return (void*)((char*)curr + sizeof(FreeBlock));
        }
        
        prev = curr;
        curr = curr->next;
    }
    
    // 没找到合适的块
    return NULL;
}

个人经验:我第一次实现时忘了处理「剩余空间太小」的情况。结果分配 64 字节时,一个 128 字节的块被切成了 64+64,但剩下的 64 字节因为不够放一个 FreeBlock 头(16 字节),导致链表指针错乱。嗯,后来我加了个阈值判断:剩余空间必须大于 sizeof(FreeBlock) + 最小分配单元,才允许切分。

3.1.3 优缺点分析

维度 评价
分配速度 快。通常很快就能找到合适的块,尤其是前面有小块时
内存利用率 一般。容易在链表头部产生大量小碎片
实现复杂度 低。逻辑简单,代码量少
适用场景 小块分配多、对延迟敏感的场景

3.2 最佳适应(Best Fit)

最佳适应就讲究多了。它的策略是:遍历整个空闲链表,找到大小 >= 请求大小,且最接近请求大小的那个块。

说白了,就是「量体裁衣」——找一块刚好够用的,尽量减少浪费。我早期做嵌入式系统时,内存只有 64KB,那时候最佳适应几乎是标配,因为每一字节都金贵。

3.2.1 算法流程

  1. 遍历整个空闲链表
  2. 记录所有大小 >= 请求大小的块
  3. 从中选出大小最小的那个(即最接近请求大小的)
  4. 从该块中切出所需大小,剩余部分插回链表

3.2.2 代码实现

// 最佳适应分配
void* best_fit_alloc(MemoryPool* pool, size_t size) {
    size = align_up(size, 8);
    
    FreeBlock* curr = pool->free_list;
    FreeBlock* best = NULL;
    FreeBlock* best_prev = NULL;
    FreeBlock* prev = NULL;
    size_t best_size = SIZE_MAX;  // 初始化为最大可能值
    
    // 第一遍:遍历找到最佳块
    while (curr != NULL) {
        if (curr->size >= size && curr->size < best_size) {
            best = curr;
            best_prev = prev;
            best_size = curr->size;
            
            // 如果找到完全匹配的,直接退出(不可能更好了)
            if (curr->size == size) {
                break;
            }
        }
        prev = curr;
        curr = curr->next;
    }
    
    if (best == NULL) {
        return NULL;  // 没找到合适的块
    }
    
    // 第二遍:从最佳块中切分(逻辑同首次适应)
    if (best->size > size + sizeof(FreeBlock)) {
        FreeBlock* new_block = (FreeBlock*)((char*)best + sizeof(FreeBlock) + size);
        new_block->size = best->size - size - sizeof(FreeBlock);
        new_block->next = best->next;
        
        best->size = size;
        
        if (best_prev == NULL) {
            pool->free_list = new_block;
        } else {
            best_prev->next = new_block;
        }
    } else {
        if (best_prev == NULL) {
            pool->free_list = best->next;
        } else {
            best_prev->next = best->next;
        }
    }
    
    best->next = NULL;
    return (void*)((char*)best + sizeof(FreeBlock));
}

注意:最佳适应需要遍历整个链表,时间复杂度是 O(n)。如果空闲块数量很大(比如几千个),每次分配都遍历一次,性能会急剧下降。我曾经在一个项目里,显存池的空闲块数量到了 5000+,最佳适应的分配延迟从微秒级飙升到毫秒级,直接导致帧率掉了一半。后来我换成了首次适应 + 定期整理,才把性能救回来。

3.2.3 优缺点分析

维度 评价
分配速度 慢。必须遍历整个链表
内存利用率 高。碎片更少,浪费更小
实现复杂度 中。需要记录最佳候选
适用场景 内存紧张、分配次数少的场景

3.3 两种策略的对比

为了让你更直观地理解,我画了一张图:

首次适应 vs 最佳适应 分配过程对比 初始空闲块: 32B 64B 128B 48B 请求分配 48 字节: 首次适应:找到第一个 >=48B 的块 → 块1(32B)不够,块2(64B)够 → 从块2切出48B 32B 48B(已分配) 16B 128B 48B 最佳适应:遍历所有块,找 >=48B 且最小的 → 块4(48B)完美匹配! 32B 64B 128B 48B(已分配) 首次适应产生了一个 16B 的小碎片(外部碎片) 最佳适应没有产生碎片,但需要遍历整个链表

3.4 如何选择?我的建议

说实话,没有银弹。两种策略各有千秋,我一般根据这几个因素来选:

  • 内存是否紧张? 如果显存只有 2GB,但应用要跑大模型,选最佳适应,省着点用。
  • 分配频率高不高? 如果每帧要分配/释放几百次,选首次适应,别让遍历拖垮性能。
  • 分配大小是否规律? 如果每次分配的大小都差不多(比如纹理、缓冲区大小固定),最佳适应反而容易产生碎片——因为每次都会留下一个很小的剩余块,久而久之全是小碎片。

我的经验法则:首次适应适合 80% 的场景。只有在内存极度紧张(比如嵌入式 GPU、老显卡)或者分配次数很少时,我才考虑最佳适应。而且,不管用哪种策略,定期整理碎片才是王道——这个我们后面会专门讲。

3.5 避坑指南

最后分享几个我踩过的坑:

  • 对齐问题:GPU 内存通常要求 256 字节或 512 字节对齐。我一开始没做对齐,结果分配出来的地址不对,驱动直接报错。记得在分配前做对齐处理。
  • 链表遍历性能:如果空闲块数量超过 1000,每次分配都遍历链表会非常慢。可以考虑用「伙伴系统」或者「分级空闲链表」来优化——这个我们后面章节会讲。
  • 并发安全:多线程分配时,别忘了加锁。我见过一个项目,两个线程同时分配,结果链表指针被改乱了,内存池直接崩了。最简单的做法是用互斥锁保护整个分配过程。

好了,分配策略就聊到这里。记住:首次适应求快,最佳适应求省。选哪个,取决于你的场景。下一章我们聊聊释放和合并——嗯,这部分其实比分配更有意思。