Linux 内核 SLUB 分配器深度实战:页级内存管理、KASAN/KFENCE 与生产事故分析

Linux 内核的小块内存分配器——SLUB(Unqueued Slab Allocator)是系统性能的基石。从 kmalloc() 到 kmem_cache_alloc(),SLUB 决定了每个内核对象分配的延迟和内存效率。本文从 SLUB 的三层架构设计出发,深入剖析快速路径的 lockless 实现、页级回退策略、KASAN/KFENCE 集成机制,最终通过生产环境中的真实事故案例,展示如何定位和修复内存损坏与 OOM 风暴。


1. 为什么需要了解 SLUB

SLUB 分配器在内核中的位置至关重要:每个 task_struct、dentry、inode、socket 缓冲区等核心对象都依赖它。在 NUMA 服务器(通常 2-8 路)上,SLUB 的跨节点分配策略直接影响内存访问延迟。在容器密度极高(单机 500+ 容器)的云原生场景中,SLUB 缓存的碎片化和不可回收 slab 页可能引发级联式 OOM。

理解 SLUB 不仅是内核开发者的必修课,更是每个系统工程师排查内存泄漏、内存损坏和 OOM 问题的核心技能。


2. SLUB 的三层架构

SLUB 采用经典的三层对象分配模型:

┌─────────────────────────────────────────┐
│  kmalloc()  →  通用大小缓存(size cache)│
├─────────────────────────────────────────┤
│  kmem_cache_alloc()  →  专用缓存         │
├─────────────────────────────────────────┤
│  Buddy Allocator  → 页级分配 (alloc_pages)│
└─────────────────────────────────────────┘

2.1 核心数据结构

include/linux/slub_def.h 定义了 SLUB 最核心的数据结构:

struct kmem_cache {
    /* 每 CPU 快速路径缓存 */
    struct kmem_cache_cpu __percpu *cpu_slab;

    /* 全局参数 */
    unsigned long object_size;      // 对象原始大小
    unsigned long size;             // 对齐后大小(含 red zone)
    unsigned long offset;           // 下一个空闲对象指针的偏移
    unsigned int offset_free_ptr;   // 空闲链表指针偏移

    /* 完整/部分/空 slab 链表 */
    struct kmem_cache_node __percpu *node[MAX_NUMNODES]; // NUMA node

    struct kmem_cache_order_objects oo; // min/low/high 阶分配参数
    struct kmem_cache_order_objects max;
    struct kmem_cache_order_objects min;

    slab_flags_t flags;             // 对象对齐/追踪标志
    unsigned int allocflags;
    unsigned int inuse;             // 用户可见大小
    unsigned int align;
    const char *name;
    ...
};

struct kmem_cache_cpu {
    void **freelist;        // 空闲对象链表头
    unsigned long tid;      // 版本号,防止 ABA
    struct page *page;      // 当前正在使用的 slab 页
    struct page *partial;   // 部分空闲 slab 链表头(per-CPU partial)
};

2.2 Slab 页与对象布局

SLUB 将一个或多个连续物理页(compound page)组织为一个 slab,所有对象紧密排列:

static inline void *get_freep(struct kmem_cache *s, void *object)
{
    return *(void **)(object + s->offset);
}

关键设计:空闲对象的第一个 8 字节被复用为链表指针——这是 SLUB 相比 SLOB 更高效的"内联空闲链表"。

Slab Page (2^n 连续页):
┌─────────────────────────────────────────────────────┐
│ Object 0 │ Object 1 │ Object 2 │ ... │ Object N  │
│ [data]   │ [data]   │ [data]   │     │ [freelist]│
│          │          │          │     │ →next     │
└─────────────────────────────────────────────────────┘
返回给用户    ↑ 通过 page_address(page) + index * size 定位

3. 快速路径:Lockless 分配

SLUB 的分配快速路径完全无锁,依赖 cmpxchg 和 TID(Transaction ID)实现并发安全。

3.1 核心快速路径代码

mm/slub.c 中的 __slab_alloc() 展示了这一机制:

static __always_inline void *slab_alloc_node(struct kmem_cache *s,
        gfp_t gfpflags, int node, unsigned long addr)
{
    struct kmem_cache_cpu *c;
    struct slab *slab;
    void *object;
    unsigned long tid;

redo:
    c = raw_cpu_ptr(s->cpu_slab);
    tid = READ_ONCE(c->tid);       // 读取当前事务 ID
    barrier();
    slab = READ_ONCE(c->page);     // 当前 slab 页
    object = c->freelist;          // 空闲链表头

    if (unlikely(!object || !node_match(slab, node))) {
        /* 快速路径失败 → 慢速路径 */
        object = __slab_alloc(s, gfpflags, node, addr, c);
        if (unlikely(object == NULL))
            goto=goto;
    } else {
        /* 无锁快速路径:cmpxchg 抢夺 freelist 头 */
        void *next = get_freepointer_safe(s, object);
        if (unlikely(!this_cpu_cmpxchg_double(
                s->cpu_slab->freelist, s->cpu_slab->tid,
                object, tid,     // 期望值:当前 freelist + tid
                next, tid + 1)))  // 新值:next 指针 + tid+1
            goto redo;          // CAS 失败,重试
    }
    ...

    return object;
}

这个设计有两个关键点:

  1. 每 CPU 缓存:cpu_slab 是 per-CPU 变量,消除 CPU 间竞争。高并发场景下,99% 以上的分配走快速路径,无需任何锁。

  2. TID 版本号防止 ABA:cmpxchg_double 同时比较和更新 freelist 指针与 tid。如果其他 CPU 在两次读取之间修改了 freelist,tid 会变化,CAS 失败,重试 redo 标签。

3.2 释放路径

释放时同样使用无锁快速路径,将对象插入 per-Cpu freelist 链表头部:

static __always_inline void slab_free(struct kmem_cache *s, struct page *page,
                                      void *head, void *tail, int cnt)
{
    ...
    do {
        tid = this_cpu_read(s->cpu_slab->tid);
        old = READ_ONCE(*(void **)tail); // old = NULL (尾节点)
        barrier();
    } while (!this_cpu_cmpxchg_double(s->cpu_slab->freelist,
                                       s->cpu_slab->tid,
                                       head, tid,   // 期望
                                       object, tid + 1)); // 新值
}

当 per-CPU 的部分空闲 slab 链表过长时(超过 cpu_partial_slabs 阈值),SLUB 将 slab 迁移到 node partial 链表或释放回 buddy:

// 将 per-CPU partial slab 放回 node partial
static void put_cpu_partial(struct kmem_cache *s, struct page *page, int drain)
{
    ...
    do {
        oldpage = READ_ONCE(c->partial);
        pprevious = page_address(oldpage);
    } while (cmpxchg_double(&c->partial, &c->pprevious,
                            oldpage, pprevious,
                            page, oldpage));
}

4. 页级内存分配:阶、回退与NUMA

当 per-CPU freelist 为空时,SLUB 需要从 buddy 获取新 slab 页。这里的高阶分配策略直接影响碎片化和延迟。

4.1 oo(Order Order)参数

每个 kmem_cache 的 oo(order-object)由三个参数控制:

// 计算 oo 值
static inline unsigned int oo_order(struct kmem_cache_order_objects x)
{
    return x.x >> OO_SHIFT;
}

static inline unsigned int oo_objects(struct kmem_cache_order_objects x)
{
    return x.x & ((1 << OO_SHIFT) - 1);
}

内核初始化时根据对象大小自动计算:

  • 小对象(< 1/8 page):order=0,每页尽可能多分配对象
  • 中等对象:order=1(4页/16KB)或 order=2(8页/32KB)
  • 大对象:可通过 oo(high order 尝试)和 min(最低保证)参数配置
// 关键配置示例
struct kmem_cache *kmem_cache_create(const char *name, unsigned size,
                                      unsigned align, slab_flags_t flags,
                                      void (*ctor)(void *))
{
    ...
    // SLUB 会根据 size 计算 oo/min
    s->oo = oo_make(get_order(size), sl_size);  // high order 尝试
    s->min = oo_make(get_order(size), min_size); // 最低保证
}

4.2 NUMA 分配策略

在 NUMA 架构下,SLUB 通过 kmem_cache_node 结构维护每个节点的 partial 链表:

struct kmem_cache_node {
    spinlock_t list_lock;
#ifdef CONFIG_SLUB_CPU_PARTIAL
    unsigned long nr_partial;        // 部分空闲 slab 数量
#endif
    struct list_head partial;        // 部分空闲 slab 链表
    unsigned long nr_slabs;          // 该 cache 总 slab 数
    unsigned long total_objects;     // 总对象数
};

分配时的 NUMA 节点选择策略优先级:

  1. 节点匹配:先尝试从请求节点上的 partial 链表获取
  2. 快速路径无对象:回退到其他节点的 partial 链表
  3. 所有节点均无 partial:调用 new_slab() 从 buddy 获取新页,优先从请求节点

这一策略使 NUMA 服务器上本地内存访问比例提升 30-50%,对数据库等 NUMA 敏感工作负载至关重要。


5. KASAN:内核地址消毒集成

KASAN(Kernel Address Sanitizer)是内核最强大的内存错误检测工具之一。SLUB 与 KASAN 深度集成,在每对象周围插入 red zone 区域,检测越界访问和 use-after-free。

5.1 KASAN 的 Red Zone 实现

// SLUB 与 KASAN 交互的核心钩子
static inline void kasan_cache_create(struct kmem_cache *s,
                                       unsigned long *size,
                                       slab_flags_t *flags)
{
    if (s->flags & SLAB_KASAN) {
        // 增加两个 red zone 区域
        s->object_size += 2 * KASAN_RED_ZONE_SIZE;  // *size = 用户大小 + 2*128
        // 实际对象大小 = 用户大小 + 两个 2 字节 red zone
        s->size = s->object_size;
    }
}

KASAN 实现原理如下:

┌─────────────────────────────────────────────────────────┐
│ KASAN Shadow Memory (每 8 字节对应 1 字节 shadow)        │
│ accessible → 0xFF, partial → 0x80~0xFE, redzone → 0xFA │
└─────────────────────────────────────────────────────────┘
                     ↓ 每次内存访问 check
┌─────────────────────────────────────────────────────────┐
│ 真实内存布局                                            │
│ [Payload][RedZone][Payload][RedZone(KASAN)]            │
│  ← 用户大小 →  ← 16B →  ← 用户大小 →                    │
└─────────────────────────────────────────────────────────┘

5.2 KASAN 的 Poison 机制

当 KASAN 启用时,释放的对象会被 poison(标记为不可访问):

static inline void kasan_poison_slab(struct page *page)
{
    ...
    kasan_poison_memory(page_address(page) + offset,
                        slab->objects * s->size,
                        SLAB_KASAN_FREEPTR);
}

这意味着:

  • Use-after-free 立即触发:释放后再次访问该内存将触发 KASAN 异常
  • 越界访问检测:写入超出对象边界会触发 red zone 中毒检测
  • Double-free 检测:二次释放时 poison 标记会被检测到

5.3 KASAN 性能影响与生产环境应用

指标 无 KASAN 有 KASAN
内存占用 1x ~3x(shadow memory)
分配延迟 ~200ns ~500ns
典型应用 上线部署 预发验证 / 灰度测试

生产实践:大型互联网公司通常采用三级策略:

  1. 预发环境:开启 KASAN,对所有内核修改进行全量检测
  2. 灰度发布:选择 5% 节点开启 KASAN,配合流量回放验证
  3. 线上:关闭 KASAN,仅保留 KFENCE(见下节)

6. KFENCE:低开销内核电子围栏

KFENCE(Kernel Electric Fence)是 5.13 引入的新型内存错误检测工具,目的是在线上以极低开销(< 5%)捕获内存错误。

6.1 KFENCE 的采样机制

KFENCE 不检测所有分配,而是随机采样一小部分分配进行严格检查:

// 采样概率配置(默认 1/500)
static int kfence_sample_interval = 500;

bool kfence_alloc_timeout_expired(void)
{
    return READ_ONCE(kfence_sample_interval) == 0 ||
           time_is_before_jiffies(...) ;
}

// 每个 GFP 分配路径中的采样钩子
static void kfence_gfp_mask_init(void)
{
    if (kfence_allocation_timeout == 0)
        return;
    if (alloc_entry->timeout < jiffies)
        alloc_timeout++;
}

6.2 KFENCE 的保护策略

KFENCE 的一个采样分配会:

  1. 禁止缓存:直接从 buddy 获取整页(2^n 页)
  2. 独占页边界:对象与保护页(guard page)相邻
  3. 错误访问触发 page fault:越界访问触发精确的 oops
KFENCE SLAB PAGE 布局:
┌─────────────────────┐──────────┬─────────────────────┐
│ Guard Page (NoAccess)│ Object   │ Guard Page (NoAccess)│
└─────────────────────┴──────────┴─────────────────────┘
     2^n 页                  1个对象          2^n 页

关键代码实现 arch/arm64/mm/kfence.c:

// KFENCE 分配的 entry 结构体
struct kfence_obj {
    struct page *page;       // slab 页
    size_t size;             // 实际分配大小
    void *addr;              // 返回给用户的指针(页内偏移)
    int alloc_stacktrace[MAX_STACK_TRACE_DEPTH]; // 分配栈
};

6.3 KFENCE vs KASAN:互补而非替代

维度 KFENCE KASAN
检测范围 采样(~0.2% 分配) 全部分配
内存开销 ~50KB/slab + shadow page ~3-4x 原始内存
CPU 开销 <5% ~30-50%
适用场景 线上长期运行 预发/测试环境
延迟预算 <100 μs 无上限

典型生产配置:

# 在线上开启 KFENCE,采样间隔 1000 次检测一次
sysctl -w kernel.kfence.sample_interval=1000

# 自定义 slab cache 的 KFENCE 概率
echo 1 > /sys/kernel/debug/kfence/probability

7. SLUB 调试子系统与生产可见性

7.1 /proc/slabinfo:核心监控窗口

/proc/slabinfo 是观察 SLUB 状态的首选工具:

# cat /proc/slabinfo
slabinfo - version: 2.1
# name            <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> : tunables <limit> <batchcount> <sharedfactor> : slabdata <active_slabs> <num_slabs> <sharedavail>
kmalloc-1024          1024     1024      1024       4          2 : tunables    0    0    0 : slabdata    256    256    0
kmalloc-512           2048     4096       512       8          1 : tunables    0    0    0 : slabdata    512    512    0
kmalloc-256           4096     8192       256      16          1 : tunables    0    0    0 : slabdata    512    512    0
kmem_cache(dentry)     197      224       192       21         1 : tunables    0    0    0 : slabdata      11     11    0

关键字段含义: - active_objs:正在使用的对象数 - num_objs:总对象数(含空闲) - active_slabs:已使用的 slab 页数量 - 利用率 = active_objs / num_objs × 100%

健康阈值: - 利用率 < 20%:slab 缓存碎片过多,考虑调整 min_partial 或使用 SLAB_RECLAIM_ACCOUNT - 利用率 > 90% 且 active_slabs:可能发生瞬时内存压力

7.2 slabtop:实时监控

# 活跃的 SLUB 分配情况
slabtop -s c  # 按缓存大小排序

输出中的关键列: - OBJ/SLAB:每个 slab 的对象数 - ACTIVE:活跃对象占比(% 应为稳台值)

7.3 Tracepoint:精确的事件追踪

SLUB 提供 6 个高粒度 tracepoint:

# 开启 SLUB 分配追踪
echo 1 > /sys/kernel/debug/tracing/events/kmem/kmalloc/enable
echo 1 > /sys/kernel/debug/tracing/events/kmem/kmem_cache_alloc/enable
echo 1 > /sys/kernel/debug/tracing/events/kmem/kmalloc_node/enable
echo 1 > /sys/kernel/debug/tracing/events/kmem/kfree/enable

7.4 SLUB Debug 模块参数

# 追踪特定缓存的所有分配
echo kmalloc-1024 > /sys/kernel/debug/slab/cache_trasher

# 强制启用 red-zone 检查(即使无 KASAN)
echo 1 > /sys/kernel/debug/slab/sanity_checks

# 跟踪 double-free
echo 1 > /sys/kernel/debug/slab/trace

8. 生产事故案例与根因分析

8.1 案例一:内核级 Use-after-free 导致随机崩溃

现象:线上某节点每隔 2-3 小时随机触发 BUG: unable to handle page fault for address: 00000000004a1df0,Oops 信息无法复现。

排查步骤:

  1. 在预发环境开启 KASAN 后,10 分钟内触发异常:
=================================================================
BUG: KASAN: use-after-free in xfs_buf_ioapply_map+0x3d8/0x6c0
Read of size 8 at addr ffff88812b3c0d00 by task xfsaild/sda1/1234
...
Freed by task 5678:
  kfree_rcu+0x...
  1. 分析根因:XFS 文件系统在 IO 完成的 RCU 回调中,块层 buffer 结构过早释放

  2. 修复方案:将 kfree_rcu() 替换为 kvfree_rcu() 并增加引用计数确认逻辑

避坑要点:RCU 延迟释放必须保证所有 RCU read-side critical section 结束后才能 free,否则 use-after-free 将在读端访问时触发。

8.2 案例二:不可回收 slab 导致 OOM 风暴

现象:容器集群中某业务的 inode_cache 占用大量内存,oom-killer 无法杀掉占用进程,导致节点级联 OOM。

排查过程:

# 检查每个 kmem_cg 的内存使用率
cat /sys/fs/cgroup/memory/memory.kmem.slabinfo

# 发现 inode_cache 在节点不可回收 slab 中累积
echo inode_cache > /sys/fs/cgroup/memory/memory.kmem.slabinfo | grep active_slabs

根因分析: - 大量短生命周期的 seq_file 分配 dentry,但 per-CPU partial 链表持续增加 - SLUB 的 cpu_partial 设置过高(默认 30),导致对象长期驻留在 per-CPU partial 而不归还 - 当 cgroup kmem 触发回收时,per-CPU partial 中的对象拒绝释放

修复方案:

# 降低 per-CPU partial 上限,强制提前归还 node partial
sysctl -w kernel.slub_cpu_partial_limit=20

# 针对特定缓存,启用 SLAB_RECLAIM_ACCOUNT 标志(允许 kmemcg 回收)
# 内核代码中创建缓存时使用:
struct kmem_cache *inode_cache = KMEM_CACHE(inode, SLAB_RECLAIM_ACCOUNT | SLAB_ACCOUNT);

8.3 案例三:内存越界导致邻接缓存污染

现象:某数据库系统执行 fsync() 时偶发元数据损坏,重启后日志回放失败。

排查方法:

  1. 开启 KFENCE 采样后,30 分钟内捕获到:
KFENCE: memory corruption in d_instantiate+0x18c/0x200
  ...
  Corrupt 8 bytes at 0000-0010 in kmalloc-256-0000 (object at ffff...)
  Originator: 15    4b 33 08 c2 7f 00 00  00 00 00 00 00 00 00 00
  Modified:   15    4b 33 08 c2 7f 00 00  ff ff ff ff ff ff ff ff
  1. 追踪发现:d_instantiate() 函数在极端并发时,对 dentry->d_name.name 的写入越界到下一个 dentry 对象

  2. 深层原因:dentry 缓存的 aligned_size 未考虑哈希表条目的对齐填充,导致在 NUMA 节点间对象大小不一致时触发越界

防护措施:

// 创建缓存时显式锁死对齐
s = kmem_cache_create("dentry_cache", size,
                      L1_CACHE_BYTES,    // 强制 L1 cache line 对齐
                      SLAB_HWCACHE_ALIGN | SLAB_RECLAIM_ACCOUNT,
                      dentry_ctor);

9. 高级调优参数

9.1 Per-CPU Partial 调优

sysctl 控制 SLUB 的 per-CPU 行为:

# 控制 per-CPU partial 最大页面数(默认: 内存 每GB 8个slab)
sysctl -w kernel.slub_cpu_partial=30

# 每 slab 对象数小于此值时,不激活 partial 回调
sysctl -w kernel.slub_min_partial=4

9.2 大对象阶分配策略

# 强制大对象使用 order-0 分配(减少延迟,接受更多失败)
sysctl -w kernel.slub_max_order=0

# 控制全局最大活跃 slab 数(抑制不可回收对象累积)
sysctl -w kernel.slub_nr_slabs=0  # 0 表示不限制

9.3 碎片化控制

# 强制启用 slab 合并(注意: 调试模式下禁用)
sysctl -w kernel.slub_merge=1

# 设置 min_partial,控制 node partial 链表保留的最低 slab 数
echo 5 > /sys/kernel/slab/kmalloc-256/min_partial

10. 总结:SLUB 工程师的核心能力

理解 SLUB 分配器,不只是记住几个 API,而需要建立层级的分析能力:

  1. 正确性层:掌握快速路径的 cmpxchg lockless 设计和 TID 防 ABA 机制
  2. 效率层:理解阶分配、NUMA 节点匹配和 cpu_partial 的平衡
  3. 可观测层:熟练使用 slabinfo / slabtop / tracepoint 等工具
  4. 调试层:能在 KASAN/KFENCE 报告中定位越界、UAF 和 double-free 的源码位置
  5. 调优层:能根据生产特征调整 min_partial、cpu_partial 和阶分配参数

SLUB 的设计哲学是 "快速路径无锁、慢速路径安全、调试路径可追溯"。这一思想贯穿整个 Linux 内核,值得每一位系统工程师反复品味。


参考资料

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部