引言:为什么内核需要专用内存分配器?

Linux 内核是一个庞大而复杂的系统,它对内存的管理远不止简单的页面分配。当我们编写内核模块或深入理解系统性能时,Slab 分配器是一个绕不开的核心机制。它诞生于 1994 年 SunOS 的 Jeff Bonwick 之手,后来被引入 Linux 2.1.23,至今已演进三代:SLAB(经典实现)、SLUB(默认分配器)和 SLOB(精简版)。

本文将从零开始,深入剖析 Slab 分配器的设计哲学、内部数据结构、三代实现差异,并结合生产环境实战案例,带你彻底掌握这一内核内存管理的基石。

第一章:Buddy System 与 Slab 的分层协作

理解 Slab 必须先理解 Buddy System(伙伴系统)。Buddy System 以 页(Page,通常 4KB) 为最小分配单位,管理物理内存。但内核中大量对象的尺寸远小于一页——如 task_struct(约 7KB)、inode(约 600 字节)、dentry(约 200 字节)。如果每次分配都使用页面级分配器,内部碎片将极其惊人。

Slab 分配器正是为了解决这个问题:它在 Buddy System 之上构建对象缓存机制,将页面划分为多个等大小的对象槽,实现细粒度的内存分配与回收,消除内部碎片。

两者协作模型:

  • Buddy System:管理页面级连续物理内存,解决外部碎片(通过迁移和合并)
  • Slab 分配器:管理小于一页的频繁分配/释放的对象,解决内部碎片
  • kmalloc:基于 Slab 的通用分配器,支持 8 字节到 8MB 的任意大小

第二章:Slab 的核心数据结构与缓存层次

2.1 Cache 结构

每个对象类型对应一个 kmem_cache 结构,核心字段如下:

struct kmem_cache {
    struct kmem_cache_cpu *cpu_slab;    // Per-CPU 快速分配缓存
    struct kmem_cache_node *node[MAX_NUMNODES]; // 各 NUMA 节点的半空 slab 列表
    unsigned int size;                   // 对象实际大小(对齐后)
    unsigned int object_size;            // 对象原始大小
    unsigned int offset;                 // 空闲对象链表指针偏移
    slab_flags_t flags;                  // 构造函数等标志
    unsigned int num;                    // 每个 slab 包含的对象数量
    void (*ctor)(void *);                // 对象构造函数
    const char *name;                    // 缓存名称(如"dentry_cache")
};

2.2 Slab 描述符

一个 slab 是物理上连续的内存区域(1-N 页),由 struct slab(新版,替代了旧版 page 内嵌 slab 描述)管理:

struct slab {
    unsigned long __page_flags;
    struct list_head slab_list;   // 指向 cache 的 partial/full/empty 列表
    void *freelist;                // 空闲对象链表头指针
    unsigned inuse;                // 已使用对象数量
    unsigned objects;              // 对象总数
    unsigned long __page_flags_2;
};

2.3 三层分配策略

Slab 缓存维护三类链表,形成优先级明确的分配路径:

  • cpu_slab(hot cache):Per-CPU 专属缓存,无锁分配,性能最优。每个 CPU 维护 freelist 指针和当前 slab 页,分配/释放无需加锁。
  • Node Partial:NUMA 节点的半空 slab 列表。当 cpu_slab 中的 slab 用尽或空闲完毕时,从 Node 的 partial 列表中取用。
  • Buddy System:当所有 partial 列表均空时,向 Buddy System 申请新页面,创建新 slab。

这种设计完美契合现代多核 NUMA 架构:优先在本地 CPU 无锁操作,其次考虑 NUMA 亲和性,最后才申请新内存。

第三章:SLAB / SLUB / SLOB 三代实现对比

3.1 SLAB(经典版,1994-2005 主流)

特点:

  • 每个 slab 内部维护 bufctl 数组记录空闲对象索引
  • 使用 slab 描述符嵌入在 page 结构体的前几个字段
  • 着色(Color)机制利用硬件 Cache Line 对齐,减少 Cache 冲突
  • 元数据管理复杂,在多核扩展性上存在瓶颈

3.2 SLUB(Unqueued Slab,当前默认,2007 至今)

SLUB 是目前 Linux 内核的默认分配器,由 Christoph Lameter 设计,核心改进:

  • 取消 per-slab 队列:直接利用 page 结构体内嵌 slab 描述符,大幅减少元数据
  • 简化 free list:空闲对象链表直接存在对象内存的第一个指针位,freelist 指针链式串联
  • 更优的多核扩展:每个 CPU 独立管理 freelist,减少全局锁争用
  • Red-Zone 越界检测:在对象末尾填充特殊标记,释放时检查是否被越界写入
  • Poison 毒化:释放时填充 0x5a5a5...,捕获 use-after-free
  • 合并到 page 结构体中,减少独立数据结构带来的开销

3.3 SLAB freelist 硬核布局

SLUB 的核心设计之一是 freelist 的存储方式。空闲对象的前 8 字节被用作 next 指针:

// SLUB freelist 原理:复用对象内存存储链表指针
struct kmem_cache *cache = dentry_cache;
void *object = cache->cpu_slab->freelist;

// 分配过程(无锁):
void *next_object = *(void **)object;  // 读出 next 指针
cache->cpu_slab->freelist = next_object;
// 返回给调用者的对象前8字节已被"垃圾"数据覆盖
// 用户无需关心这段元数据

3.4 SLOB(精简版,适用于嵌入式)

专为内存极度受限的嵌入式系统设计:

  • 极小的代码量(约 600 行 vs SLUB 数千行)
  • 简单的第一适应(First-fit)分配策略
  • 不支持 Per-CPU 缓存和 NUMA 优化
  • 使用 Buddy System 直接的碎片管理能力较弱

三代实现性能对比:

维度SLABSLUBSLOB
多核扩展性中等(全局缓存锁)优(Per-CPU 无锁)差(全局锁)
内存开销高(bufctl 数组)极低(内嵌元数据)最低(最简元数据)
碎片控制好(着色优化)好较差(第一适应)
调试功能基础强(RedZone/Poison)无
代码复杂度高中极低
适用场景已淘汰通用服务器、桌面嵌入式 < 64MB RAM

第四章:kmalloc 与 kmem_cache_alloc 的使用实战

4.1 kmalloc:内核通用分配接口

kmalloc 是大多数内核代码使用的接口,它背后是一组预定义的 kmem_cache:

// 内核源码 mm/slab_common.c 中的尺寸分级
static struct kmem_cache *(*[KMALLOC_SHIFT_HIGH + 1]) = ...
// 如 kmalloc-8, kmalloc-16, kmalloc-32, ..., kmalloc-8K, ... kmalloc-64K

// 使用示例
char *buf = kmalloc(256, GFP_KERNEL);  // 匹配到 kmalloc-256 缓存
strcpy(buf, "Hello Slab!");
kfree(buf);

kmalloc 分配的内存物理连续,适合 DMA 等需要物理连续性的场景,但大尺寸分配可能因 Buddy System 碎片失败。

4.2 kmem_cache_alloc:自定义对象缓存

对于频繁分配/释放的固定大小对象(如自定义结构体),应创建专用 cache:

static struct kmem_cache *my_obj_cache;

// 模块初始化时创建缓存
my_obj_cache = kmem_cache_create(
    "my_obj_cache",          // 缓存名称(/proc/slabinfo 可见)
    sizeof(struct my_object), // 对象大小
    0,                        // 对齐偏移(0 表示自然对齐)
    SLAB_HWCACHE_ALIGN | SLAB_POISON, // 属性标志
    my_ctor                   // 可选构造函数
);

// 分配对象
struct my_object *obj = kmem_cache_alloc(my_obj_cache, GFP_KERNEL);

// 使用对象...
obj->value = 42;

// 释放对象
kmem_cache_free(my_obj_cache, obj);

// 模块退出时销毁缓存
kmem_cache_destroy(my_obj_cache);

4.3 GFP 标志详解

标志含义适用场景
GFP_KERNEL标准分配,允许睡眠等待换页普通进程上下文
GFP_ATOMIC高优先级,不睡眠(使用紧急内存池)中断上下文、持有自旋锁
GFP_DMA分配 ZONE_DMA(0-16MB)内存老式 ISA DMA 设备
GFP_DMA32分配 ZONE_DMA32(0-4GB)内存32 位 DMA 地址线设备
GFP_NOWARN分配失败时抑制警告日志预期可能失败的分配
__GFP_ZERO分配后清零避免旧数据泄露的安全场景
GFP_NOIO允许分配但禁止启动 I/O 换页块层、文件系统内部

第五章:NUMA 感知与分配拓扑

现代多路服务器采用 NUMA(非统一内存访问)架构,CPU 访问本地节点内存延迟约 100ns,跨节点访问延迟可达 200-300ns。Slab 分配器深度集成了 NUMA 优化:

  • 每个 NUMA 节点维护独立的 kmem_cache_node 包含 partial/full/empty slab 链表
  • CPU slab 优先使用本地节点内存分配新 slab
  • 跨节点偷取(stealing):当本地节点 slab 用尽时,从其他节点的 partial 列表借用而非立即申请新页
  • NUMA 调度器配合 Slab 分配策略,可将跨节点访问比例控制在 15% 以下
struct kmem_cache_node {
    spinlock_t list_lock;              // 保护链表的自旋锁
    unsigned long nr_partial;          // partial slab 数量
    struct list_head partial;          // partial slab 链表(主要分配来源)
#ifdef CONFIG_SLUB
    unsigned long nr_slabs;            // 总 slab 数(仅统计用)
    unsigned long total_objects;       // 总对象数
    struct list_head full;             // 满 slab 链表
#endif
};

第六章:调试、监控与性能调优

6.1 /proc/slabinfo 详解

$ cat /proc/slabinfo | head -20
slabinfo - version: 2.1
# name            <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> : ...
dentry              283792  301250    192   21    1 : ... 
inode_cache         142056  143232    648   28    8 : ...
kmalloc-64          128453  131072     64   64    1 : ...
task_struct          1024   1024   7616    5    8 : ...

6.2 slabtop 实时监视

$ slabtop -o
 Active / Total Objects (% used)    : 523491 / 587123 (89.2%)
 Active / Total Slabs (% used)      : 12847 / 13082 (98.2%)
 Active / Total Caches (% used)    : 142 / 211 (67.3%)
 Active / Total Size (% used)      : 234.52K / 258.91K (90.6%)
 Minimum / Average / Maximum Object : 0.01K / 0.44K / 8.00K

6.3 性能调优实践

场景一:dentry 缓存膨胀导致内存压力

大量文件操作会导致 dentry 缓存占用大量内存。诊断与调优:

# 查看 dentry 缓存占用
$ grep dentry /proc/slabinfo
dentry  301250  301250  192  21  1
# 计算:301250 * 192 = 约 55MB

# 清理 dentry 和 inode 缓存(谨慎操作)
$ sync
$ echo 2 > /proc/sys/vm/drop_caches   # 清理 slab 缓存

# 调整 vfs_cache_pressure(默认 100)
# 值越大,内核越积极回收 dentry/inode
$ sysctl -w vm.vfs_cache_pressure=500

场景二:/proc/slabinfo 中 active_objs/num_objs 持续接近

说明缓存趋于饱和,无空闲对象可回收。解决方案:

  • 检查是否为特定工作负载导致的正常现象(如大量并发文件描述符)
  • 确认是否有内存泄漏(active_objs 持续增长)
  • 对于自定义 cache,可通过 kmem_cache_shrink() 主动释放空 slab

场景三:SLUB 调试——捕获越界写和释放后使用

// 开启 Red-Zone 和 Poison 检测(编译内核时配置)
CONFIG_SLUB_DEBUG=y
CONFIG_SLUB_DEBUG_ON=y

// 运行时对指定缓存开启调试
echo 1 > /sys/kernel/slab/dentry/validate

// 捕获越界写(Red-Zone 被破坏)
// 内核会在 dmesg 中打印:
// slub: corrupted data after object name=dentry ...
// 
// 捕获释放后使用(Poison 0x5a5a5a... 被覆盖)
// slub: Object use after free ...

第七章:现代演进——SLUB 与 cgroup 内存控制

Linux 5.x+ 内核中,SLUB 与 cgroup v2 内存控制器深度集成:

  • 每个 cgroup 可独立统计 SLAB 内存使用量(memory.slabinfo)
  • cgroup 销毁时自动释放其关联的所有 slab 对象
  • memory.max 限制包含 SLAB 缓存使用的内存
  • 这对容器化环境至关重要:防止某个容器的 dentry/inode 无限膨胀挤占宿主机内存
$ cat /sys/fs/cgroup/memory.slabinfo | head -10
# name <active> <total>
dentry 123456 130000
inode_cache 98765 100000
kmalloc-64 54321 55000

第八章:问题诊断与常见问题排查

Q1: kmalloc 返回 NULL 怎么办?

  • 检查 GFP 标志:GFP_KERNEL 分配失败通常是因为内存枯竭触发 OOM Killer
  • 使用 GFP_NOWARN 避免刷屏式警告
  • 使用 show_free_areas() 查看各 Zone 水位线
  • 检查是否存在内存泄漏(/proc/meminfo 中 Slab 持续增长)

Q2: SLUB 如何调试一个"Bad page state"崩溃?

  • 这通常意味着对已释放的 slab 页面进行了操作
  • 开启 CONFIG_SLUB_DEBUG_ON=y 自动检测
  • 使用 slabinfo -v 验证 slab 状态
  • 注意跨 CPU 释放问题:在 CPU A 分配、CPU B 释放可能导致 CPU hot cache 延迟释放

Q3: 为什么 /proc/slabinfo 中仍有大量 partial slab?

这是正常现象——slab 分配器蓄水池化设计。只有在内存压力下才会将 partial slab 的内容归还 buddy system。如需强制回收:

# 触发同步回收
echo 3 > /proc/sys/vm/drop_caches

# 调整 min_free_kbytes 提升水位线(提前回收)
sysctl -w vm.min_free_kbytes=262144   # 256MB

总结与展望

Slab 分配器从诞生至今已近 30 年,但其核心理念——减少碎片、提升缓存命中率、NUMA 感知——依然指导着现代内核开发。随着 CXL(Compute Express Link)内存扩展和异构计算架构的兴起,SLUB 持续演进:

  • 未来 SLUB 将更好地管理多层级内存(HBM/DDR/CXL) 的对象分配策略
  • cgroup v3 将为 slab 资源控制提供更细粒度的 QoS 策略
  • Rust for Linux 驱动中,kmem_cache 与 Rust 分配器 trait 结合的安全封装正在进行中
  • io_uring 的 registered buffer 机制与 Slab 的交互正在持续优化

掌握 Slab 分配器不仅是内核开发的必经之路,更是诊断系统性能疑难杂症的利器——理解它,你就能看懂 /proc/slabinfo 中的内存密码,在 OOM 黑暗中点亮一盏明灯。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部