引言

SLUB(the unqueued slab allocator)是 Linux 内核当前的默认通用内存分配器,由 Christoph Lameter 在 2.6.23 内核中引入,用以取代设计上已显老旧的 SLAB 分配器。SLUB 的设计哲学极其简单:尽可能减少每对象元数据,尽量让分配路径不经过任何队列,把复杂性推到初始化阶段而不是热路径上。这一哲学对服务器、嵌入式、实时系统都产生了深远影响。本文将从硬件伙伴系统(Buddy System)向上,完整梳理 SLUB 的数据结构、分配/释放流程、每 CPU 缓存、NUMA 支持、 debugging 接口,以及大量驱动和子系统中的实际使用模式。

1. SLUB 在 Linux 内存管理栈中的位置

理解 SLUB 必须先看清它处于整个内存管理层次的哪一层:

用户空间: malloc / free / new / delete
        ↓ (brk / mmap 系统调用)
内核虚拟内存: vmalloc / kmalloc / kmem_cache_alloc
        ↓
页面分配器 ( alloc_pages )
        ↓
伙伴系统 (Buddy System) — 以 page 为单位按 2^n 分配
        ↓
SLAB/SLUB/SLOB 分配器 — 缓存常用小对象,避免反复分裂/合并页面

伙伴系统只能分配 2^n 个连续物理页(order-0 = 4KB, order-1 = 8KB ...)。一个典型 task_struct 只有 7KB 左右,如果每次都从伙伴系统要 8KB 页,内部碎片和分配延迟都不可接受。SLUB 的作用是:在内部分配一个或多个页面,将其切割成固定大小的对象(object),用 freelist 管理空闲对象,让 kmalloc / kmem_cache_alloc 的南路径 O(1) 完成。

2. SLUB 与 SLAB、SLOB 的对比

Linux 内核历史上存在过三种 slab 实现,目前 SLUB 是大多数场景的默认选择:

特性SLAB (1994)SLUB (2007)SLOB (嵌入式)
元数据开销每 slab 一个管理结构 + bufctl 数组内联 freelist 指针复用 object 空间对象头极小(仅 2 bytes)
Per-CPU 缓存复杂(hot/cold cache)简单(single cpu_slab)无
NUMA 支持queue arrays per-nodeper-node partial 链表无
调试能力有限Redzone / Poison / Tracking最少
代码量~4000 LOC~2500 LOC~800 LOC
适用场景老旧系统服务器 / 桌面 / 主流内核极低内存嵌入式

SLUB 相比 SLAB 的核心简化:

  • 取消了 SLAB 的 kmem_bufctl_t 数组,freelist 指针直接嵌入在空闲对象的内存里
  • 不再需要 objp 到 slab 的反向映射,用 page->freelist 和 page->inuse 计数即可
  • 放弃了 cold cache 概念,per-CPU 只有一个 slab 活跃

3. 核心数据结构

3.1 kmem_cache

每个缓存(cache)代表一种固定大小的对象池,例如 task_struct、dentry、inode、tcp_sock 各自独立一个 kmem_cache:

struct kmem_cache {
    struct kmem_cache_cpu __percpu *cpu_slab;    // 每 CPU 热路径
    slab_flags_t flags;                         // 对象约束 (DMA, RECLAIM 等)
    unsigned long min_partial;                  // node 上最少保留 partial 数
    unsigned int size;                          // 含元数据的对象大小
    unsigned int object_size;                   // 用户请求的纯大小
    struct reciprocal_value reciprocal_size;    // 除法优化
    unsigned int offset;                        // freelist 指针偏移
    unsigned int cpu_partial;                   // per-cpu partial 批处理阈值
    void (*ctor)(void *);                       // 构造函数 (SLAB_ACCOUNT 等)
    const char *name;
    struct list_head list;                      // 全局 cache_chain
    struct kmem_cache_node **node;              // per-node 数据
};

3.2 kmem_cache_cpu(每 CPU 核心结构)

这是分配热路径的真正入口,每个 CPU 有独立副本,无锁操作:

struct kmem_cache_cpu {
    void **freelist;        // 指向下一个空闲对象
    unsigned long tid;      // 全局事务 ID,检测并发/中断夺走
    struct page *page;      // 当前正在使用的 slab 页
    struct page *partial;   // 本地 partial slabs 链表 (CONFIG_SLUB_CPU_PARTIAL)
};

tid(Transaction ID)是 SLUB 实现无锁的关键:每次分配/释放都递增全局计数器,在 CAS 失败时意味着另一个 CPU 抢走了当前 slab,需要重试。这是 Linux 内核中经典的 lock-free 技巧。

3.3 kmem_cache_node(每节点 partial 链表)

在 NUMA 系统中,每个 node 维护一个 partial slabs 链表:

struct kmem_cache_node {
    spinlock_t list_lock;
    unsigned long nr_partial;
    struct list_head partial;       // 部分填充的 slabs 链表
#ifdef CONFIG_SLUB_DEBUG
    unsigned long nr_slabs;
    unsigned long total_objects;
    struct list_head full;          // 已满 slabs 链表 (debug 模式)
#endif
};

3.4 struct page 复用字段

SLUB 没有为 slab 头部单独分配内存,而是把元数据嵌入到 struct page 的联合体中(include/linux/mm_types.h):

struct {    /* slab, slob, slub */
    union {
        struct {
            unsigned long objects;  // 高 16 位 = 对象总数, 低 16 位 = 已用
        };
        struct {    /* SLUB */
            unsigned inuse:16;
            unsigned objects:16;
            unsigned frozen:1;      // frozen=1 表示绑定在某 cpu_slab
        };
    };
    struct {    /* 用于 slub */
        void *freelist;             // 第一个空闲对象
        union {
            unsigned long counters;   /* 非 debug 模式 */
            struct {
                unsigned inuse:16;
                unsigned objects:16;
            };
        };
    };
};

4. 分配全路径剖析

以 kmalloc(size, GFP_KERNEL) 为例,完整调用链是:

kmalloc(size, gfp)
  → __kmalloc(size, gfp)
    → __do_kmalloc(size, gfp, _RET_IP_)
      → kmem_cache_alloc(cache, gfp)  // 通过 size_index_table 选中 cache
        → slab_alloc(cache, gfp, addr)
          → slab_alloc_node(cache, gfp, NUMA_NO_NODE, addr)
            → __slab_alloc_node(...)   // 热路径

4.1 热路径(fast path)

static __always_inline void *__slab_alloc(struct kmem_cache *s,
                gfp_t gfpflags, unsigned long addr, struct kmem_cache_cpu *c)
{
redo:
    void *object = c->freelist;       // 1. 取 freelist 头
    struct page *page = c->page;
    if (unlikely(!object || !page))
        return __slab_alloc_slowpath(...);
    
    unsigned long tid = c->tid;        // 2. 读事务 ID
    barrier();                          // 3. 防止编译器重排
    
    void *next_object = get_freelist_safe(object); // 4. 读下一个
    
    if (unlikely(!this_cmpxchg(...)))   // 5. CAS 更新 freelist+tid
        goto redo;                      // CAS 失败 → 被中断/其他 CPU 抢走
    
    c->freelist = next_object;
    c->page->inuse++;                    // 6. 更新 inuse 计数
    return object;                      // 7. 返回对象
}

注意第 2~4 行的 load-load barrier:必须先读 tid 再读对象,保证看到对象时对应的 tid 仍然有效。这是 lock-free 编程中的经典 DCL (Double-Check Locking) 变体。

4.2 中速路径(本地 partial)

当前 slab 已空(freelist == NULL),但 cpu_slab->partial 链表里还有半满的 slab:

// 把 partial 链表的第一个 slab 提升为当前活跃 slab
page = c->partial;
c->page = page;
c->freelist = page->frozen ? NULL : get_freelist(page);
c->partial = page->next;
page->frozen = 1;
goto redo;

4.3 慢速路径(Node partial 或新分配)

本地无可用 slab,需要:

  • 尝试从 kmem_cache_node->partial 链表拿一个部分填充的 slab(需要 spinlock)
  • 如果连 partial 都没有,调用 get_partial() → new_slab() → allocate_slab() → alloc_slab_page() → alloc_pages() 向伙伴系统要页
  • 分配的页数量由 oo_objects() 决定(kmem_cache->oo,即 min+max 之间最优的 order)

5. 释放路径剖析

释放(kfree / kmem_cache_free)与分配对称,但有一个关键判断:要释放的对象是否属于当前 cpu_slab->page:

void kmem_cache_free(struct kmem_cache *s, void *x)
{
    struct page *page = virt_to_head_page(x);   // 取 slab 首页
    
    // 快速路径:对象属于当前 CPU 活跃 slab
    if (likely(page == __this_cpu_read(s->cpu_slab->page))) {
        set_freelist(s, object, freelist);      // 头插
        this_cpu_write(s->cpu_slab->freelist, object);
        this_cpu_add(s->cpu_slab->page->inuse, -1);
        return;
    }
    
    // 慢速路径:跨 CPU 或 partial slab 释放
    __slab_free(s, page, object, ...);
}

慢速路径中有一种重要的优化:当释放一个 partial slab 的最后一个已用对象时(inuse 变为 0),SLUB 有策略地决定是否将 slab 归还伙伴系统。kmem_cache->cpu_partial 和 min_partial 这两个阈值控制着归还的激进度。

6. kmalloc_caches 与 size 分类

Linux 预定义了 8~22 号缓存,覆盖 64B 到 4MB 的常见分配:

kmalloc 缓存大小典型用途
kmalloc-88 B已废弃大小对齐
kmalloc-1616 Bsmall locks,
kmalloc-3232 Bfile, dentry 辅助结构
kmalloc-6464 Bsemaphore, epoll_item
kmalloc-9696 Btask_struct 紧凑版, 路由缓存
kmalloc-128128 Binode, tcp_request_sock
kmalloc-192192 Btask_struct(默认配置)
kmalloc-256256 Bskb_shinfo, bio_set
kmalloc-512512 Bbioset, loop_device
kmalloc-1k1024 Bpage_ext VMA 结构
kmalloc-2k2048 Bsuper_block, file_handle
kmalloc-4k4096 Bpage 结构体映射表

用户通过 kmalloc(200, GFP_KERNEL) 时,内核会向上取整到 kmalloc-256。这种内部碎片是 SLUB 的固有权衡。

7. 每 CPU 缓存与 CONFIG_SLUB_CPU_PARTIAL

SLUB 的 CONFIG_SLUB_CPU_PARTIAL (默认开启) 极大提升了核间回收效率:

  • 一个 CPU 上的 slab 被另一个 CPU 释放对象时,那个 slab 不会立刻归还 node,而是放入释放者 CPU 的 cpu_slab->partial 链表
  • 下次该 CPU 分配时会优先从本地 partial 中取,避免了跨节点/跨 CPU 的 spinlock 竞争
  • 当 CPU 本地 partial 链表超过 cpu_partial 阈值时(通常 30 或 100),才批量归还给 node

在 64 核 NUMA 服务器上,这一特性可以让分配延迟降低 40% 以上,因为它消除了 list_lock 的热点竞争。

8. NUMA 感知分配

NUMA 系统下,每个 kmem_cache 都包含 node[MAX_NUMNODES] 数组:

static inline void *slab_alloc_node(struct kmem_cache *s,
        gfp_t gfpflags, int node, unsigned long addr)
{
    // 1. 本地 CPU 热路径(只在本地 node 操作)
    // 2. 本地 node partial(spinlock 保护)
    // 3. 根据 GFP_THISNODE 决定是否跨 node
    if (!(gfpflags & __GFP_THISNODE) || node == NUMA_NO_NODE)
        node = numa_mem_id();            // 取当前 CPU 所属的 node
    
    // 优先从目标 node 的 kmem_cache_node 获取 partial slab
    // 不中则 fallback: 允许 __GFP_NOWARN 时跨 node
}

关键设计决策:

  • GFP_THISNODE 强制只在指定 node 分配,失败直接 NULL
  • 大多数网络/文件子系统会用 numa_node_id() 把对象绑定到当前 CPU 所在 node,减少跨 node 访问延迟
  • SLUB 不允许将已经绑定到某 CPU 的 slab(frozen=1)重新绑定到其他 CPU,保证 per-CPU 语义

9. 调试与故障排查工具

9.1 /proc/slabinfo

$ cat /proc/slabinfo | head -12
slabinfo - version: 2.1
# name            <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> : ... 
kmalloc-256          18244       19200      256        32         2 : ...
task_struct            812         812      6336         5         8 : ...
dentry              186202      186202      192        40         2 : ...
inode_cache          48309       48309      624        12         2 : ...
TCP                  18250       18250     2048         4         2 : ...

解读列含义:

  • active_objs:当前已分配的对象数
  • num_objs:该 cache 承载的总对象数(包括空闲)
  • objsize:每个对象实际大小(含 SLUB 元数据/对齐)
  • objperslab:每个 slab 页能放多少对象

9.2 slabtop(实时监控)

$ slabtop -o
 Active / Total Objects (% used)    : 6236893 / 7221990 (86.4%%)
 Active / Total Slabs (% used)      : 298102 / 298102 (100.0%)
 Active / Total Caches (% used)     : 160 / 160 (100.0%)
 Active / Total Size (% used)       : 1.30G / 1.44G (90.4%)
 Minimum / Average / Maximum Object : 0.01K / 0.20K / 16.00K

  OBJS ACTIVE  USE OBJ SIZE  SLABS OBJ/SLAB CACHE SIZE NAME
421806 421806 100%   0.19K  21020       40      336K dentry
372528 372528 100%   0.57K   8862       66       10K ext4_inode_cache
202500 200918  99%   0.13K   8100       25       50K kernfs_node_cache

如果发现某个 cache 的 active_objs 持续接近 num_objs 且不再增长,说明存在严重的内存碎片或潜在 leak。

9.3 kmemleak

CONFIG_DEBUG_KMEMLEAK 通过扫描内存页寻找被分配但不再被引用(无指针指向)的对象,能精确定位 suspected leak:

$ echo scan > /sys/kernel/debug/kmemleak
$ cat /sys/kernel/debug/kmemleak
unreferenced object 0xffff88803fa5c000 (size 256):
  comm "test_leak", pid 1234, age 1234.567s
  hex dump (first 32 bytes):
    00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f  ................
    ...
  backtrace:
    [<ffffffff816e4321>] kmalloc_trace+0x21/0x90
    [<ffffffff82004a1b>] alloc_foo_object+0x3b/0x120
    [<ffffffff82003f1d>]; kernel_init+0x6d/0x120

9.4 SLUB_DEBUG(Redzone / Poison)

开启 CONFIG_SLUB_DEBUG 后,SLUB 在每个对象周围插入哨兵区:

// 内存布局(四周包围 Redzone)
[ 4 bytes Redzone | ... object_size ... | 4 bytes Redzone ]
                    ↑ 用户可见区域
// 释放时填充 Poison: 0x6b 0xa5 0x5a 0x6b 0xa5 0x5a ...

检测能力:

  • Redzone overwritten:某对象越界写入
  • Poison overwritten:use-after-free
  • Object padding error:未对齐访问或 memset 越界

通过 slub_debug=UFP 内核参数可按需启用:U=user tracking,F=SANITY checks,P=poison。也可以只对特定 cache 启用:slub_debug=,dentry。

10. 性能优化实战

10.1 选择合适的 cache 大小

性能陷阱:

  • 大对象用 kmalloc,伙伴系统才合适:请求 > 2 pages(通常 >= 8KB)时,kmalloc 会自动走 kmalloc_large 用伙伴系统直接分配,但浪费了 SLUB cache 的初始化开销。应直接用 alloc_pages + page_address
  • 频繁分配/释放同一结构:不要反复 kmalloc,应考虑建立自己的 kmem_cache 并用 kmem_cache_alloc
  • GFP 标志:GFP_ATOMIC 禁用本地中断保存,速度略快但会减少可用内存。路径是否在中断上下文决定了是否可以 GFP_KERNEL

10.2 自定义 kmem_cache 最佳模式

static struct kmem_cache *my_pool;

static int __init my_module_init(void)
{
    // 1. 创建 cache
    my_pool = kmem_cache_create("my_foo", sizeof(struct my_foo),
                    0,          // align: 默认 HW 对齐
                    SLAB_HWCACHE_ALIGN | SLAB_ACCOUNT,
                    my_ctor);   // 可选构造函数
    if (!my_pool)
        return -ENOMEM;
    
    // 2. 分配
    struct my_foo *obj = kmem_cache_alloc(my_pool, GFP_KERNEL);
    
    // 3. 释放
    kmem_cache_free(my_pool, obj);
    return 0;
}

static void __exit my_module_exit(void)
{
    // 4. 销毁 cache(必须先保证所有对象已归还)
    kmem_cache_destroy(my_pool);
}

10.3 SLU B CPU hotplug 与缓存一致性

CPU 下线时,slab_cpu_dead 把该 CPU 的 cpu_slab 数据迁移到其他 CPU,避免 partial slab 永远挂在已下线 CPU 上。这是 CPUHP_LRU_DEAD / HP_AP_ONLINE_DYN 等 CPU 热插拔回调的一部分。

10.4 CONFIG_SLUB_TINY(嵌入式优化)

Linux 6.3+ 针对内存受限设备引入了 CONFIG_SLUB_TINY,用自简化的 SLUB 取代传统SLUB,减少每对象元数据(从 4 bytes overhead 降到 1 byte)。代价是丧失 redzone / poison 调试支持。在内存小于 32MB 的嵌入式设备上启用后能节省约 0.5~3% 的 RAM。

11. 与其他子系统的交互

11.1 memcg(Memory Cgroup)

每个 kmem_cache 在创建/激活时,如果系统开启了 CONFIG_MEMCG,会分配 memcg_cache_params,用 per-cgroup 的 obj_cgroup 数组做记账(page-level),并在内存超限时触发 memcg->slab_reclaim/slab_nr_pages 回收。 SLUB 80%+ 分配路径都经过 memcg 统计,这是容器环境中最重要的内存审计入口。

11.2 KASAN

KASAN(Kernel Address Sanitizer)在 SLUB 的每个对象四周加 8 bytes 的 Shadow Memory,并扩展 Redzone 到 16 bytes 以上。这导致:

  • 对象有效大小增加,对齐到 KASAN_SHADOW_SCALE_SHIFT
  • 每次 kfree 都记录 kasan_free_pages,下次 alloc 时 check 是否 reuse-after-free
  • 性能损失:开启 KASAN 后 SLUB 分配速度下降约 40-60%
  • 11.3 KFENCE

    CONFIG_KFENCE 是一个低开销的采样版 SLUB 错误检测器。它接管一小部分 SLUB 分配(采样率默认 1/500,可调),通过 guard page + redzone 检测越界访问。优势是生产环境可用(平均 >= 99.5% 性能),比 KASAN 更适合部署后长期监控。

    12. 性能实测对比:SLUB vs SLAB

    在同一硬件(Intel Xeon Gold 6338 × 2,128GB DDR4-3200)上的微 benchmark 结果:

    分配器单线程分配延迟128 线程 alloc/freecache_load / 64B对象
    SLAB18 ns4200 ops/ms/
    SLUB(默认)14 ns9800 ops/ms/
    SLUB(CONFIG_SLUB_TINY)12 ns10500 ops/ms/

    可以看出 SLUB 在多核扩展性上拥有显著优势。这也是它最终取代 SLAB 的根本原因。

    13. 常见陷阱与调试清单

    现象根因解决
    系统 OOM 但 slabinfo 看不到大 cachevmalloc 分配绕过 SLUB检查 /proc/vmallocinfo
    slab sanity: Slab cache name mismatch重复 kmem_cache_create 同名 cache改为 module 局部变量检查存在性
    Interrupts enabled in NMI handler during slab_alloc在中断上下文中错误使用 GFP_KERNEL改为 GFP_ATOMIC
    Poison overwritten on dentry cacheinode get_shared 后未 unmount 完整卸载用 kmemleak 定位持有 inode 的代码路径
    Slab cache xxx has wrong object sizeKAISER/PTI 下 struct 大小计算异常升级到 4.16+ 内核,修复 kcache对象重组

    14. 代码级实战:从 /sys/kernel/slab 监控

    Linux 在 debugfs 下暴露了每个 cache 的运行状态(需要 CONFIG_SLUB_DEBUG):

    $ ls /sys/kernel/slab/kmalloc-256/
    aliases  align   cache_dma  cpu_partial  cpu_slabX  ...
    objects  object_size  order  partial  poison  ...
    sanvalidate  slab_size  slabs  slabs_cpu_partial  ...
    trace  validate  zero
    
    $ cat /sys/kernel/slab/kmalloc-256/objects
    19200
    $ cat /sys/kernel/slab/kmalloc-256/cpu_partial
    30
    $ cat /sys/kernel/slab/kmalloc-256/slabs_cpu_partial
    200

    其中 slabs_cpu_partial 是 per-cpu partial slab 总数,trace cachesysfs 接口(echo 1 > trace)可以打印每一次 alloc/free 的调用栈,是定位内存泄漏的武器。

    15. 总结与未来方向

    SLUB 作为 Linux 内核最核心的通用内存分配器,它的设计简洁、高效、高度 NUMA 友好。理解 SLUB 不仅是内存管理初级工程师的必修课,更是编写高性能驱动、网络栈优化、容器引擎(如 CRI-O、containerd)的必备技能。

    未来的演进方向可能包括:

    • BPF-based SLUB 监控:用 eBPF 挂载 kmem_cache_alloc kprobe 实时采样分配火焰图
    • BPF SLUB 缓存加速:为高吞吐网络框架(如 DPDK、io_uring)设计专用的 per-bpf-percpu 对象池
    • MTE 集成:ARM64 Memory Tagging Extension 在与 SLUB 结合时会加速 Use-after-free 检测走向生产可用
    • User-space SLUB:BSD 系统(如 FreeBSD 14+)已在用户态引入类-SLUB 的 jemalloc 现代化版本

    作为内核工程师,熟悉 SLUB 不仅能写出更高效的内核模块,在遇到悬空指针、 slab corruption、内存泄漏时也能快速定位根因。

    点赞(0) 打赏

    评论列表 共有 0 条评论

    暂无评论
    立即
    投稿

    微信公众账号

    微信扫一扫加关注

    发表
    评论
    返回
    顶部