Linux内核学习轨迹第五部:内核内存分配器:SLUB/SLOB/SLAB全解析(第四小节)
4. 内核内存分配器:SLUB/SLOB/SLAB全解析
伙伴系统解决了外部碎片问题,以页为单位分配内存,但内核中大量的小对象(比如task_struct、inode、file结构体),大小只有几十到几百字节,如果用整页分配,会导致严重的内部碎片。SLAB/SLUB/SLOB分配器就是为了解决这个问题,基于伙伴系统分配的整页,拆分更小的对象,实现小内存的高效分配,减少内部碎片。
4.1 三种分配器的对比与适用场景
Linux内核提供了三种slab分配器,可通过内核启动参数选择,默认使用SLUB分配器:
分配器 | 核心设计 | 优势 | 劣势 | 适用场景 |
SLUB | 无队列设计,简化结构,基于页管理对象 | 性能高、扩展性好、调试简单、内存占用低,解决了SLAB的队列锁竞争问题 | 对小对象的内存利用率略低于SLAB | Linux默认分配器,绝大多数场景,包括服务器、桌面、嵌入式 |
SLAB | 基于每CPU/每节点队列设计,复杂的缓存结构 | 小对象内存利用率高,缓存命中率高 | 代码复杂、多核锁竞争严重、扩展性差、调试困难、内存开销大 | 老旧系统,对小对象内存利用率要求极高的嵌入式场景 |
SLOB | 简单的首次适配分配器,代码极简 | 代码量极小,内存占用极低 | 内存碎片化严重,性能差,多核扩展性极差 | 内存极小的嵌入式设备,比如单片机、物联网设备 |
本章节重点讲解默认的SLUB分配器,它是目前Linux内核的主流,性能和扩展性最好,代码结构清晰。
4.2 SLUB分配器的核心设计思想
SLUB分配器的核心设计是简化结构,消除SLAB的复杂队列,基于页管理对象,核心思想:
- Slab页:SLUB从伙伴系统分配一个或多个连续的页(Slab页),把页拆分为固定大小的对象,每个Slab页只管理一种大小的对象;
- Kmem Cache:每种大小的对象对应一个kmem_cache缓存,比如task_struct对应一个kmem_cache,inode对应另一个kmem_cache,每个缓存管理自己的Slab页;
- 无队列设计:SLUB没有SLAB复杂的每CPU/每节点队列,直接把空闲对象链表存储在Slab页本身,减少锁竞争,提升多核扩展性;
- 每CPU缓存:为每个CPU维护一个活动Slab页,单CPU的对象分配和释放,直接从本地CPU的活动Slab中操作,不需要加锁,性能极高;
- 合并缓存:SLUB会自动合并大小相近的kmem_cache,减少Slab页的数量,提升内存利用率。
4.3 SLUB分配器的核心数据结构
SLUB分配器的核心数据结构有两个:struct kmem_cache(对象缓存)和struct slab(Slab页管理结构),定义在mm/slub.c中。
4.3.1 kmem_cache结构体
每个固定大小的对象对应一个kmem_cache实例,管理该大小对象的所有Slab页,核心字段拆解:
struct kmem_cache { // 每CPU的活动Slab缓存,无锁快速分配 struct kmem_cache_cpu __percpu *cpu_slab; // 缓存的名称,比如"task_struct"、"kmalloc-128" const char *name; // 对象的大小,包括对齐填充 unsigned int size; // 对象的实际大小,不包括对齐填充 unsigned int object_size; // 对象的对齐要求 unsigned int align; // 每个Slab页包含的对象数量 unsigned int num; // 每个Slab页的阶数(2^order个页) unsigned int order; // 对象的偏移量,在Slab页中的起始位置 unsigned int offset; // Slab的标志位,比如SLAB_POISON、SLAB_RED_ZONE等调试标志 slab_flags_t flags; // 构造函数,对象分配时调用 void (*ctor)(void *obj); // 节点的Slab管理结构,每个NUMA节点对应一个 struct kmem_cache_node *node[MAX_NUMNODES]; // 缓存合并相关字段 struct kmem_cache *next; // 调试相关字段 int refcount; int in_slab; } ____cacheline_aligned;核心字段解析:
- cpu_slab:每CPU的活动Slab缓存,是SLUB快速分配的核心,每个CPU有一个独立的活动Slab页,分配和释放不需要加锁,性能极高;
- size/object_size:对象的大小,object_size是用户实际需要的大小,size是对齐后的大小,包括填充和元数据;
- order:每个Slab页的阶数,决定了从伙伴系统分配的连续页数量,对象越大,order越大;
- um:每个Slab页包含的对象数量,由Slab页的总大小和对象大小决定;
- ode[MAX_NUMNODES]:每个NUMA节点的Slab管理结构,管理该节点内的所有Slab页,包括满的、部分空闲的、完全空闲的Slab页。
4.3.2 kmem_cache_cpu结构体
每CPU的活动Slab缓存,是SLUB无锁快速分配的核心,定义在mm/slub.c中:
struct kmem_cache_cpu { // 空闲对象链表的第一个对象,无锁分配直接从这里取 void **freelist; // 当前活动的Slab页对应的page结构体 struct page *page; // 下一个要释放的对象,用于批量释放 void **next_freelist; // 事务编号,用于调试 unsigned int tid; };核心设计:每个CPU的kmem_cache_cpu有一个当前活动的Slab页,freelist指向该Slab页中的空闲对象链表,分配时直接从freelist取第一个对象,释放时直接把对象放回freelist,整个过程不需要加锁,因为是每CPU的私有数据,多核之间没有竞争,性能极高。
4.3.3 Slab页与page结构体的关联
SLUB的Slab页就是从伙伴系统分配的连续物理页,用struct page结构体管理,page结构体中的联合体字段,对应SLUB的管理信息:
// page结构体中SLUB相关的联合体字段 struct { struct slab *slab; // 指向Slab管理结构 void *s_mem; // Slab内的第一个对象地址 union { unsigned int active; // Slab内的活跃对象数 void *freelist; // 空闲对象链表 }; };每个Slab页有三种状态:
- 满(Full):Slab内的所有对象都被分配了,没有空闲对象;
- 部分空闲(Partial):Slab内有部分对象被分配,还有空闲对象;
- 完全空闲(Free):Slab内的所有对象都被释放了,没有活跃对象。
4.4 SLUB分配器的核心流程源码解析
SLUB分配器的核心流程分为对象分配、对象释放、Slab页的分配与释放,我们基于Linux 6.6内核拆解核心逻辑。
4.4.1 对象分配流程:kmem_cache_alloc()
对象分配的核心入口是kmem_cache_alloc(),最终落到slab_alloc()函数,核心分为快速路径和慢速路径。
分配流程核心步骤:
kmem_cache_alloc(struct kmem_cache *s, gfp_t gfpflags)
↓
slab_alloc(s, gfpflags, _RET_IP_)
↓
1. 快速路径分配:
├→ 关闭抢占,获取当前CPU的kmem_cache_cpu
├→ 从freelist中取出第一个空闲对象
├→ 更新freelist,指向下一个空闲对象
├→ 开启抢占,返回对象地址
└→ 如果freelist为空,进入慢速路径
↓
2. 慢速路径分配:__slab_alloc()
├→ 检查当前活动Slab页是否还有空闲对象,有则刷新freelist,回到快速路径
├→ 如果没有,从当前NUMA节点的Partial Slab链表中,取出一个有空闲对象的Slab页,设置为当前CPU的活动Slab,回到快速路径
├→ 如果Partial链表为空,调用new_slab(),从伙伴系统分配新的Slab页,初始化对象和空闲链表,设置为活动Slab,回到快速路径
└→ 如果伙伴系统分配失败,返回NULL,分配失败
核心逻辑解析:
- 快速路径是无锁、无睡眠的,性能极高,99%的分配都会在快速路径完成;
- 只有当当前CPU的活动Slab页没有空闲对象时,才会进入慢速路径,需要加锁访问节点的Partial链表,或者从伙伴系统分配新的Slab页;
- ew_slab()函数会从伙伴系统分配对应order的连续页,初始化Slab页,把页拆分为固定大小的对象,构建空闲对象链表,设置为当前CPU的活动Slab。
4.4.2 对象释放流程:kmem_cache_free()
对象释放的核心入口是kmem_cache_free(),最终落到slab_free()函数,核心流程和分配对应。
释放流程核心步骤:
kmem_cache_free(struct kmem_cache *s, void *obj)
↓
slab_free(s, obj, _RET_IP_)
↓
1. 快速路径释放:
├→ 关闭抢占,获取当前CPU的kmem_cache_cpu
├→ 把释放的对象插入到freelist的头部
├→ 开启抢占,释放完成
└→ 如果Slab页从满变为部分空闲,进入慢速路径
↓
2. 慢速路径释放:__slab_free()
├→ 把Slab页加入到节点的Partial链表中
├→ 检查Slab页是否所有对象都被释放,如果是,把Slab页释放回伙伴系统
└→ 完成释放
核心逻辑解析:
- 快速路径释放也是无锁的,直接把对象放回当前CPU活动Slab的freelist中,性能极高;
- 只有当Slab页从满变为部分空闲,或者完全空闲时,才会进入慢速路径,需要加锁操作节点的链表,或者把Slab页释放回伙伴系统;
- SLUB会定期把完全空闲的Slab页释放回伙伴系统,避免内存浪费,这一点和SLAB不同,SLAB会缓存大量的Slab页,不会轻易释放。
4.5 kmalloc通用内核内存分配器
kmalloc是内核中最常用的通用内存分配接口,基于SLUB分配器实现,类似于用户态的malloc,用于分配任意大小的内核小内存。
kmalloc的实现原理:
内核预定义了一系列通用的kmem_cache,对应不同的大小,比如kmalloc-8、kmalloc-16、kmalloc-32、kmalloc-64、kmalloc-128、kmalloc-256、kmalloc-512、kmalloc-1024、kmalloc-2048、kmalloc-4096等,每个大小对应一个kmem_cache缓存。当调用kmalloc(size)时,内核会找到大于等于size的最小的kmalloc缓存,从对应的缓存中分配对象,比如分配100字节,会从kmalloc-128缓存中分配。
4.6 SLUB分配器的工程实践与调试
1.SLUB调试功能的使用
SLUB提供了强大的调试功能,可以检测内存泄漏、越界访问、释放后使用、重复释放等常见的内核内存bug,是驱动开发、内核调试的利器。
a.内核启动参数开启SLUB调试:
slub_debug=FUZP
常用调试标志:
- F:开启sanity checks,每次分配和释放都检查Slab的合法性;
- U:开启释放后使用检测,释放的对象填充毒值,访问会触发警告;
- Z:开启红区检测,对象前后设置红区,越界访问会触发警告;
- P:开启Poison检测,分配的对象填充毒值,未初始化访问会触发警告;
- A:开启所有调试功能。
调试信息查看:
cat /sys/kernel/slab//,可以查看每个缓存的分配统计、错误信息、活动对象等。
2.Slab内存泄漏排查
线上常见的问题是Slab内存占用持续增长,导致系统内存不足,OOM触发,根源是内核驱动/模块的内存泄漏。
排查流程:
- 查看/proc/meminfo,确认Slab、SReclaimable(可回收)、SUnreclaim(不可回收)的占用,如果SUnreclaim持续增长,说明有不可回收的Slab内存泄漏;
- 查看/proc/slabinfo,或者slabtop命令,找到占用内存最多、对象数量持续增长的kmem_cache;
- 开启SLUB调试,跟踪对象的分配和释放栈,找到泄漏的位置;
- 常见根因:内核驱动分配的对象没有释放、引用计数泄漏、缓存对象没有正确回收。
临时解决方案:如果是可回收的Slab内存,可以手动回收:echo 2 > /proc/sys/vm/drop_caches,释放可回收的slab对象。
3.自定义kmem_cache的最佳实践
内核驱动开发中,如果需要频繁分配和释放固定大小的对象,应该创建自定义的kmem_cache,而不是用kmalloc,性能更高,内存利用率更好,也更容易调试。
自定义kmem_cache的标准实现:
// 定义自定义对象结构体
struct my_obj { int id; char name[32]; struct list_head list; }; // 定义缓存指针 static struct kmem_cache *my_obj_cache; // 模块初始化时创建缓存 static int __init my_module_init(void) { // 创建自定义kmem_cache my_obj_cache = kmem_cache_create( "my_obj_cache", // 缓存名称 sizeof(struct my_obj), // 对象大小 __alignof__(struct my_obj), // 对齐要求 SLAB_HWCACHE_ALIGN | SLAB_POISON, // 标志位 NULL // 构造函数 ); if (!my_obj_cache) { return -ENOMEM; } return 0; } // 分配对象 struct my_obj *obj = kmem_cache_alloc(my_obj_cache, GFP_KERNEL); // 释放对象 kmem_cache_free(my_obj_cache, obj); // 模块退出时销毁缓存 static void __exit my_module_exit(void) { // 销毁缓存前,必须确保所有对象都被释放 kmem_cache_destroy(my_obj_cache); } module_init(my_module_init); module_exit(my_module_exit);避坑指南:模块退出时,必须确保所有从缓存中分配的对象都被释放,否则调用kmem_cache_destroy()会失败,导致内存泄漏;自定义缓存的名称必须唯一,不能和系统已有的缓存重名。
4.SLUB与SLAB的选型
- 绝大多数场景下,使用默认的SLUB分配器即可,它的性能、扩展性、调试性都远优于SLAB;
- 只有在内存极小的嵌入式设备,对小对象的内存利用率要求极高时,才考虑使用SLAB;
- 只有在内存极小的单片机、物联网设备,代码量要求极简时,才考虑使用SLOB。
