4、SLAB分配器:SLAB/SLUB/SLOB的区别、kmalloc与kmem_cache原理、对象复用机制
4.1 为什么需要SLAB分配器?
讲内存回收,绕不开SLAB分配器。说白了,内核里最频繁的操作之一就是分配和释放小对象——比如一个task_struct、一个inode、一个socket buffer。每次都用页分配器(伙伴系统)去搞,那效率低得没法看。
我刚开始看内核源码时,就纳闷:为什么不能像用户态malloc那样直接搞?后来踩了坑才明白——内核里的小对象分配,频率高、对象小、生命周期短。伙伴系统最小单位是4KB的页,你分配一个64字节的task_struct,剩下的3999字节全浪费了。这还不算,频繁的分配释放会导致严重的内存碎片。
SLAB分配器就是来解决这个问题的。它预先把大块内存切成固定大小的“对象池”,你要小对象,直接从池子里拿,用完放回去。嗯,有点像图书馆的储物柜——你不需要每次去造一个新柜子。
核心思想:SLAB分配器是伙伴系统和内核小对象分配之间的“中间层”。它把伙伴系统的大块内存(通常是2^N个页)切成多个相同大小的对象,实现快速分配和回收。
4.2 SLAB、SLUB、SLOB:三兄弟的恩怨
Linux内核里其实有三种SLAB实现:SLAB、SLUB、SLOB。我当年刚接触时也懵——为什么搞三个?后来在项目中调优内存性能,才真正体会到它们的差异。
| 特性 | SLAB | SLUB | SLOB |
|---|---|---|---|
| 设计年代 | 1994年(老前辈) | 2007年(现代主力) | 2008年(极简主义) |
| 核心数据结构 | per-CPU缓存 + 三色链表 | per-CPU slab + 链表 | 简单链表 + 首次适配 |
| 内存开销 | 较高(元数据多) | 较低(精简元数据) | 极低(几乎无元数据) |
| 适用场景 | 大型NUMA系统 | 通用场景(默认) | 嵌入式/小内存系统 |
| 调试能力 | 强(红区、毒药模式) | 中等 | 弱 |
4.2.1 SLAB:老当益壮
SLAB是祖师爷级别的实现。它把每个缓存(kmem_cache)分成多个slab,每个slab是一块连续内存。slab内部有三种状态:满、部分满、空。分配时优先从部分满的slab里拿,释放时如果slab变空了就还给伙伴系统。
我个人觉得SLAB最大的特点是per-CPU缓存——每个CPU有自己的对象缓存,分配时不需要加锁。这在多核系统上性能很好。但代价是元数据复杂,内存开销大。我记得在某个项目中,光SLAB的元数据就占了上百MB,对于嵌入式设备来说简直是灾难。
4.2.2 SLUB:现代主力
SLUB是现在内核的默认选择。它简化了SLAB的复杂结构,去掉了per-CPU缓存,改用更轻量的per-CPU slab指针。说白了,就是把原来SLAB里那些花里胡哨的队列、颜色、批量操作全砍了,只保留最核心的功能。
为什么会这样?因为现代CPU的L1/L2缓存越来越大,SLUB直接利用硬件缓存来加速对象分配,反而比SLAB的软件per-CPU缓存更高效。我在调优数据库服务器时,把SLAB换成SLUB,内存占用直接降了30%,性能还略有提升。
我的经验:除非你的系统是大型NUMA架构(比如几十个CPU),否则直接用SLUB就好。SLAB在NUMA上有更好的亲和性优化,但普通服务器用SLUB更省心。
4.2.3 SLOB:极简主义
SLOB(Simple List Of Blocks)是给嵌入式系统准备的。它不维护复杂的缓存结构,而是用简单的首次适配算法——把内存块串成链表,分配时从头遍历找合适的块。这听起来很原始,但好处是代码量极小(不到SLUB的十分之一),内存开销几乎为零。
我曾经在一个只有4MB内存的IoT设备上用过SLOB。嗯,当时SLUB的元数据就占了1MB多,根本跑不起来。换成SLOB后,系统总算能正常工作了。但要注意,SLOB的性能很差,频繁分配释放时会有严重的碎片问题,只适合小内存、低频分配的场景。
避坑指南:我曾经在生产环境误用了SLOB,结果系统跑了三天后内存碎片化严重,kmalloc分配失败导致内核panic。记住:SLOB只适合嵌入式或实验环境,生产服务器千万别用。
4.3 kmalloc与kmem_cache:两种分配方式
内核里分配小对象有两种方式:kmalloc和kmem_cache。你想想看,它们有什么区别?
kmalloc:通用分配器。你告诉它要多少字节,它从预定义的缓存池里找一个大小合适的给你。内核启动时会创建一组固定大小的缓存(比如32、64、128、256字节等),kmalloc就是在这组缓存里分配。
kmem_cache:专用分配器。你可以创建自己的缓存,指定对象大小、对齐方式、构造函数等。比如文件系统会为inode创建专用的kmem_cache,网络子系统为sk_buff创建专用的缓存。
| 特性 | kmalloc | kmem_cache |
|---|---|---|
| 使用方式 | 直接调用,指定大小 | 先创建缓存,再分配对象 |
| 内存利用率 | 可能有内碎片(向上取整) | 零碎片(对象大小固定) |
| 性能 | 中等(需要查找合适缓存) | 高(直接操作对象池) |
| 适用场景 | 临时、大小不固定的分配 | 频繁分配释放的固定大小对象 |
我个人习惯:如果某个结构体在代码里频繁分配(比如每秒几千次),我一定用kmem_cache。举个例子,网络驱动里每个数据包都要分配sk_buff,用kmem_cache分配一次只要几十纳秒,而kmalloc可能要几百纳秒。
4.4 对象复用机制:SLAB的精髓
SLAB分配器最牛的地方不是分配快,而是对象复用。什么意思?你释放一个对象后,它并没有真正还给伙伴系统,而是留在slab里。下次再分配同样大小的对象,直接拿回来用。
这带来了两个好处:
- CPU缓存友好:刚释放的对象还在CPU缓存里,马上分配出去,命中率极高。
- 减少内存操作:不需要清空内存(除非你要求),直接复用旧数据。
具体怎么实现的?每个slab内部维护一个freelist(空闲链表)。分配时从freelist头部取一个对象,释放时把对象放回freelist头部。这个操作就是几个指针赋值,比页分配器快两个数量级。
// 简化的SLAB对象分配流程
void *slab_alloc(struct kmem_cache *cache) {
struct slab *slab;
void *object;
// 1. 从per-CPU缓存或部分满slab中取一个
slab = get_partial_slab(cache);
// 2. 从freelist头部取对象
object = slab->freelist;
slab->freelist = object->next;
// 3. 更新slab状态
slab->inuse++;
if (slab->inuse == slab->objects)
move_to_full_list(slab);
return object;
}
你可能会问:那对象里的旧数据怎么办?嗯,这里要注意:SLAB默认不会清空对象。如果你分配后直接读旧数据,可能会出大问题。所以内核里有个习惯——分配后一定要初始化对象,或者用kzalloc(清零分配)。
关键点:对象复用机制是SLAB高性能的核心。它利用“时间局部性”原理——刚释放的对象很可能马上又被分配,留在CPU缓存里能极大提升性能。但代价是必须由使用者负责初始化,否则会读到脏数据。
4.5 实战:如何选择分配器
说了这么多理论,最后给点实战建议。你在写内核模块或驱动时,怎么选?
- 临时分配:用kmalloc/kfree。比如你要分配一个临时缓冲区处理数据,用完就释放。
- 频繁分配固定对象:用kmem_cache_create创建专用缓存。比如你的驱动里每个请求都要分配一个request结构体。
- 大对象(超过一个页):别用SLAB了,直接用__get_free_pages或vmalloc。
- 嵌入式系统:编译时选CONFIG_SLOB,省内存。
- 调试内存泄漏:用SLAB的调试功能(CONFIG_SLAB_DEBUG),可以检测越界访问和未初始化使用。
我记得有一次排查内存泄漏,就是靠SLAB的毒药模式(poison)——分配时填充0x6b,释放时填充0x6d。如果某个对象的值是0x6b,说明它被分配后没初始化;如果是0x6d,说明它被释放后还在用。嗯,这招帮我抓到了不少bug。
最后说一句:SLUB现在是内核默认,99%的场景直接用就好。除非你明确知道自己在做什么,否则别手贱去改。我曾经在编译内核时把SLUB改成SLAB,结果性能反而下降了——因为我的机器不是NUMA架构,SLAB的per-CPU缓存反而成了负担。