引言

在 Linux 内核的庞大子系统中,内存管理无疑是最核心、最复杂的模块之一。而在内存管理的"最后一公里"——小对象分配与释放领域,SLUB(Simplified Linux Allocator)分配器扮演着至关重要的角色。作为内核默认的通用内存分配器,SLUB 承载着内核中绝大多数 kmalloc() 调用的背后工作。理解 SLUB 的设计哲学、内部数据结构和性能优化手段,对于内核开发者、系统调优工程师以及任何对底层系统感兴趣的程序员来说,都是一项必备的硬核技能。

本文将深入 SLUB 的内核实现,从基本概念入手,逐步剖析其核心数据结构、分配/释放流程、per-CPU 缓存机制、调试与追踪能力,并结合实际的工程案例,展示如何通过 SLUB 信息诊断内存泄漏和性能瓶颈。

一、SLUB 的历史演进与设计目标

1.1 从 SLAB 到 SLUB 的演变

Linux 内核的小对象分配器经历了三代演变:

  • SLAB(1994 年,Jeff Bonwick 为 Solaris 设计,后被 Linux 引入):最早的面向对象内存分配器,通过缓存常用对象的已初始化内存块来避免频繁的构造/析构开销。Linux 内核早期的 SLAB 实现由 "SLAB: A Memory Allocator for Solaris" 演化而来。
  • SLUB(2007 年,Christoph Lameter 提出):针对 SLAB 在多 NUMA 节点、多 CPU 场景下 metadata 过于复杂的问题,进行了大幅简化。SLUB 的核心思想是"把 page 直接嵌入到 slab 管理结构中",从而减少元数据开销。自 2.6.23 起成为默认分配器。
  • SLOB(Simple List Of Blocks):专为嵌入式等内存极小环境设计的极简分配器,使用 first-fit 算法,代码量极小但碎片严重。

1.2 SLUB 的设计哲学

SLUB 的核心设计原则可以概括为几点:

  • 极简 metadata:不维护独立的 slab 管理结构,将 slab 管理信息直接存储在 page 结构体中(通过 page->freelist 和 page->inuse 等字段)
  • Per-CPU 缓存:每个 CPU 维护一个本地 slab 缓存,消除大部分情况下的锁竞争
  • NUMA 感知:结合伙伴系统的 NUMA 策略,优先从同节点分配
  • 调试内置:内建 red zone、poison、tracking 等调试机制,零成本支持 KASAN

二、核心数据结构

2.1 struct kmem_cache

kmem_cache 是 SLUB 的核心描述符,每个"缓存类型"(如 task_struct、dentry、inode 等)对应一个。关键字段如下:

struct kmem_cache {
    struct kmem_cache_cpu __percpu *cpu_slab;  // Per-CPU slab 缓存
    struct kmem_cache_node *node[MAX_NUMNODES]; // 每个 NUMA 节点的 kmem_cache_node
    
    unsigned int size;       // 对象实际大小(含对齐)
    unsigned int object_size; // 用户请求的对象原始大小
    unsigned int offset;     // 下一个空闲对象指针的偏移
    unsigned int order;      // 每个 slab 占据的 page 阶数(allocation order)
    
    slab_flags_t flags;      // 创建时的标志(如 SLAB_ACCOUNT, SLAB_PANIC)
    
    unsigned int min_partial; // 节点 partial slab 最小保留数
    int refcount;            // 引用计数(用于合并和销毁)
    
    void (*ctor)(void *);    // 对象构造函数
    const char *name;        // 缓存名称(如"task_struct")
};

2.2 struct kmem_cache_cpu

每个 CPU 维护一个 kmem_cache_cpu 结构体,它是 SLUB 分配热路径的核心:

struct kmem_cache_cpu {
    void **freelist;   // 指向下一个空闲对象的指针(LIFO 链表)
    unsigned long tid; // 全局事务 ID,用于检测 CPU 迁移后的竞态
    struct page *page; // 当前 CPU 正在使用的 slab page
    struct page *partial; // CPU 本地的 partial slab 链表(CONFIG_SLUB_CPU_PARTIAL)
};

关键设计点:

  • freelist 指针:指向下一个可分配的对象。LIFO 顺序有利于缓存局部性。
  • tid(Transaction ID):每次 alloc/free 操作递增。如果 CPU 在 alloc 和 free 之间被抢占并迁移到其他 CPU,tid 不匹配会触发慢路径重试,避免数据竞争。
  • cpu partial:当一个 slab 的所有对象都被分配完(或全部释放)时,不会立即归还给节点,而是暂存在 CPU 本地,减少链表操作开销。

2.3 struct kmem_cache_node

每个 NUMA 节点拥有一个 kmem_cache_node,管理该节点上所有的 full slab 和 partial slab:

struct kmem_cache_node {
    spinlock_t list_lock;  // 保护 partial 和 full 链表的自旋锁
    
#ifdef CONFIG_SLUB
    unsigned long nr_partial;     // partial slab 数量
    struct list_head partial;     // partial slab 链表(slabs 中部分对象被使用)
#endif
    
    nr_slabs;             // 该节点上 slab 总数
    nr_slabs_partial;     // partial slab 数(仅 debug 模式)
};

三、分配与释放流程详解

3.1 分配热路径(kmalloc → SLUB alloc)

SLUB 的分配流程可以分为快速路径和慢速路径两个阶段:

// 快速路径(无锁)—— 约 99% 的分配走这里
static __always_inline void *slab_alloc(struct kmem_cache *s,
        gfp_t gfpflags, unsigned long addr, struct kmem_cache_cpu *c)
{
    void *object;
    
retry:
    object = c->freelist;    // 1. 读取 CPU freelist
    if (!object)
        goto new_slab;       // freelist 为空,走慢路径
    
    c->tid = next_tid(c->tid); // 2. 递增事务 ID
    c->freelist = get_freelist_ptr(s, object); // 3. 解引用获取下一个指针
    
    // 内存屏障 + tid 校验(检测 CPU 迁移竞态)
    if (unlikely(!this_cpu_cmpxchg_double(...)))
        goto retry;
    
    return object;           // 4. 返回分配好的对象

new_slab:
    // 慢路径:从 CPU partial → 节点 partial → 伙伴系统 获取新的 slab
    object = ___slab_alloc(s, gfpflags, addr, c);
    return object;
}

整个流程的关键步骤:

  1. 从 cpu_slab->freelist 直接取对象指针 → O(1)
  2. 如果 freelist 为空,先看 cpu_slab->partial 是否有可用 slab
  3. 如果 cpu partial 也为空,从 kmem_cache_node->partial 链表取一个 partial slab
  4. 如果节点也没有 partial slab,向伙伴系统申请新的 page(new_slab)
  5. 最后更新 freelist 和返回对象

在快速路径上,整个过程只需几条汇编指令,无任何锁操作。实测在 x86_64 上,一次 SLUB 快速分配大约耗时 20-40ns,接近一个 L2 cache miss 的延迟。

3.2 释放流程(kfree → SLUB free)

static __always_inline void slab_free(struct kmem_cache *s, void *x,
        unsigned long addr)
{
    struct page *page = virt_to_head_page(x);
    struct kmem_cache_cpu *c = raw_cpu_ptr(s->cpu_slab);
    void *prior;
    
    // 热路径:对象归还到 CPU 本地 slab
    do {
        prior = READ_ONCE(c->freelist);
        // 将对象的 freelist 指针指向之前的 freelist
        set_freelist_ptr(s, x, prior);
        c->tid = next_tid(c->tid);
    } while (unlikely(!this_cpu_cmpxchg_double(...)));
    
    // 如果此时 slab 之前是 full(所有对象都被占用),
    // 释放后变成 partial,需要挂回 node->partial 链表
    if (unlikely(!prior)) {
        // 需要加 node->list_lock
        add_partial(node, page, tail);
    }
}

释放的关键点在于"prior == NULL"判断:如果释放前 slab 的所有对象都是 in-use 的(freelist 为空,即该 slab 在 full 链表上),释放一个后 slab 变成 partial,需要挂回到 node->partial 链表,后续可以从那里分配。这个操作需要加 list_lock。

3.3 CPU Partial 缓存机制

CONFIG_SLUB_CPU_PARTIAL 是 SLUB 的一个关键性能优化。当一个 slab 的所有对象都被释放时(freelist 恢复为完整),SLUB 不会立即把它归还给节点或伙伴系统,而是放到 cpu_slab->partial 链表上。后续该 CPU 需要分配对象时,可以优先从 cpu partial 获取。

CPU partial 的两个阈值:

  • 目标数量:slub cpu partial 软上限(通常为 5),超过后会把多余的 slab 推回节点
  • 催还策略:当 CPU partial 数量超过阈值时,在每次 alloc 或 free 操作末尾会触发"unfreeze"操作,将部分 slab 交还给节点

四、SLAB 合并(Slab Merging)

Linux 内核存在大量的 kmem_cache,如果每种对象大小都创建独立的 cache,会浪费大量内存用于 metadata 和 partial slab 的碎片。因此内核引入了 SLAB 合并机制。

创建新的 kmem_cache 时(kmem_cache_create_usercopy 或 kmem_cache_create),SLUB 会先尝试查找一个"相似"的已有缓存进行合并:

  • 大小相近(在 s->size 的 1/8 范围内)
  • 对齐要求一致
  • 构造函数要么都有要么都没有(避免析构不一致)
  • flags 兼容

合并的好处是显而易见的:减少 partial slab 浪费、降低 metadata 开销、提高缓存命中率(同一个 cache 上的对象更容易在 CPU cache 中命中)。

可以通过 /proc/slabinfo 观察合并效果:

$ grep dentry /proc/slabinfo
dentry          23809  25024    192   21    1 : tunables  0   0   0 : slabdata   1192   1192    0

五、调试与追踪机制

5.1 Red Zone 检测

SLUB 默认在用户数据区域的前后各插入一段 "red zone"(哨兵区域),填充固定的魔数 0x12(RED_ACTIVE)表示已分配,0x56(RED_INACTIVE)表示已释放。访问越界时魔数被破坏,内核会在下次 alloc/free 时检测到并报告:p>

[  +0.000000] SLUB: Padding overwritten. 0x0000000000000056-0x0000000000000056
[  +0.000000] INFO: 0x00000000cdbf2820-0x00000000cdbf2825. First byte 0x56 instead of 0xcc

5.2 Poison 毒化

释放的对象被填充为 0x6b(POISON_FREE)或 0x5a(POISON_END)。如果在分配出的对象中看到了 poison 值,说明存在 use-after-free 或未初始化的访问。

5.3 Fault Injection

CONFIG_FAULT_INJECTION 结合 SLUB_DEBUG 可以模拟分配失败:

# 让分配有 5% 的概率失败
echo 5 > /sys/kernel/debug/failslab/probability
echo 100 > /sys/kernel/debug/failslab/times

这常用于测试分配失败路径的正确性。

5.4 Kmemleak 内存泄漏检测

CONFIG_DEBUG_KMEMLEAK 结合 SLUB 可以实现运行时内存泄漏扫描:

# 触发一次内存泄漏扫描
echo scan > /sys/kernel/debug/kmemleak

# 查看泄漏报告
cat /sys/kernel/debug/kmemleak

Kmemleak 通过扫描整个内存,查找哪些已分配的对象不再被任何指针引用(且无扫描区域内的指针指向它),然后按分配调用栈分组报告。

六、性能调优实战

6.1 查看 SLUB 统计信息

# 基础 slabinfo
cat /proc/slabinfo

# 详细 SLUB 统计(调试用)
cat /sys/kernel/slab/kmalloc-64/alloc_from_node  # 各节点分配计数
cat /sys/kernel/slab/kmalloc-64/cpu_partial       # CPU partial slab 数量
cat /sys/kernel/slab/kmalloc-64/partial           # 节点 partial slab 数量

6.2 调优参数

参数路径说明
cpu_partial/proc/sys/vm/slub_cpu_partialCPU partial slab 的最大数量,越大越减少锁竞争但增加内存占用
min_partial/sys/kernel/slab/*/min_partial节点 partial slab 最小保留数,太少会频繁申请新 slab
order/sys/kernel/slab/*/order每个 slab 的 page order,增大可减少内存碎片但增加单次分配失败风险
cache_order/proc/sys/vm/slub_cache_order自动计算 optimal order 时使用的基准阶数

6.3 高并发场景调优

在高并发网络服务器场景中,SLUB 的锁争用可能成为瓶颈:

# 增加 CPU partial 缓存,减少 list_lock 竞争
echo 32 > /proc/sys/vm/slub_cpu_partial

# 增大节点 partial 保留数,减少向伙伴系统申请新 slab 的频率
echo 8 > /sys/kernel/slab/sock_inode_cache/min_partial

# 使用 SL_HWCACHE_ALIGN 创建 cache,保证对象 cache line 对齐
kmem_cache_create("my_struct", sizeof(struct my_struct),
                  L1_CACHE_BYTES, SLAB_HWCACHE_ALIGN, NULL);

七、SLUB 与 eBPF:可观测性的新武器

随着 eBPF 的成熟,SLUB 的内核行为可以通过 kprobe/tracepoint 进行无侵入式观测:

// eBPF 程序追踪 SLUB 分配延迟
SEC("kprobe___slab_alloc")
int trace_slab_alloc(struct pt_regs *ctx) {
    u64 ts = bpf_ktime_get_ns();
    u64 pid = bpf_get_current_pid_tgid();
    
    // 记录开始时间
    start.update(&pid, &ts);
    return 0;
}

SEC("kretprobe___slab_alloc")
int trace_slab_alloc_ret(struct pt_regs *ctx) {
    u64 *tsp, delta;
    u64 pid = bpf_get_current_pid_tgid();
    
    tsp = start.lookup(&pid);
    if (!tsp) return 0;
    
    delta = bpf_ktime_get_ns() - *tsp;
    // 统计到 histogram
    alloc_lat.increment(bpf_log2l(delta));
    start.delete(&pid);
    return 0;
}

BPF 工具链(如 bpftrace、BCC)可以一行命令实现类似追踪:

# 追踪 kmalloc 调用的延迟分布
bpftrace -e 'kprobe:___slab_alloc { @start[tid] = nsecs; } 
             kretprobe:___slab_alloc /@start[tid]/ { 
                 @us = hist((nsecs - @start[tid]) / 1000); 
                 delete(@start[tid]); 
             }'

八、与其他分配器的对比

特性SLABSLUBSLOB
默认选择否(2.6.23前是)是(3.0+)仅嵌入式
元数据复杂度高(独立管理结构)低(嵌入 page)极低
NUMA 支持复杂原生友好无
调试能力强最强弱
代码复杂度~2500行~3000行~300行
分配延迟中低(最快)不定(first-fit 遍历)
碎片控制好好(自动合并)差

结语

SLUB 作为 Linux 内核最核心的内存分配子系统,其设计简洁而精妙。从 per-CPU 缓存消除锁争用,到 CPU partial 机制隐藏 slab 间移动开销,再到内置的 red zone/poison 调试能力,每一处设计都体现了"追求极致热路径性能"的内核开发哲学。

理解 SLUB 不仅有助于排查内存相关故障(如内存泄漏、越界访问、use-after-free),更能帮助我们在上层应用开发中做出更合理的内存使用决策——比如避免频繁的小对象分配(可通过 SLAB 分配器缓解)、或利用 SLAB 合并减少内存浪费。

对于希望深入 Linux 内核的开发者来说,《Understanding the Linux Kernel》第 8 章、《Professional Linux Kernel Architecture》第 5 章以及内核源码的 mm/slub.c 都是极佳的学习材料。结合实际系统的 /proc/slabinfo 观测和 eBPF 追踪,你将真正掌握这把内存管理的"手术刀"。

点赞(0) 打赏

评论列表 共有 0 条评论

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

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部