3. slab分配器:slab/slub/slob原理、kmalloc与kzalloc、内存池mempool

说到内核内存管理,slab分配器是个绕不开的话题。我刚开始接触内核时,总觉得伙伴系统已经够用了,为什么还要搞个slab出来?后来在项目中调一个网络驱动的性能瓶颈,才真正体会到——频繁分配和释放小对象时,伙伴系统的页分配开销太大了。说白了,slab就是为小对象量身定做的内存管理方案。

3.1 为什么需要slab分配器?

伙伴系统以页为单位管理内存,最小粒度是4KB(通常情况)。但内核里大量对象只有几十字节,比如task_struct、inode、dentry。你想想看,每次为了一个几百字节的结构体去分配一整页,浪费太严重了。

另外,频繁的分配释放会导致内存碎片化。伙伴系统虽然能合并连续页,但对小对象无能为力。slab分配器解决了两个核心问题:

  • 减少碎片:相同大小的对象放在一起,不会打乱伙伴系统的页
  • 提高效率:对象缓存、着色、每CPU缓存,分配速度极快

核心思想:slab分配器把内存划分为多个"缓存"(cache),每个缓存专门管理一种固定大小的对象。分配时直接从缓存中取,释放时放回缓存,避免了频繁调用伙伴系统。

3.2 slab / slub / slob 三兄弟

内核里其实有三种slab实现:slab、slub、slob。我最早接触的是slab,后来发现新内核默认都用slub了。它们各有千秋,我简单说说区别。

特性 slab slub slob
适用场景 大型系统 大型系统(默认) 小型嵌入式系统
代码复杂度 中等
每CPU缓存 有(简化版)
调试能力 中等
内存开销 较大 较小 最小

3.2.1 slab——老牌分配器

slab是经典的实现,结构比较复杂。它把每个缓存分成多个slab,每个slab是一块连续内存(通常是一页或多页)。slab内部又分成多个对象槽位。我记得在调试一个内存泄漏问题时,slab的调试功能帮了大忙——它可以在对象释放时检查是否被越界访问。

slab有个"着色"(coloring)的概念,目的是让不同slab中的对象在cache line上错开,减少缓存冲突。嗯,这个优化在早期CPU上效果明显,现在倒没那么重要了。

3.2.2 slub——当前默认

slub是slab的简化版,从2.6.22开始成为默认分配器。它去掉了复杂的队列管理和着色,把slab结构直接嵌入到空闲对象中。说白了,就是"化繁为简"。

我个人习惯在新项目里直接用slub,除非有特殊需求。它的代码量只有slab的三分之一,但性能几乎一样,甚至在某些场景下更好。我曾经在ARM平台上对比过,slub的启动时间比slab快了约15%。

3.2.3 slob——嵌入式首选

slob是给极度内存受限的系统准备的。它基于"首次适应"算法,把所有空闲对象串成一个链表。分配时从头遍历,找到第一个足够大的块。

slob的代码只有几百行,但性能也最差。我在一个只有4MB内存的IoT设备上用过slob,虽然慢了点,但能跑起来就是胜利。如果你在做资源极度受限的嵌入式系统,可以考虑它。

选型建议:一般嵌入式Linux直接用slub就好。只有内存小于8MB时才考虑slob。slab基本可以退休了,除非你需要它的高级调试功能。

3.3 kmalloc 与 kzalloc

kmalloc是内核里最常用的内存分配函数,底层就是基于slab/slub实现的。它的原型是:

void *kmalloc(size_t size, gfp_t flags);

kzalloc相当于kmalloc + memset(0),分配的同时把内存清零:

void *kzalloc(size_t size, gfp_t flags);

我建议:能用kzalloc就别用kmalloc。为什么?因为不清零的内存里可能有敏感数据,比如之前存放的密码、密钥。我曾经在review代码时发现一个驱动用kmalloc分配了缓冲区,没清零就直接传给用户态——这妥妥的信息泄露漏洞。

另外,flags参数也很关键。常用的有:

  • GFP_KERNEL:普通分配,可以睡眠。进程上下文中用
  • GFP_ATOMIC:原子分配,不会睡眠。中断上下文、自旋锁内用
  • GFP_DMA:分配DMA可访问的内存(低端内存)

注意:在中断处理函数里千万别用GFP_KERNEL,否则会导致内核崩溃。我见过一个新手写的网卡驱动,在中断里用kmalloc(GFP_KERNEL),结果系统跑几分钟就死机了。

3.4 内存池 mempool

mempool是slab之上的一层封装,用于"保证分配不会失败"。它预先分配一批对象放在池子里,当正常分配失败时,就从池子里取。等释放时再还回去。

你可能会问:既然有slab了,为什么还要mempool?嗯,有些场景下分配失败是致命的。比如块设备驱动在回收内存时,如果分配失败会导致死锁——因为回收操作本身需要分配内存。mempool就是解决这种"鸡蛋相生"的问题。

使用mempool的典型流程:

// 创建内存池,min_nr是最小保留数
mempool_t *mempool_create(int min_nr, 
                          mempool_alloc_t *alloc_fn,
                          mempool_free_t *free_fn,
                          void *pool_data);

// 分配对象(优先从slab分配,失败则从池中取)
void *mempool_alloc(mempool_t *pool, gfp_t gfp_mask);

// 释放对象(放回slab,如果池中不足则保留)
void mempool_free(void *element, mempool_t *pool);

// 销毁内存池
void mempool_destroy(mempool_t *pool);

我曾在开发一个NVMe驱动时用过mempool。NVMe的提交队列需要预分配请求对象,如果分配失败,整个I/O路径就卡死了。用mempool保底,至少保证关键路径不会因为内存不足而崩溃。

避坑指南:mempool不是万能的。它只是"最后一道防线",不能滥用。每个mempool会占用大量内存(min_nr个对象),如果系统中到处都是mempool,内存压力反而更大。我曾经见过一个系统里创建了20多个mempool,每个保留100个对象,结果光这些池子就占了几十MB内存。

3.5 知识体系总览

下面这张图展示了slab分配器、kmalloc/kzalloc、mempool之间的关系:

slab分配器知识体系 伙伴系统 (Buddy) slab分配器 slab | slub (默认) | slob kmalloc / kzalloc mempool (内存池) 三种实现 slab/slub/slob 常用API GFP_KERNEL等 保底机制 避免分配失败 从伙伴系统 → slab分配器 → kmalloc/kzalloc → mempool 层层封装,各司其职

3.6 实战建议

最后,我总结几条实战经验:

  1. 优先用kzalloc:安全、省心,性能损失几乎可以忽略
  2. 注意上下文:中断里用GFP_ATOMIC,进程里用GFP_KERNEL
  3. mempool别滥用:只在关键路径上使用,min_nr设小一点
  4. 调试时开slub_debug:可以检测越界、重复释放等问题
  5. 嵌入式选slub:除非内存极小,否则别碰slob

嗯,slab分配器就聊到这里。记住一句话:小对象用slab,大对象用伙伴系统,关键路径用mempool保底。这个原则在我多年的内核开发中从未失手过。


专注资料整理