Linux 内核 Slab 分配器深度实战:从伙伴系统到对象缓存架构
引言:为什么需要 Slab 分配器
Linux 内核内存管理是一个精密的层次化体系。在最底层,伙伴系统(Buddy System) 以页为单位(通常 4KB)管理物理内存,通过二分拆分与合并来解决外部碎片问题。然而,内核中大量对象的尺寸远小于一页——task_struct 约 1.7KB,inode 约 580B,dentry 约 192B。如果直接由伙伴系统分配,内部碎片将浪费大量内存(一页 4KB 存放一个 192B 的 dentry,浪费率超过 95%)。
更致命的是性能问题。高频创建/销毁小对象会产生三个瓶颈:
- 页分配开销:每次都要经过伙伴系统的加锁、拆分、链表操作
- 初始化成本:内核对象构造函数(如
task_struct的初始化逻辑)代价高昂 - 内存碎片:频繁分配释放导致伙伴系统产生不可合并的小碎片
1994 年,Sun 工程师 Jeff Bonwick 在 Solaris 2.4 中提出了 Slab 分配器,用"对象缓存+预初始化"的思想一举解决了这三个问题。Linux 在 2.1.23 内核中由 Linus Torvalds 引入 Slab,此后历经 Slub(2.6.23 成为默认)和 SLOB(嵌入式场景)三种实现,至今仍是内核小内存分配的核心基础设施。
本文将深入剖析 Slab 分配器的设计哲学、内核实现细节、三种变体的差异,以及在驱动和子系统开发中的实战模式。
一、核心设计哲学
1.1 对象复用:避免重复初始化
内核对象的初始化通常涉及:设置引用计数、初始化锁、链表头、清零字段等。以 struct inode 为例,创建时需初始化 i_mutex、i_lock、i_sb_list、i_data 的 radix tree 等。销毁时同样要执行反向清理(释放锁、从链表删除、解锁)。
Slab 的核心洞察是:同类对象的初始化逻辑完全相同,只需执行一次,后续复用即可。
当一个对象被"释放"回 Slab 缓存时,Slab 并不执行完整的析构逻辑,而是将对象放回空闲链表。下次分配时直接取出,跳过初始化。这种"延迟析构"策略带来了数量级的性能提升。
1.2 NUMA 感知:本地内存优先
现代服务器多为 NUMA 架构,跨节点访问内存的延迟是本地节点的 1.5-3 倍。Slab 分配器的 per-node 设计确保:
- 每个 NUMA 节点维护独立的 slab 列表
kmem_cache_alloc()默认从请求 CPU 所在节点的本地 slab 分配- 仅当本地节点无空闲 slab 时才跨节点借用
在双路 AMD EPYC 平台上,本地 NUMA 节点内存分配(约 80ns)相比远程节点(约 220ns)快近 3 倍。
1.3 缓存着色(Cache Coloring)
现代 CPU 使用组相联缓存(Set-Associative Cache)。如果多个对象的起始地址恰好落在同一个缓存组(Cache Set)中,会发生缓存行冲突(Cache Line Thrashing)——频繁访问时互相驱逐,缓存命中率暴跌。
Slab 通过着色偏移(Colour Offset)解决:每个 slab 在末尾预留不同大小的偏移量,使得连续 slab 中相同位置对象的物理地址映射到不同缓存组。典型的着色范围是 0 到 colour_off × (colour + 1) 字节。
实验表明,在 L1D 32KB/8-way 的 CPU 上,启用缓存着色后 kmem_cache_alloc() 吞吐提升 12-18%。
二、Slab 分配器数据结构
2.1 kmem_cache:缓存描述符
每个 Slab 缓存由一个 struct kmem_cache 描述,它是整个分配器的顶层数据结构:
struct kmem_cache {
struct kmem_cache_cpu *cpu_slab; // Per-CPU 热路径缓存(SLUB)
unsigned long flags; // 对象约束标志(如 GFP_ACCOUNT)
unsigned int size; // 对象实际大小
unsigned int object_size; // 用户请求的原始大小
unsigned int align; // 对齐要求
unsigned int offset; // 空闲链表指针的偏移
unsigned int colour; // 可用着色数
unsigned int colour_off; // 每个着色单位的字节数
unsigned int inuse; // 已使用对象数
unsigned int freeable; // 可释放 slab 数
struct list_head list; // 全局缓存链表
struct kmem_cache_node **node; // Per-node 数组(SLUB)
char name[NAME_MAX+1]; // 缓存名称
struct kmem_cache_ops *ops; // 操作函数表
// ... 统计、调试、rcu 回调等字段
};
关键字段说明:
- cpu_slab:Per-CPU 缓存,SLUB 分配器第一层快速路径,无锁分配
- object_size vs size:object_size 是用户请求大小(如 sizeof(task_struct)),size 是对齐后的实际占用空间
- offset:空闲链表 next 指针在对象内的偏移,当对象太小无法容纳指针时,Slab 将指针放在对象外部
- colour_off:通常等于 L1 缓存行大小(64 字节),确保每个 slab 的着色偏移对齐到缓存行
2.2 Slab 管理结构
每个物理页(或页组)被 Slab 接管后,其 struct page 被复用:
// SLUB 中的 page 复用(union 布局)
struct page {
struct { // 当 page 作为 slab 页时
void *freelist; // 空闲对象链表头头指针
struct { // union
unsigned inuse:16; // 已使用对象计数
unsigned objects:16; // 本页对象总数
};
};
struct kmem_cache *slab_cache; // 所属缓存
};
一个 slab 的内存布局:
┌──────────────────────────────────────────────┐
│ 对象 0 │ 对象 1 │ 对象 2 │ ... │ 对象 N-1 │ 着色区 │
└──────────────────────────────────────────────┘
↑ freelist → 空闲对象链
每个空闲对象的起始 8 字节(64 位)存储 next 指针,形成单向链表。分配时 freelist 弹出队首,释放时压入队首——O(1) 无锁操作。
2.3 Per-Node 管理
每个 NUMA 节点维护一个 struct kmem_cache_node:
struct kmem_cache_node {
spinlock_t list_lock;
unsigned long nr_partial; // 部分空闲 slab 数
struct list_head partial; // 部分空闲 slab 链表
#ifdef CONFIG_SLUB_CPU_PARTIAL
unsigned long nr_slab; // CPU 部分缓存中的 slab 数
#endif
atomic_long_t nr_slabs; // 本节点总 slab 数
atomic_long_t total_objects; // 本节点总对象数
};
分配路径的层级流转:
CPU 本地缓存 (cpu_slab) → CPU 部分链表 (cpu_partial) → Node 部分链表 (partial) → Node 全空 slab → 分配新 slab (new_slab)
↑ 无锁快速路径 ↑ 每 CPU 锁 ↑ Node 自旋锁 ↑ 伙伴系统
三、分配与释放路径详解
3.1 kmem_cache_alloc() 快速路径
以下是 SLUB 分配器快速路径的简化逻辑:
static __always_inline void *slab_alloc(struct kmem_cache *s,
gfp_t gfpflags, unsigned long addr)
{
struct kmem_cache_cpu *c = raw_cpu_ptr(s->cpu_slab);
struct page *page = c->page; // 当前 CPU slab 页
void *object = c->freelist; // 空闲链表头
if (unlikely(!object || !page))
return __slab_alloc(s, gfpflags, addr); // 慢速路径
c->freelist = get_freepointer(s, object); // 取下一个
c->tid = next_tid(c->tid); // 事务 ID 递增
return object;
}
关键优化:
- 无锁:整个快速路径不使用任何锁(单 CPU 独占 cpu_slab)
- CPU 私有:
raw_cpu_ptr()直接访问 per-CPU 变量,无需 atomic - 无原子操作:
get_freepointer()只是普通内存读取,依赖 CPU 亲和性保证安全 - 推测执行友好:无内存屏障(smp_rmb 仅在 freelist 换页时出现)
实测快速路径耗时约 15-25ns,接近 kmalloc 的理论极限。
3.2 慢速路径:换页与新建 Slab
当 cpu_slab 中无空闲对象时,进入 __slab_alloc():
- 检查 cpu_partial:CPU 私有部分链表有可用 slab → 切换 c->page
- 检查 node->partial:本节点部分空闲 slab 链表非空 → 取出一个
- new_slab():调用伙伴系统 alloc_pages() 分配新页组,初始化 slab
- 伙伴系统分配:对于大对象(>1 页),使用
alloc_pages_node()按节点分配
static struct page *new_slab(struct kmem_cache *s, gfp_t gfpflags)
{
struct page *page = allocate_slab(gfpflags, node, oo);
if (!page)
return NULL;
do {
SetSlab(sl); // 标记为 slab 管理
init_object(sl, obj); // 可选:构造函数
add_freelist(sl, obj); // 加入空闲链表
} while (...);
setup_page_traceability(sl);
return page;
}
3.3 kmem_cache_free() 释放路径
static __always_inline void slab_free(struct kmem_cache *s, void *x)
{
struct kmem_cache_cpu *c = raw_cpu_ptr(s->cpu_slab);
void *prior;
// 待释放对象属于当前 cpu_slab:直接压入 freelist
if (likely(page == c->page)) {
set_freepointer(s, object, c->freelist);
c->freelist = object;
return;
}
// 不属于 cpu_slab:走慢速路径,可能触发 slab 归还到 node 或释放回伙伴系统
__slab_free(s, page, object, ...);
}
关键行为:
- 当 slab 中所有对象都被释放时,Pruning 机制将整页归还伙伴系统
- 当 node->partial 链表过长时,__kmem_cache_shrink() 回收空闲 slab
- SLUB 的 lazy free 机制延迟归还,减少伙伴系统抖动
四、三种 Slab 实现对比
4.1 传统 Slab(Linux 2.1 → 2.6.22)
Bonwick 原始设计的 Linux 实现。特点:
- 每个 slab 嵌入
struct slab管理头:放在 slab 外部(off-slab)外部 - 三个完整链表:
slabs_full、slabs_partial、slabs_empty - 硬件缓存对齐:强制对象起始地址按 L1_CACHE_BYTES 对齐
- 复杂但清晰的逻辑:管理头与对象分离,适合教学理解
缺点:
- struct slab 管理头外部存储增加了一层间接寻址
- kmem_cache 结构体积庞大(100+ 字段),无法内联到 CPU cache line
- 大量长链表遍历开销大
4.2 SLUB(Linux 2.6.23+,当前默认)
SLUB 全称 "Unqueued Slub",核心思路是将管理信息嵌入到 page 结构中:
- 复用 page 的 union 空间:不分配独立的管理头结构
- 合并三个链表为 partial 一个:通过 page->inuse 区分满/空
- cpu_slab + cpu_partial 双层 per-CPU:减少 node 锁争抢
- per-cpu partial 配额控制:避免单个 CPU 囤积过多空闲 slab
- Red-Zone / Poisoning 调试:在对象两侧填充魔数,检测越界和 use-after-free
- Freelist hardening:ASLR 随机化 freelist 指针,增加溢出攻击难度
SLUB 在大多数 workload 下是三种实现中吞吐量最高的。一个标准 benchmark 结果(Intel Xeon 6330,分配/释放 64B 对象):
| 实现 | 操作/秒 | 平均延迟 | % 退化 |
|---|---|---|---|
| SLUB (快速路径) | 58M | 17ns | baseline |
| SLUB (慢速路径) | 12M | 83ns | 4.8× |
| 传统 Slab | 31M | 32ns | 1.9× |
| SLOB | 8M | 125ns | 7.3× |
4.3 SLOB(Simple List Of Blocks)
SLOB 是为极端内存受限的嵌入式设备设计的:
- 首次适应(First Fit)分配:遍历页面链表找到第一个足够大的空闲块
- 极致简洁:代码量约 500 行,远小于 SLUB(2500+ 行)
- 无 per-CPU、无着色、无调试:纯朴素实现
- 适合场景:内存 < 64MB 的嵌入式设备,或启动早期(伙伴系统可用之前)
SLOB 在分配时遍历所有 page 和空闲块,时间复杂度 O(n)。对于小内存设备可以接受,但不可扩展。现代内核中通常通过 CONFIG_SLOB=y 仅在 MIPS/ARM 嵌入式平台启用。
4.4 选型决策矩阵
高吞吐生产负载 ──→ SLUB (默认,最成熟,性能最好)
< 64MB 嵌入式 ──→ SLOB (代码极简,内存开销极小)
教学/内核开发参考 ──→ 传统 Slab (代码清晰,适合理解原型)
五、kmalloc:大小分级的通用分配器
开发者通常不会直接使用 kmem_cache_alloc(),而是通过 kmalloc() 接口。kmalloc 内部预定义了一组大小分级的通用 slab 缓存:
// mm/slab_common.c 中的通用缓存表
static struct cache_size {
char cache_name[CACHE_NAMELEN];
size_t size;
struct kmem_cache *cachep;
} cache_sizes[] = {
{ "kmalloc-8", 8, NULL },
{ "kmalloc-16", 16, NULL },
{ "kmalloc-32", 32, NULL },
{ "kmalloc-64", 64, NULL },
{ "kmalloc-96", 96, NULL },
{ "kmalloc-128", 128, NULL },
{ "kmalloc-192", 192, NULL },
{ "kmalloc-256", 256, NULL },
{ "kmalloc-512", 512, NULL },
{ "kmalloc-1024", 1024, NULL },
{ "kmalloc-2048", 2048, NULL },
{ "kmalloc-4096", 4096, NULL },
{ "kmalloc-8192", 8192, NULL },
// ... 扩展到 32MB
};
5.1 大小分级策略
缓存大小按 8 字节 → 倍数递增(16/32/64…)的序列排列。在中段(96/128/192)使用精细分级,因为这是内核最常分配的尺寸区间。当请求大小超过 8KB 时,kmalloc 直接回退到伙伴系统分页——因为大对象本身已经是页的倍数,slab 优化不再有意义。
5.2 实测内存利用率
以分配 100 字节为例:
- 请求 100 字节 → 实际从
kmalloc-128分配 128 字节 - 浪费 28 字节(22%),但省去了自定义缓存的创建和查找开销
kmem_cache_create("my_obj", 100, ...)可精确匹配,但需额外管理缓存生命周期
通用策略:性能敏感且对象数量大 → 自定义缓存;通用场景或对象稀少 → kmalloc。
六、自定义 Slab 缓存实战
6.1 创建缓存
static struct kmem_cache *my_obj_cache;
static int __init my_init(void)
{
my_obj_cache = kmem_cache_create(
"my_object", // 缓存名称(/proc/slabinfo 中可见)
sizeof(struct my_obj), // 对象大小 (如 248 字节)
0, // 对齐值 (0 = 自然对齐)
SLAB_HWCACHE_ALIGN, // 标志:硬件缓存行对齐
my_ctor // 可选构造函数
);
if (!my_obj_cache)
return -ENOMEM;
return 0;
}
6.2 分配与释放对象
struct my_obj *obj = kmem_cache_alloc(my_obj_cache, GFP_KERNEL);
if (!obj)
return -ENOMEM;
// 使用 obj...
kmem_cache_free(my_obj_cache, obj);
6.3 工厂模式封装
static inline struct my_obj *my_obj_alloc(gfp_t gfp)
{
struct my_obj *obj = kmem_cache_alloc(my_obj_cache, gfp);
if (obj)
usage_count_inc();
return obj;
}
static inline void my_obj_free(struct my_obj *obj)
{
kmem_cache_free(my_obj_cache, obj);
usage_count_dec();
}
6.4 销毁缓存
static void __exit my_exit(void)
{
kmem_cache_destroy(my_obj_cache);
}
关键约束:kmem_cache_destroy() 会调用 flush_all() 等待所有 RCU 释放完成,并强制回收所有尚未归还的 slab。必须在确认无活跃对象后调用,否则内核会 print BUG。
七、调试与可观测性
7.1 /proc/slabinfo:实时分配器状态
$ cat /proc/slabinfo | head -10
slabinfo - version: 2.1
# name <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> : tunables <limit> <batchcount> <sharedfactor> : slabdata <active_slabs> <num_slabs> <sharedavail>
kmalloc-64 28488 32256 64 63 1 : tunables 0 0 0 : slabdata 512 512 0
task_struct 2564 3441 1664 8 2 : tunables 0 0 0 : slabdata 431 431 0
dentry 158928 158928 192 21 1 : tunables 0 0 0 : slabdata 7568 7568 0
inode_cache 12480 12480 584 14 2 : tunables 0 0 0 : slabdata 892 892 0
解读:
- task_struct:objsize=1664(含对齐),objperslab=8(每 slab 8 个),pagesperslab=2(每 slab 用 2 页=8KB)
- active_objs/num_objs:查看内存利用率。若比值 < 50% 说明 slab 半空,存在浪费
7.2 SLUB 调试机制
通过 slub_debug 参数启用不同调试级别:
# 全量调试(含 poisoning、red-zone、tracking)
slub_debug=FPZU
# 仅启用 poisoning(分配时填 0x5a,释放后填 0x6b)
slub_debug=P
# 启用 red-zone(对象尾部放 0xbb,检测越界写)
slub_debug=Z
# 启用用户跟踪(记录每次 alloc/free 的调用栈)
slub_debug=U
使用 slub_debug=U 后可通过 /sys/kernel/slab/<cache_name>/trace 查看每个对象的分配栈,精准定位泄漏。
7.3 kmemleak:自动泄漏检测
kmemleak 以垃圾回收标记-扫描的方式检测内核泄漏:
// 启用后扫描(通过 debugfs)
echo scan > /sys/kernel/debug/kmemleak
cat /sys/kernel/debug/kmemleak
输出示例:
unreferenced object 0xffff88807f8c1a00 (size 256):
comm "worker", pid 2847, jiffies 4294912837 (age 847.328s)
hex dump (first 32 bytes):
5a 5a 5a 5a 5a 5a 5a 5a 5a 5a 5a 5a 5a 5a 5a 5a
5a 5a 5a 5a 5a 5a 5a 5a 5a 5a 5a 5a 5a 5a 5a 5a
backtrace:
[<ffffffff81234567>] kmem_cache_alloc+0x123/0x230
[<ffffffff81abcdef>] my_worker_func+0x42/0x89
八、性能优化:NUMA 与 CPU 亲和
8.1 GFP 标志对分配路径的影响
// GFP_THISNODE:强制从当前 NUMA 节点分配,不到其他节点借用
obj = kmem_cache_alloc_node(cache, GFP_KERNEL | GFP_THISNODE, numa_node_id());
// GFP_NOFAIL:永不失败,可能触发直接内存回收或 OOM killer
obj = kmem_cache_alloc(cache, GFP_NOFAIL); // 慎用!
// GFP_ACCOUNT:计入 cgroup memory.stat
obj = kmem_cache_alloc(cache, GFP_KERNEL | GFP_ACCOUNT); // cgroup v2 默认
8.2 绑定 CPU 亲和
void *obj;
int cpu = smp_processor_id();
// 当前 CPU 节点的本地分配
obj = kmem_cache_alloc_node(cache, GFP_KERNEL, cpu_to_node(cpu));
8.3 Slab 合并(Slab Merging)
SLUB 默认开启 slab_nomerge=false,同名同尺寸缓存会被合并统计。对于调试场景,设置 slab_nomerge=true 阻止合并。
合并示例:两个子系统各自创建 256B 缓存,合并后共享 kmalloc-256 的 slab,减少冗余缓存条目。
九、新趋势:cgroup 与 Rust 生态
9.1 cgroup v2 memory 控制器
在 cgroup v2 下,所有 GFP_ACCOUNT 标记的 slab 分配都计入 memory.current,可通过 memory.slabinfo 查看每个 cgroup 内部缓存用量:
# cat /sys/fs/cgroup/workload-a/memory.slabinfo
<cache_name> <active_objs> <num_objs> <objsize>
kmalloc-64 4096 6144 64
task_struct 64 88 1664
当 cgroup 达到 memory.max 限制时,后续 GFP_ACCOUNT 分配失败返回 NULL,实现 slab 级别的 QoS 隔离。
9.2 Folio 与复合页
Linux 引入 Folio(复合页)后,slab 分配可以接管更大的物理块。对于数据库缓冲池这类需要连续大内存的场景,减少 TLB miss 和页表遍历开销。
9.3 Rust Slab 分配器生态
Rust-for-Linux 项目引入了 Slab trait 和 KmemCache 包装类型,使得 Rust 驱动也能复用 C 侧的 SLUB 缓存:
// Rust 侧使用自定义 slab 缓存
impl kernel::slab::SlabAllocator for MyDriver {
fn alloc(&self) -> Result<Box<MyObject>, AllocError> {
KmemCache::try_alloc(self.cache)?;
Ok(Box::try_new(MyObject::default())?)
}
}
十、常见陷阱与诊断
10.1 Use-After-Free (UAF)
症状:kmem_cache_free() 后仍继续使用对象。
诊断:启用 slub_debug=FU,分配填充 0x6b,释放填充 0x5a。若访问到的值是 0x6b,说明读的是已释放对象。
10.2 越界写 (Out-of-Bounds)
症状:写入超出对象末尾,破坏相邻对象或 freelist。
诊断:启用 slub_debug=Z,红区写入 0xbb。内核每 128 次分配检查一次红区。
10.3 Double Free
症状:同一对象被释放两次,导致 freelist 形成环。
诊断:SLUB 的 freelist hardening 对 next 指针做 XOR 混淆,如果重复释放,指针被混淆两次后不再指向合法对象,触发 slab_err。
10.4 不正确的 GFP 上下文
// 错误!持有 spinlock 时使用 GFP_KERNEL 可能睡眠
spin_lock(&lock);
obj = kmem_cache_alloc(cache, GFP_KERNEL); // MAY_SLEEP → BUG!
spin_unlock(&lock);
// 正确:在原子上下文中使用 GFP_ATOMIC
spin_lock(&lock);
obj = kmem_cache_alloc(cache, GFP_ATOMIC);
spin_unlock(&lock);
内核提供 might_sleep_if(gfpflags_allow_blocking(gfp)) 在检测到 GFP_KERNEL + 原子上下文时打印栈回溯。
十一、Slab 分配器演进趋势
传统 slab → SLUB → (未来方向):
- Per-CPU 批量分配:借鉴 userspace 分配器(mimalloc/jemalloc)的 batch 策略,每次
fetch_and_add一次性拿走多个对象,减少 CAS/atomic 竞争 - Partial slab 预取:根据 CPU 运行队列长度动态调整每个 CPU 的 partial 配额,避免突发流量时的 freelist 弹尽
- 基于 eBPF 的实时监控:跟踪
kmem_cache_alloc/free的延迟和频率,构建 slab 分配器的 P99 实时热力图 - 内存去重(KSM for slab):对内容相同的 slab 页进行合并去重,适用于容器全同镜像场景
- CXL 扩展内存感知:CXL-attached 内存延迟更高,SLUB 节点拓扑扩展为三层:本地 DDR → 远程 NUMA → CXL memory
总结
Slab 分配器是 Linux 内核内存管理的"最后一公里"——高效的层次化对象缓存。它的设计哲学——复用初始化成果、NUMA 本地性、缓存行对齐着色——至今仍深刻影响着系统级编程。
理解 Slab 分配器的核心价值在于:
- 性能归因:当 dentry cache 命中率高时,不需要重新从磁盘读 inode
- 泄漏发现:使用
cat /proc/slabinfo | sort -k4 -n找到膨胀的缓存 - 正确同步:对象无锁化复用 + RCU 释放,保持热路径极致简洁
- 资源隔离:cgroup v2 将 slab 纳入统算,实现容器级内存配额
在云原生时代,微服务和容器化使得内核对象生命周期管理更加碎片化。SLUB 分配器不断演进——从 freelist hardening 到 per-cpu partial 配额控制,始终在安全与性能之间寻找平衡点。对于驱动开发者和高性能系统程序员,深入掌握 Slab 分配器是必修课。

发表评论 取消回复