Linux 内核 Slab 分配器深度实战:从伙伴系统到对象缓存架构

引言:为什么需要 Slab 分配器

Linux 内核内存管理是一个精密的层次化体系。在最底层,伙伴系统(Buddy System) 以页为单位(通常 4KB)管理物理内存,通过二分拆分与合并来解决外部碎片问题。然而,内核中大量对象的尺寸远小于一页——task_struct 约 1.7KB,inode 约 580B,dentry 约 192B。如果直接由伙伴系统分配,内部碎片将浪费大量内存(一页 4KB 存放一个 192B 的 dentry,浪费率超过 95%)。

更致命的是性能问题。高频创建/销毁小对象会产生三个瓶颈:

  1. 页分配开销:每次都要经过伙伴系统的加锁、拆分、链表操作
  2. 初始化成本:内核对象构造函数(如 task_struct 的初始化逻辑)代价高昂
  3. 内存碎片:频繁分配释放导致伙伴系统产生不可合并的小碎片

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():

  1. 检查 cpu_partial:CPU 私有部分链表有可用 slab → 切换 c->page
  2. 检查 node->partial:本节点部分空闲 slab 链表非空 → 取出一个
  3. new_slab():调用伙伴系统 alloc_pages() 分配新页组,初始化 slab
  4. 伙伴系统分配:对于大对象(>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 → (未来方向):

  1. Per-CPU 批量分配:借鉴 userspace 分配器(mimalloc/jemalloc)的 batch 策略,每次 fetch_and_add 一次性拿走多个对象,减少 CAS/atomic 竞争
  2. Partial slab 预取:根据 CPU 运行队列长度动态调整每个 CPU 的 partial 配额,避免突发流量时的 freelist 弹尽
  3. 基于 eBPF 的实时监控:跟踪 kmem_cache_alloc/free 的延迟和频率,构建 slab 分配器的 P99 实时热力图
  4. 内存去重(KSM for slab):对内容相同的 slab 页进行合并去重,适用于容器全同镜像场景
  5. 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 分配器是必修课。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
/* 跳过导航链接 (无障碍) */ position: absolute; top: -100px; left: 15px; z-index: 99999; padding: 8px 16px; background: #007bff; color: #fff; font-size: 14px; border-radius: 0 0 4px 4px; text-decoration: none; transition: top 0.2s; } top: 0; outline: 3px solid #0056b3; }