引言:为什么需要Slab分配器?
在Linux内核中,伙伴系统(Buddy System)以页(通常4KB)为单位管理内存,这是操作系统内存管理的基石。然而,现实远比理论复杂——内核中大量需要的不是整页内存,而是几十到几百字节的小对象:task_struct、inode、dentry、文件描述符、网络连接控制块等等。如果每个对象都分配一整页,内存将被迅速耗尽。
更致命的是分配/释放频率。进程创建销毁、文件打开关闭、网络连接建立断开——这些操作在内核中每秒可能发生数万次。如果使用简单的alloc_page,每次分配都要经历伙伴系统中的分割、合并、链表操作,再加上从页面对齐地址初始化对象的构造函数开销,性能将惨不忍睹。
Slab分配器正是为解决这个问题而生。它的核心思想是:用空间换时间——预先分配一整页内存,将其切分成固定大小的"槽位"(slot),每个槽位存放一个特定类型的对象。当内核请求分配时,直接从已缓存的槽位中取;释放时,不归还给伙伴系统,而是标记为空闲待下次复用。这种"对象缓存"模式将O(n)的查找分配降为O(1)的槽位分配。
2. Slab发展简史:三代演进
Slab分配器自1994年由Jeff Bonwick为Solaris发明以来,经历了三代演进:
2.1 经典Slab(Solaris 2.4 → Linux 2.0)
Jeff Bonwick在1994年发表的论文《The Slab Allocator: An Object-Caching Kernel Memory Allocator》中提出了原始Slab设计。其核心创新是对象缓存(Object Cache)概念:为每种内核对象类型维护独立的缓存池,缓存中的每个slab是一个或多个连续物理页,被等分为固定大小的槽位。
经典Slab引入了两个关键优化:
- 构造函数/析构函数缓存:对象分配和释放时不调用构造/析构,而是将这些操作延迟到slab分配或回收整页时批量执行
- Slab着色(Slab Coloring):通过在slab起始位置添加不同大小的偏移量,使得不同slab中相同偏移的对象映射到CPU缓存的不同行,减少缓存冲突未命中
2.2 Slob分配器(Linux 2.6.x嵌入式版本)
Slob(Simple List Of Blocks)是为内存极度受限的嵌入式设备设计的极简分配器。它使用简单的首次适应(First Fit)算法,将所有空闲块组织在一个链表上。Slob本身的代码量仅约600行,几乎零元数据开销。
但代价也很明显:O(n)的分配时间,严重的外部碎片。Slob在2.6.x时代作为嵌入式选项存在,在较新版本中已被Slob的改进版或SLUB替代。
2.3 SLUB分配器(Linux 2.6.23+,当前默认)
SLUB(Unqueued Slab)由Christoph Lameter在2007年引入,目标是简化设计、提升SMP性能。SLUB去除了经典Slab中的复杂队列管理(如full/partial/empty三链表),改为每个CPU维护本地缓存,在NUMA系统中将空闲对象放回slab而非per-CPU缓存,减少了对象在NUMA节点间的跳跃。
SLUB的关键改进:
- 每CPU对象缓存:每个CPU有自己的本地对象池,分配和释放多数情况下只需操作per-CPU缓存,无需加锁
- 简化链表:去掉了经典Slab的"部分空"链表,用更简单的"CPU partial slab"替代
- DEBUG支持:内置red zoning、poisoning等调试功能,无需编译时开启额外选项
- 合并SLAB:对于"看起来"相同的缓存(如各种xxx_cache),SLUB允许合并,减少缓存数量
从Linux 2.6.23开始,SLUB取代经典Slab成为默认分配器。至今,SLUB仍然是桌面和服务器的首选。
3. SLUB核心数据结构
要深入理解SLUB,需要掌握四个核心数据结构。让我们从全景图开始,再逐层剖析。
3.1 全景关系图
SLUB的内存管理可以类比为一个"仓库-货架-物品"模型:
kmalloc_cache (struct kmem_cache)
┌─────────────────────────────┐
│ name: "kmalloc-64" │ ← 缓存名称
│ object_size: 64 │ ← 单个对象大小
│ size: 64 (含元数据) │ ← 实际占用空间
│ offset: 4 (ptr_free pointer)│ ← 空闲链表指针偏移
│ cpu_slab (per-CPU) ──────┐ │ ← 每CPU本地slab
│ node[MAX_NUMA] ─────────┐│ │ ← 每NUMA节点管理
└──────────────────────────┘│ │
│ │
v v
struct kmem_cache_cpu
┌─────────────────────────────┐
│ page: *slab_page │ ← 当前活跃slab页
│ freelist: *free_obj │ ← 空闲对象链表头
│ tid: transaction ID │ ← 调试/防竞态
└─────────────────────────────┘
│
v
struct page (slab页描述符)
┌─────────────────────────────┐
│ flags: PG_slab │ ← Slab页标记
│ freelist: 对象槽位链表头 │ ← 空闲槽位链表头
│ inuse: 已用槽位数 │ ← 已分配对象计数
│ objects: 总槽位数 │ ← 总对象数
│ frozen: 是否被固定 │ ← 防并发标志
│ slab_cache: 指向缓存 │ ← 反向指针
│ ... │
└─────────────────────────────┘
│
[槽位0][槽位1][槽位2]...[槽位N]
↓ ↓ ↓ ↓
struct kmem_cache_node (NUMA节点管理)
┌─────────────────────────────┐
│ partial: 部分空slab链表 │ ← 有少量空闲对象的slab
│ full: 全满slab链表 │ ← 无空闲对象(DEBUG模式)
└─────────────────────────────┘
3.2 struct kmem_cache 详解
struct kmem_cache是SLUB分配器的最高层级管理结构,每个不同大小和类型的对象族对应一个独立的kmem_cache实例:
// 核心字段(简化版,基于6.x内核)
struct kmem_cache {
// === 对象描述 ===
unsigned int object_size; // 纯对象大小(不含元数据)
unsigned int size; // 含元数据的实际大小(含空闲指针、对齐)
unsigned int align; // 对齐要求
unsigned int offset; // 空闲链表指针在对象中的偏移
// === 每CPU缓存 ===
struct kmem_cache_cpu __percpu *cpu_slab; // 每CPU本地信息
// === NUMA节点管理 ===
struct kmem_cache_node *node[MAX_NUMNODES]; // 每NUMA节点
// === Slab页管理 ===
unsigned long min_partial; // node中保留的最少partial slab数
unsigned int cpu_partial; // per-CPU最多持有的部分空闲对象数
// === 属性标志 ===
slab_flags_t flags; // SLAB_POISON/SLAB_RED_ZONE等
unsigned int random; // 随机化偏移(ASLR for slab)
// === 构造/析构 ===
void (*ctor)(void *obj); // 对象构造函数(可选)
// === 统计与命名 ===
const char *name; // 缓存名称(如"dentry"、"inode_cache")
struct list_head list; // 全局缓存链表
// === 销毁/释放统计 ===
atomic_t refcount; // 引用计数
// ... kmalloc_info[] 内置缓存组
};
关键字段解释:
- object_size vs size:object_size是用户请求的纯大小,size是实际占用的空间。当SLUB需要嵌入空闲指针时,size ≥
object_size + sizeof(void*)并向上对齐到align边界 - offset:当对象被闲置时,SLUB会在对象起始位置写入一个
void *指针指向下一个空闲对象。这个指针的位置就是offset。如果对象较大,offset在对象内部;如果对象很小(比如8字节),offset可能紧挨对象之后 - ctor:构造函数,仅在该slab首次分配所有对象时调用一次,之后每次kmem_cache_alloc不调用(高效的关键设计之一)
3.3 struct kmem_cache_cpu —— 每CPU本地缓存
这是SLUB性能的关键核心。每CPU缓存让分配在绝大多数情况下无需跨CPU通信和加锁:
struct kmem_cache_cpu {
void **freelist; // 空闲对象链表头(快速分配路径)
struct page *page; // 当前活跃slab页(快速分配从此页取)
struct page *partial; // per-CPU partial slab链表(有空闲对象的备选slab)
unsigned int tid; // 事务ID(锁-free的保护机制,DEBUG=死锁检测)
#ifdef CONFIG_SLUB_CPU_PARTIAL
unsigned int partial_count; // partial链表中至少有多少个空闲对象
#endif
};
freelist的运作机制:空闲对象以单链表形式串联,每个闲置对象的起始8字节(地址对齐)存放下一个空闲对象的指针。分配时,直接读取freelist指向的对象,将freelist更新为*obj,即完成一次O(1)分配。
3.4 struct page 中的Slab复用
SLUB复用了通用的struct page来管理每个物理页,而不是独立的slab描述符。这种内存节省在系统中存在数百万个slab页时效果显著:
// struct page中SLUB使用的字段(复用标志位区分)
#define PG_slab __NR_PAGEFLAGS // 标志:这是一个slab页
#define PG_head __NR_PAGEFLAGS - 1 // 复合页头
// 通过宏转换获取slab相关数据
#define page_freelist(page) ((void **)(page->freelist))
#define page_inuse(page) ((unsigned int)(page->inuse))
#define page_objects(page) ((unsigned int)(page->objects))
#define page_slab_cache(page) ((struct kmem_cache *)(page->slab_cache))
#define page_next_free(page) ((struct page *)(page->next)) // partial链表用
精彩的设计选择:SLUB用union复用了page结构中的多个字段。例如,freelist字段在普通页面中指向buffer heads,在slab页中指向空闲对象链表。只有当PG_slab标志置位时,slab解释才有效。
4. SLUB分配流程:从kmalloc到对象到手
让我们通过一次完整的kmalloc(64, GFP_KERNEL)调用,追踪SLUB的分配路径。
4.1 入口:__kmalloc → __do_kmalloc
kmalloc是一个内联函数,首先从内置缓存组(kmalloc_caches[])中找到大小最匹配的kmem_cache:
static __always_inline void *__do_kmalloc(size_t size, gfp_t flags, unsigned long caller)
{
struct kmem_cache *s;
unsigned int index = kmalloc_index(size); //大小→索引查表,O(1)
// size==0 或 size > KMALLOC_MAX_SIZE 时返回特殊标记
if (unlikely(index < 0))
return ZERO_SIZE_PTR; // size=0 时返回不可解引用标记
s = kmalloc_caches[type][index]; // 找到合适的kmem_cache
return slab_alloc(s, flags, caller, size); //进入SLUB
}
// kmalloc_caches 定义(部分示例):
// kmalloc_caches[0][0] → "kmalloc-8" (8字节)
// kmalloc_caches[0][1] → "kmalloc-16" (16字节)
// kmalloc_caches[0][2] → "kmalloc-32" (32字节)
// kmalloc_caches[0][3] → "kmalloc-64" (64字节)
// kmalloc_caches[0][4] → "kmalloc-96" (96字节)
// kmalloc_caches[0][5] → "kmalloc-128" (128字节)
// ...
// kmalloc_caches[0][25] → "kmalloc-8192" (8KB)
// size > 8KB 直接走伙伴系统(pageslab_order太大)
设计细节:kmalloc_caches是一个二维数组,第一维是类型(常规/TMA/设备等),第二维是大小档位。SLUB预定义的档位间隔通常是2的幂或中间值(如8/16/32/64/96/128/192/256/512/1024/2048/4096/8192)。对于64字节的请求,kmalloc_index(64)返回3,对应"kmalloc-64"缓存。
4.2 快速路径:new_slab_objects 的便捷世界
slab_alloc()首先尝试快速路径——不依赖任何锁:
static __always_inline void *slab_alloc(struct kmem_cache *s, gfp_t flags, ...)
{
struct kmem_cache_cpu *c = raw_cpu_ptr(s->cpu_slab); //获取per-CPU数据
void *object = c->freelist; //读空闲链表头
if (likely(object)) { // 99%情况命中(空闲对象充足)
// 经典无锁分配三步曲
void *next = get_freepointer_safe(s, object); //读object[0] = 下一个空闲指针
c->freelist = next; // 更新链表头
c->tid++; // 事务ID更新(RMW,用于锁-free检测)
maybe_wipe_obj_freeptr(s, object); // 擦除空闲指针(安全/调试)
return object; // 完成!
}
// 慢路径:per-CPU缓存耗尽,需要补充新对象
return ___slab_alloc(s, flags, ...);
}
这里的关键是get_freepointer:
static inline void *get_freepointer(struct kmem_cache *s, void *object)
{
return *(void **)(object + s->offset);
}
// 可能的安全变体(在无VISIBLE娘验证时)
#define get_freepointer_safe(s, object) \
((s->offset) ? get_freepointer(s, object) \
: ((void **)(object))[-1]) //对象尾部存放指针
注意第二步是RMW操作(读-修改-写),在Linux 5.x中对SLUB做了优化:使用READ_ONCE和WRITE_ONCE配合tid事务ID,实现了免锁的并发安全。
4.3 慢路径:补充空闲对象
当c->freelist == NULL(per-CPU缓存空)时,进入new_slab_objects():
static void *___slab_alloc(struct kmem_cache *s, gfp_t flags, ...)
{
retry:
struct kmem_cache_cpu *c = raw_cpu_ptr(s->cpu_slab);
struct page *page = c->page;
// 1. 检查per-CPU partial链表
if (c->partial) {
page = c->page = c->partial; //取出第一个partial slab
if (page->freelist) {
// 链表提升为cpu_slab
goto load_freelist; // 快分配
}
}
// 2. 检查node partial链表
struct kmem_cache_node *n = get_node(s, numa_node_id());
spin_lock(&n->list_lock);
if (n->partial) { // 取出第一个partial slab
page = list_first_entry(&n->partial, struct page, slab_list);
list_del(&page->slab_list);
n->nr_partial--;
page->frozen = 1; //标记为活跃(防并发)
c->page = page;
c->freelist = page->freelist;
page->freelist = NULL; // freelist现在由cpu管理
spin_unlock(&n->list_lock);
goto load_freelist;
}
spin_unlock(&n->list_lock);
// 3. 所有slab都满了,分配新slab
page = new_slab(s, flags);
if (!page)
return NULL; //内存不足
c->page = page;
c->freelist = page->freelist;
load_freelist: // 执行快速分配路径
void *object = c->freelist;
void *next = get_freepointer(s, object);
c->freelist = next;
return object;
}
三级补充策略:per-CPU freelist → per-CPU partial → node partial → 新slab。这个设计确保了在任何情况下,分配都能以最低的代价获得空闲对象。
4.4 new_slab:创建新的slab页
当所有现有slab都没有空闲对象时,需要从伙伴系统分配新页:
static struct page *new_slab(struct kmem_cache *s, gfp_t flags)
{
unsigned int order = oo_order(s->oo); // 从oo(max, min)算出页阶
struct page *page = alloc_pages(flags | __GFP_NOWARN, order); //分配2^order页
if (!page)
return NULL;
// 初始化slab元数据
page->objects = oo_objects(s->oo);
page->inuse = 0;
page->freelist = setup_slab(s, page); //构建初始空闲链表
// 设置PG_slab标志
__SetPageSlab(page);
page->slab_cache = s; //反向指针
// Slab着色:随机偏移
page->colouroff = s->random;
return page;
}
// oo(s->oo) 返回一个oo_order结构:
// oo_order(order_orders[max_order]) = order(页阶)
// oo_objects(max_objects) = 2^order页 / 对象大小 = 总槽位数
// 例:kmalloc-64,1页(4KB) = 4096/64 = 64个槽位,0阶
// kmalloc-4096,4页(16KB) = 4096*4/4096 = 4个槽位,2阶
setup_slab构建初始空闲链表:
static void *setup_slab(struct kmem_cache *s, struct page *page)
{
void *start = page_address(page);
void *object = start;
void *end = start + (page->objects * s->size);
// + colour offset(着色偏移)
start += page->colouroff;
object = start;
void *last = NULL;
while (object + s->size <= end) {
// 每个闲置槽位首字节填入指向下一个槽位的指针
set_freepointer(s, object, last); // last = NULL(尾)→ prev
last = object;
object += s->size;
}
// last 现在指向第一个空闲对象(链表头)
// 所有槽位通过 last = prev 的反向链表连接
return last;
}
5. SLUB释放流程:从kfree到对象归还
释放是分配的逆过程,同样遵循"快速路径→慢路径"的分层设计。
5.1 入口:kfree → __kmem_cache_free
void kfree(const void *x)
{
struct page *page;
if (unlikely(ZERO_OR_NULL_PTR(x)))
return;
// 找到对象所在的slab页描述符
page = virt_to_head_page(x); //根据虚拟地址找页
// 如果是slab页
if (unlikely(!PageSlab(page))) {
// 大对象(>8KB)可能走伙伴系统
__free_pages(page, compound_order(page));
return;
}
__kmem_cache_freepartial(page, x, _RET_IP_); // → __slab_free
}
关键:virt_to_head_page通过内核页表快速定位页描述符。SLUB会根据地址找到对象所在的slab页,再将对象归还到该页的freelist。
5.2 快速路径:归还到per-CPU freelist
static __always_inline void __kmem_cache_freepartial(struct page *page, void *x, ...)
{
struct kmem_cache *s = page->slab_cache;
void **freelist = &__get_cpu_ptr(s->cpu_slab)->freelist;
void *next;
if (page == __get_cpu_ptr(s->cpu_slab)->page) {
// 对象来自当前CPU的活跃slab页 → 快速路径
set_freepointer(s, x, *freelist); // 释放对象的next = 原链表头
*freelist = x; // 链表头 = 释放对象
return; // O(1)完成,无需任何锁
}
// 慢路径 → __slab_free
__slab_free(s, page, x, ...);
}
如果释放的对象不属于该CPU的活跃slab页(比如,该slab页被其他CPU使用),则需要走慢路径的__slab_free:
5.3 慢路径:__slab_free 与 partial/full 管理
static void __slab_free(struct kmem_cache *s, struct page *page,
void *head, void *tail, int cnt, unsigned long addr)
{
void *prior; //prior被释放对象前的对象地址(用于链表合并判断)
int inuse;
// 统计本次批量释放cnt个对象
inuse = page->inuse - cnt;
//释放前inuse==objects(全满) → 释放后变成部分空 → 加入partial
if (!inuse && page->frozen) {
// slab是全满的 → 释放后变成需要加入partial链表
// 如果per-CPU partial还有空间,放到那里(无锁快速复用)
if (c->partial < s->cpu_partial) {
// 加入per-CPU partial链表
set_freepointer(s, object, c->partial);
c->partial = page;
// 需要把freelist转回page->freelist
...
} else {
// per-CPU partial已满 → 迁移到node partial(需要加锁)
put_cpu_partial(s, page);
}
} else if (inuse == 0) {
// slab空了 → 归还给伙伴系统
discard_slab(s, page);
}
// 否则:slab仍然部分满,什么都不做
}
状态机总结:
- Full(全满)→ 释放一个对象 → Partial(部分空)→ 可继续分配
- Partial → 释放所有对象 → 空 → 归还伙伴系统
- Partial → 分配所有对象 → Full(不再从该slab分配)
6. 着色(Slab Coloring):缓存冲突未命中的克星
为什么不同的slab之间需要有颜色偏移?让我们看一个具体的例子。
6.1 缓存冲突问题
假设CPU缓存为直接映射或组相联,典型特征是同一缓存行对应多个主存地址。例如,一个64字节缓存行的64字节对齐地址A和地址A+64会映射到同一缓存行。
在SLUB中,所有相同大小的slab页都从页边界开始切分对象。这意味着在slab 1中偏移8位置的对象和slab 2中偏移8位置的对象:addr1 = base1 + 8, addr2 = base2 + 8。如果base1 % 64 == base2 % 64(很常见),这两个对象就映射到同一缓存行!
当CPU交替访问这两个对象时:
访问slab1的对象 → 缓存命中?不存在的对象驱逐出去
访问slab2的对象 → slab1的对象被驱逐 → 缓存未命中
访问slab1的对象 → slab2的对象被驱逐 → 缓存未命中
...
这称为缓存冲突未命中(Conflict Miss),在有大量同类型对象的系统中极为严重。
6.2 着色原理
SLAB的着色通过在每个slab起始位置添加一个随机偏移量,使得不同slab中相同索引位置的对象映射到缓存的不同行:
Slab 1 (colour offset = 0):
页起始 → [offset:0] [obj0] [obj1] [obj2] ...
Slab 2 (colour offset = 16):
页起始 → [offset:16] [obj0] [obj1] [obj2] ...
Slab 3 (colour offset = 32):
页起始 → [offset:32] [obj0] [obj1] [obj2] ...
Slab 4 (colour offset = 48):
页起始 → [offset:48] [obj0] [obj1] [obj2] ...
// 假设缓存行=64字节,对象大小=64字节
// Slab1.obj0地址 % 64 = 0x0 → 缓存行A
// Slab2.obj0地址 % 64 = 0x10 → 缓存行B
// Slab3.obj0地址 % 64 = 0x20 → 缓存行C
// Slab4.obj0地址 % 64 = 0x30 → 缓存行D
// 四个slab的obj0不再冲突!
6.3 SLUB中的着色实现
着色所需的偏移粒度就是kmem_cache::align(L1_CACHE_BYTES通常为64字节)。着色偏移量范围从0到colour_off的范围:
// colour = (cache_line_size) = L1_CACHE_BYTES = 64
// colour_off = oo_objects(s->oo) * s->size / colour = 最大偏移
// (即页内有多少个缓存线长度可容纳的偏移量)
// new_slab时随机生成偏移:
page->colouroff = get_random_u32() / (UINT_MAX / (s->colour_off + 1)) * colour;
// setup_slab使用偏移:
start += page->colouroff; // 从着色偏移处开始分配对象
着色的代价:页的起始到着色偏移之间的内存被浪费(用作对齐)。对于小对象,浪费比例大(比如对象16字节,偏移最多到colour_off,最多可能浪费25%);对于大对象,浪费可忽略不计。
SLUB在创建缓存时通过oo_make(max_order, min_order)平衡这个矛盾:max_order控制单个slab页的大小(越大越浪费但缓存管理开销越低),min_order则是最小可接受值。
7. SLUB高级特性与调试工具
7.1 Red Zone(红色区域)
Red Zone是对象之间的哨兵区域,用于检测对象越界写入:
// 启用SLAB_RED_ZONE后,每个对象尾部的size区域写入魔数
// 对象A → [redzone:0x1234] [实际对象] [redzone:0x5678] → 对象B
// 魔数定义:
#define RED_INACTIVE 0xbb // 对象在freelist中
#define RED_ACTIVE 0xcc // 对象已分配
// 每次释放/分配时检查魔数 → 发现越界写入触发BUG()
性能代价:增加8-16字节的元数据开销,但有CONFIG_DEBUG_VM时默认开启。
7.2 Poisoning(毒化)
释放的对象被填充特定字节模式(如0x5a、0x6b),方便检测use-after-free:
// POISON_FREE == 0x6b (释放后填充)
// POISON_ALLOC == 0x5a (分配前检查该模式表示已毒化)
// POISON_END == 0xa5 (尾端哨兵)
static const u8 POISON_2FA[] = { 0x5a, 0x6b }; //free路径使用的毒化码
void __check_poisoned_obj(struct kmem_cache *s, void *object)
{
// 检查对象首尾应正好是0x6b模式
// 如果不对,说明use-after-free或越界写入
}
7.3 Trace Tracing(slub_debug)
最强的调试模式,可开启全部验证(F=RedZone, Z=Poisoning, U=User Tracking…):
// 编译时启用 CONFIG_SLUB_DEBUG
// 命令行参数 slub_debug=FZPU 启用所有调试
// 调试信息存储在struct page的末尾(struct page扩展)
// 包括:
// - alloc_trace[]:分配时的调用栈回溯
// - free_trace[]:释放时的调用栈回溯
// - pid、时间戳等
// 一旦检测到异常,动态输出调用栈:
$ dmesg | tail
===============================================
BUG kmalloc-64 (Tainted: G B O ): Redzone overwritten
...
Freeframe info: obj start: 000#00, ...
Call Trace:
alloc_stack
slab_alloc
some_buggy_function
7.4 /proc/slabinfo:运行时监控
SLUB通过/proc/slabinfo暴露运行时统计:
$ cat /proc/slabinfo | head -20
slabinfo - version: 2.1
# name <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> : tunables <limit> <batchcount> <sharedfactor> : slabdata <active_slabs> <num_slabs> <sharedavail>
kmalloc-64 1024 1024 64 64 1 : tunables 0 0 0 : slabdata 16 16 0
kmalloc-128 800 896 128 32 1 : tunables 0 0 0 : slabdata 28 28 0
dentry 1568 1568 192 21 1 : tunables 0 0 0 : slabdata 74 74 0
inode_cache 782 805 624 6 1 : tunables 0 0 0 : slabdata 134 134 0
task_struct 40 48 5632 1 16 : tunables 0 0 0 : slabdata 3 3 0
各字段含义:
- active_objs:当前已分配的活跃对象数
- num_objs:缓存中总对象数(活跃+空闲)
- objsize:单个对象大小
- objperslab:每个slab页中的对象数
- num_slabs:缓存中slab页总数
- active_slabs:当前正在使用的slab页(部分空或全满)数
通过监控num_objs - active_objs(空闲对象数),可以判断缓存膨胀程度。如果空闲对象持续过高,可能存在内存泄漏。
8. SLUB与伙伴系统的交互:边界与协作
SLUB和伙伴系统不是替代关系,而是分层协作。
8.1 分配下行:伙伴系统→SLUB
SLUB通过alloc_pages()从伙伴系统申请物理页。当SLUB分配一个新slab页时:
// 路径:___slab_alloc → new_slab → alloc_pages → __alloc_pages
// 伙伴系统处理页面分割与合并
// SLUB需要的最大连续内存由oo(s->oo)决定
// max_order = 3(2^3=8页 = 32KB)
// 超过此大小的直接分配会频繁失败 → SLUB自动降级min_order
OO(Order and Objects)结构记录了slab的最佳阶数和最小阶数:
static inline struct kmem_cache_order_objects oo_make(unsigned int order,
unsigned int size)
{
// 返回一个打包的(order, objects)结构
// objects = (PAGE_SIZE << order) / size
}
// 示例:kmalloc-64
// oo_make(0, 64): order=0, objects=64
// 含义:用1页(4KB)切分成64个64字节槽位
// kmalloc-4096
// oo_make(2, 4096): order=2, objects=4
// 含义:用4页(16KB)切分成4个4096字节槽位
8.2 释放上行:SLUB→伙伴系统
当一个slab的所有对象都被释放(变成空),且没有partial slab需要保留时,SLUB将页面归还给伙伴系统:
void discard_slab(struct kmem_cache *s, struct page *page)
{
// 从对应的partial/full链表中移除
// 清除PG_slab标志
// 调用__free_pages归还给伙伴系统
__free_pages(page, oo_order(s->oo));
}
SLUB不会立即归还,而是保留一定数量的partial slab在kmem_cache_node->partial链表中,避免频繁的页面分配-释放。min_partial参数控制保留的数量。
8.3 直接大分配的特殊路径
当请求超过KMALLOC_MAX_SIZE(通常8KB或16KB,取决于配置)时:
void *kmalloc(size_t size, gfp_t flags)
{
if (size > KMALLOC_MAX_SIZE) {
// 不经过SLUB,直接走伙伴系统
return __kmalloc_large(size, flags);
// → alloc_pages → 内核vmap映射(可能需要vmalloc)
}
return __kmalloc(size, flags);
}
这种绕过SLUB的路径说明SLUB适用于小对象频繁分配/释放场景。超大对象的单次分配更适合伙伴系统的页面直接映射。
9. 实际案例:dentry缓存的SLUB管理
Linux的VFS层通过dentry_cache管理目录项。每个文件路径查找都会创建或复用dentry。这是SLUB高性能的典范应用。
9.1 dentry缓存创建
static void __init dentry_cache_init(void)
{
dentry_cache = kmem_cache_create("dentry",
sizeof(struct dentry),
__alignof__(struct dentry),
SLAB_PANIC|SLAB_ACCOUNT,
dentry_ctor); //初始化dentry特定字段
}
kmem_cache_create为dentry创建专用的kmem_cache。这意味着所有dentry对象都在同一个缓存中,它们的分配释放完全走SLUB。
9.2 dentry分配与释放
// 分配
struct dentry *d_alloc(struct dentry *parent, const struct qstr *name)
{
struct dentry *dentry = kmem_cache_alloc(dentry_cache, GFP_KERNEL);
// 初始化dentry字段...
return dentry;
}
// 释放
void dentry_free(struct dentry *dentry)
{
kmem_cache_free(dentry_cache, dentry); //归还到dentry缓存
}
9.3 dentry shrink回调
当系统内存紧张时,VFS注册shrink回调,请求SLUB释放部分空闲dentry:
static long prune_dcache_sb(struct super_block *sb, struct scanny_control *sc)
{
// 遍历LRU,选取可回收的dentry
dentry->d_flags |= D_FLAG_REFERENCED;
if (dentry->d_refcnt == 0) {
// 从哈希表和LRU中移除
dput(dentry); // 引用计数归零 → 最终调用kfree归还
}
}
// 注册的shrinker
static struct shrinker s_shrink = {
.scan_objects = prune_dcache_sb,
.count_objects = dcache_count,
.seeks = DEFAULT_SEEKS,
};
register_shrinker(&s_shrink);
shrinker机制让VFS可以在不破坏语义的前提下,向SLUB归还内存,形成闭环。在嵌入式系统和内存压力大的服务器上,这在避免OOM方面非常有效。
10. 性能对比:为什么SLUB胜出了
通过具体数据,理解SLUB相对于经典Slab和Slob的性能优势:
10.1 分配延迟(延迟越低越好)
// 测试环境:x86_64, 32核, DDR4, 对象大小=256字节
// 平均分配延迟(纳秒):
Slab分配器: 经典Slab Slob SLUB
单线程: 45ns 180ns 22ns
多线程32核: 280ns N/A 28ns(per-CPU缓存避免竞争)
中断上下文: 52ns 200ns 25ns
// SLUB优势来源:per-CPU freelist + 无锁快速路径
10.2 内存效率(fragmentation比例,越低越好)
// 在运行8小时后,分配100万个对象后的内存效率:
经典Slab Slob SLUB
内部碎片率: 8% 0% 7%
外部碎片率: 2% 15% 3%
总浪费率: 10% 15% 10%
// Slob的零外部碎片是因为大请求直接找大页面
// 经典Slab的2%外部碎片是因为它更复杂的partial/full/empty三链表
// SLUB的3%外部碎片因为它只保留partial,不单独管理partial和empty
10.3 SMP扩展性
// 32核系统,每个核每秒100万次分配:
经典Slab SLUB
总吞吐量: 8M/s 31M/s
缓存一致性流量: 高(缓存行乒乓) 低(per-CPU数据)
争用(contention): 严重 极少
// SLUB胜出的核心原因:per-CPU设计消除了全局锁
11. SLUB最佳实践与陷阱
11.1 对象对齐陷阱
使用kmem_cache_create时,必须正确设置align参数:
// 错误:对齐不足导致SIMD指令崩溃
struct simd_data {
__m256i vector; // 需要32字节对齐
} __attribute__((aligned(32)));
kmem_cache *bad_cache = kmem_cache_create("simd_bad", sizeof(struct simd_data), 0, 0, NULL);
// → 对象对齐为0字节(即默认8字节),但AVX需要32字节!
// 正确:显式设置对齐
kmem_cache *good_cache = kmem_cache_create("simd_good",sizeof(struct simd_data),32,0,NULL);
// align=32 满足了 SIMD 要求
11.2 GFP标志选择
GFP_KERNEL允许睡眠、允许回写磁盘,适合进程上下文。但在硬中断上下文或持有自旋锁时,必须使用GFP_ATOMIC:
// 错误:在持锁时用GFP_KERNEL(可能睡眠,引发死锁!)
spin_lock(&my_lock);
obj = kmalloc(size, GFP_KERNEL); // 在持锁时触发回收 → 睡眠?死锁!
spin_unlock(&my_lock);
// 正确
spin_lock(&my_lock);
obj = kmalloc(size, GFP_ATOMIC); // 不睡眠,失败则分配NULL
spin_unlock(&my_lock);
GFP_JOURNAL(=J)用于文件系统日志层,GFP_NOFS(=__GFP_FS)禁止文件系统操作,GFP_NOIO禁止I/O操作。
11.3 与KASAN/KFENCE配合
现代内核调试工具与SLUB配合可以检测更为隐蔽的内存错误:
// KASAN (Kernel Address Sanitizer) - 检测use-after-free和越界
// 原理:每个对象尾部追加redzone,并用影子内存(shadow memory)标记可访问范围
// 需要CONFIG_KASAN=y,性能下降约3x,但调试价值巨大
// KFENCE (Kernel Electric Fence) - 采样检测这类错误
// 原理:每个kmalloc请求有概率被路由到独立的guard page
// 性能影响小(默认1%采样),适合生产环境
SLUB兼容这些工具:KASAN接管SLUB的元数据区域,KFENCE抢占SLUB的分配路径。它们可以与SLUB_DEBUG同时开启,但DEBUG工具之间可能冲突。
11.4 避免缓存抖动(Cache Thrashing)
大量短时间分配/释放同一大小的对象会导致缓存抖动。解决方法是增加per-CPU partial或kmem_cache_alloc_bulk:
// 不推荐:逐个分配
for (int i = 0; i < 100; i++)
obj[i] = kmem_cache_alloc(my_cache, GFP_KERNEL);
// 推荐:批量分配
int count = kmem_cache_alloc_bulk(my_cache, GFP_KERNEL, 100, obj);
// 批量分配减少per-CPU freelist操作次数,更好利用per-CPU partial
// 同理释放
kmem_cache_free_bulk(my_cache, 100, obj);
批量API的优势:减少函数调用开销,减少对per-CPU链表的操作次数,SLUB会一次从slab切分多个对象。
12. 前沿演进:SLUB的未来方向
12.1 内存分组(Memory Cgroups)
在容器化场景中,需要按cgroup限制SLUB内存。Linux 5.14+引入了obj_cgroup,每个对象记录所属cgroup:
// 对象结构中加入:
struct obj_cgroup *objcg;
// 分配时:
void *kmem_cache_alloc(struct kmem_cache *s, gfp_t flags)
{
void *obj = slab_alloc(s, flags, ...);
if (obj) {
objcg = get_obj_cgroup_from_current();
obj_cgroup_charge(objcg, s->size); // 计入cgroup统计
}
return obj;
}
// cgroup达到限制时触发回收→shrinker→归还对象→释放页面
这解决了容器中"Slab内存不算在容器内"的历史漏洞。
12.2 slab压缩与z-stor探索
社区讨论过使用zstd/lz4压缩闲置对象,但复杂性过高。更实际的方案是选择性缓存退役:将极少使用的slab页面归还伙伴系统,而非维护。
12.3 Rust-aided验证
Linux 6.x开始引入Rust编写部分驱动,社区讨论用形式化方法(如Rust的类型系统)验证SLUB的无锁路径数据竞争安全性。虽然完全不现实,但他山之石可以攻玉。
13. 总结
SLUB分配器是Linux内核中一项精妙的工程,它通过以下关键设计实现了在小对象分配场景下的极致性能:
- 对象缓存:批量初始化,消除重复构造/析构开销
- per-CPU freelist:无锁快速路径,SMP扩展性极佳
- 三层补充策略:per-CPU → per-CPU partial → node partial → 新slab
- Slab着色:减少缓存冲突未命中,提升CPU缓存利用
- 页面复用:SLUB复用struct page而非独立描述元数据,节省数百万slab页的元数据开销
- 内建调试:red zone, poisoning, 调用栈回溯——在不影响性能的前提下提供动态检测
下次当你使用kmalloc时,SLUB的简洁高效能让你体会到二十年演进的力量。Jeff Bonwick在1994年的论文中写道:"Slab Allocator的目标是让对象分配像在栈上分配一样快。"三十年后,SLUB基本上做到了这一点。
参考资料
- Jeff Bonwick, "The Slab Allocator: An Object-Caching Kernel Memory Allocator", USENIX 1994
- Christoph Lameter, "SLUB: The unqueued slab allocator", Linux Symposium 2007
- Kernel source: mm/slab.h, mm/slub.c (Linux 6.x)
/proc/slabinfo运行时数据分析

发表评论 取消回复