引言:为什么需要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运行时数据分析

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
0.366026s