在讲物理内存管理时,我们绕不开伙伴系统(buddy system)。但伙伴系统有一个天然的粒度问题:它以「页」为最小分配单位(通常 4KB)。而内核里绝大多数对象------一个 task_struct、一个 inode、一个 dentry------往往只有几十到几百字节。如果每次申请一个几十字节的对象都要从伙伴系统拿走一整页,内部碎片会大到无法接受。
slab 分配器就是为了解决这个矛盾而生的:它站在伙伴系统之上,把伙伴系统给的整页「切」成大小相等的小块,管理内核中频繁分配/释放的小对象。本文从设计动机出发,讲清 slab 的核心思想,再对比现代内核默认使用的 slub 实现。
一、为什么需要 slab
内核对象分配有三个非常鲜明的特征:
- 对象小且大小固定 :同一类对象(比如所有
inode)大小完全一样。 - 分配/释放极其频繁:文件系统、网络栈每秒创建销毁海量对象。
- 初始化开销可复用:很多对象释放后再分配,其内部结构(锁、链表头)其实可以保持初始化状态,省去重复 init。
针对这三点,slab 提出了三个对应的核心设计:
特征 → slab 的对策
------------------------------------------------
小对象、大小固定 → 按对象大小建立专用缓存(cache),一页切成多个 object
分配释放频繁 → 空闲对象用链表串起来,分配就是摘链表头,O(1)
初始化可复用 → 对象释放不销毁,保留已初始化状态(构造/析构语义)
二、三层结构:cache → slab → object
slab 分配器的经典模型是三层结构:
kmem_cache (一类对象的缓存,如 "inode_cache")
|
+--- slab (一个或多个连续物理页)
| |
| +-- object object object object ... (切好的等大小块)
|
+--- slab
+-- object object ...
- kmem_cache:描述「某一类对象」的缓存。每种对象一个 cache,记录对象大小、对齐、构造函数等元信息。
- slab:一块从伙伴系统申请来的连续物理页,被切分成若干个等大小的 object。
- object:最终交给调用者的那个小内存块。
每个 slab 根据自身 object 的使用情况,处于三种状态之一:
full : slab 里的 object 全部被占用
partial: slab 里部分被占用、部分空闲 ← 分配优先从这里拿
empty : slab 里 object 全部空闲,可回收还给伙伴系统
分配时优先从 partial slab 摘一个空闲 object;partial 用完了就去 empty 拿;都没有就向伙伴系统申请新页建一个新 slab。
三、经典 slab 的问题
经典 slab(Bonwick 在 Solaris 上提出、Linux 早期采用)设计精巧,但随着 SMP 系统核数越来越多,暴露出几个问题:
- 元数据开销大 :每个 slab 头部要维护复杂的管理结构(
kmem_bufctl_t数组等),对象越多元数据越占空间。 - 多层队列复杂:为了 SMP 扩展性引入了 per-CPU array cache、共享队列、多个 NUMA 节点队列,代码路径长、维护困难。
- cache footprint 大:管理结构本身占用大量 cache line,影响性能。
于是社区演进出了 slub(S 表示 unqueued / simple),并成为现代内核(2.6.23 之后)的默认分配器。
四、slub:更简洁的实现
slub 的核心思想是「能不维护的元数据就不维护」。它做了几个关键简化:
1. 把管理信息塞进对象本身
slub 不再为每个 slab 维护独立的 bufctl 数组,而是把「下一个空闲对象的指针」直接存在空闲对象自己的内存里,形成一条隐式的空闲链表(free list):
free_list ──► obj0.next ──► obj3.next ──► obj7.next ──► NULL
(指针就写在空闲对象的头几个字节里,
反正对象空闲时里面的内容没用)
分配 = 取 free_list 指向的对象,free_list 前移到 obj->next。释放 = 把对象插回链表头。全程 O(1),且几乎没有额外元数据。
2. per-CPU 的 active slab
每个 CPU 持有一个当前正在分配的 slab(kmem_cache_cpu),大部分分配走的是「无锁快路径」:
c
/* 极简化的 slub 快路径示意(非真实源码) */
static void *slab_alloc(struct kmem_cache *s)
{
struct kmem_cache_cpu *c = this_cpu_ptr(s->cpu_slab);
void *object = c->freelist; // 取 per-cpu 空闲链表头
if (likely(object)) {
c->freelist = get_freepointer(s, object); // 链表前移
return object; // 快路径:无锁,直接返回
}
return __slab_alloc(s, c); // 慢路径:per-cpu 空了,去 partial/伙伴系统
}
快路径只碰 per-CPU 数据,不需要加全局锁,这是 slub 在多核下扩展性好的关键。只有当前 CPU 的 slab 用完时,才走慢路径去 partial 列表或伙伴系统补货。
3. 更少的状态
slub 精简了队列层次,主要维护 per-CPU 的 active slab 和 per-node 的 partial 列表,去掉了经典 slab 里冗长的共享队列,代码量和 cache footprint 都大幅下降。
五、从用户视角:kmalloc 与专用 cache
内核里用到 slab/slub 有两种典型方式:
通用分配 kmalloc :内核预先建立了一组按 2 的幂递增的通用 cache(kmalloc-8、kmalloc-16、kmalloc-32 ... kmalloc-8192)。kmalloc(30, ...) 会向上取整到 kmalloc-32 这个 cache 里分配。
c
void *buf = kmalloc(30, GFP_KERNEL); // 实际从 kmalloc-32 cache 拿一个 32 字节 object
/* ... 使用 ... */
kfree(buf);
专用 cache :对于频繁分配的特定对象,内核会用 kmem_cache_create 建专用缓存,指定大小、对齐、构造函数:
c
struct kmem_cache *my_cache;
my_cache = kmem_cache_create("my_obj",
sizeof(struct my_obj),
0, /* 对齐 */
SLAB_HWCACHE_ALIGN,
NULL); /* 构造函数,可为 NULL */
struct my_obj *p = kmem_cache_alloc(my_cache, GFP_KERNEL);
/* ... */
kmem_cache_free(my_cache, p);
kmem_cache_destroy(my_cache);
系统里当前有哪些 cache、各自占用多少,可以直接看:
bash
cat /proc/slabinfo
# name <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> ...
六、小结
- 伙伴系统按页分配,slab/slub 在其上做小对象的精细化管理,解决内部碎片和分配频率问题。
- slab 的三层模型:kmem_cache → slab → object,slab 按占用分 full/partial/empty 三态,分配优先走 partial。
- slub 是现代内核的默认实现,核心优化是:空闲指针内嵌进对象(几乎零元数据)、per-CPU active slab 走无锁快路径、精简队列层次。
- 用户侧两条路:通用
kmalloc(走 kmalloc-N cache)和kmem_cache_create建专用 cache。
理解 slab/slub,本质上是理解内核如何在「伙伴系统的页粒度」和「实际对象的字节粒度」之间架起一层高效的桥梁。