引言

在Linux内核中,伙伴系统(Buddy System)以页(通常4KB)为单位管理物理内存,但内核对象往往只有几十到几百字节——如果直接分配整页,内部碎片将极其严重。Slab Allocator 正是为解决这个问题而生:它从伙伴系统批量获取页面,切割成固定大小的对象(object),实现亚页级别的细粒度分配。从 1994 年 Jeff Bonwick 为 Solaris 首创 SLAB,到 Linux 引入 SLAB/SLUB/SLOB 三大实现,再到今天 SLUB 作为默认分配器支撑着从嵌入式设备到超算集群的数十亿次每秒对象分配。本文将从伙伴系统接口出发,逐步剖析 Slab 的核心数据结构、对象缓存生命周期、着色(coloring)、调试机制、NUMA优化,并结合生产参数调优构建完整的 slab 工程知识体系。

一、从伙伴系统到 Slab:亚页分配的动机

1.1 伙伴系统的局限

伙伴系统以 page (4KB 通常)为最小分配单位。内核频繁申请的对象尺寸集中在几十字节到几 KB:

  • task_struct:约 7KB(但通过 kmalloc_slabs 有专用缓存)
  • inode:约 592 bytes(ext4_inode_cache)
  • dentry:约 192 bytes(dentry_cache)
  • file:约 256 bytes(filp_cachep)
  • buffer_head:约 104 bytes
  • cred:约 168 bytes

如果直接通过伙伴系统为 192 字节的 dentry 分配一页(4096 字节),将有 95% 的内存被内部碎片浪费。此外伙伴系统每分配一页都需要设置页表、清零内存、维护页帧状态,对于高频小对象分配来说开销过大。

1.2 Slab 的核心思想

Slab 分配器的基本策略是"以空间换时间、批量换效率":

  1. 缓存池:为每种热门内核对象创建专用缓存(kmem_cache),预分配一批对象储备
  2. 批量获取/归还:不是一次一页地向伙伴系统申请,而以 slab(多页)为单位批量获取,降低 buddy 压力
  3. 对象复用:释放的对象不立即归还物理页,而是放回空闲链表供下次分配直接使用——无需重新初始化和清零
  4. 构造/析构优化:通过 ctor 构造函数在对象初始化时预置不变字段,分配时只需重置可变字段

1.3 SLAB / SLUB / SLOB 三大实现对比

Linux 实现了三种 slab 策略,面向不同场景:

特性SLAB (Jeff Bonwick)SLUB (默认)SLOB
年代2.0 引入(1996)2.6.23 引入,2.6.36 默认嵌入式专用
设计目标高性能通用简化+调试友好内存极度受限
元数据开销高(每 slab CPU 缓存)低(嵌入 page 结构)极低
NUMA 支持一般原生支持(node partial 列表)无
调试能力良好优秀(slabinfo、RedZONE、Poison)无
内存利用效率中高优化到极限
目标场景旧系统兼容现代服务器/桌面/K8s内存 < 4MB>

二、SLUB 核心数据结构

SLUB 的设计哲学是"极简元数据"。它不单独存储 slab 的管理头,而是将所有元信息嵌入到 page 结构体中,实现"零额外内存开销"的管理。

2.1 page 中的 slab 元数据

SLUB 复用 struct page 的以下字段嵌入 slab 管理信息:

// include/linux/mm_types.h -- page 中用于 slab 的字段
struct page {
    // 当页面作为 slab 页时:
    struct {            // 匿名 union ( slab/slob 复用)
        struct folio *first_page; // slab 首页的 folio 指针
        void *freelist;           // 空闲对象链表头
        struct in_cache_info {    // 仅在 slab page 激活
            struct kmem_cache *slab_cache; // 所属缓存
            struct slab *slab_page;        // slab 描述符
        } __unitized;
    };
    
    unsigned int objects;     // 本页中对象总数
    unsigned int inuse;       // 已分配对象数
    unsigned int frozen:1;    // 是否在 CPU 缓存上
    unsigned int frozen:1;    // 是否已冻结(不归还到 node)
};

2.2 kmem_cache 核心结构

struct kmem_cache 是每种对象缓存的中央管理结构,也是 slab 调优的主要切入点:

// include/linux/slub_def.h
struct kmem_cache {
    struct kmem_cache_cpu *cpu_slab;    // per-CPU 快速分配缓存
    unsigned long flags;               // 对象约束(DMA/REF等)
    unsigned int size;                 // 对象实际大小
    unsigned int object_size;          // 用户请求大小(不含元数据)
    
    struct kmem_cache_node *node[MAX_NUMNODES]; // per-node
    
    unsigned int offset;               // 空闲指针在对象中的偏移
    unsigned int cpu_partial;          // CPU 缓存中部分分配上限
    
    struct kmem_cache_order_objects oo; // min/alloc (= order << 16>

2.3 kmem_cache_cpu:per-CPU 快速路径

这是 SLUB 性能的关键:无锁、无原子操作、per-CPU 独占。

struct kmem_cache_cpu {
    void **freelist;      // 空闲对象链表头指针
    unsigned long tid;    // 全局唯一版本号(防止ABA问题)
    struct page *page;    // 当前活跃 slab 页
    struct page *partial; // slab 部分空闲页链表(per-CPU)
};

// 快速分配路径(简化)
static __always_inline void *slab_alloc(struct kmem_cache *s, gfp_t gfp)
{
    void **object;
    
retry:
    // 1. 检查 CPU 缓存的空闲链表
    object = READ_ONCE(cpu_slab->freelist);
    if (unlikely(!object || !cpu_slab->page))
        goto new_slab;
    
    // 2. CAS 尝试摘链(无锁)
    if (this_cmpxchg_double_pointer(), ...,object, ...))
        return void *)object;
    else
        goto retry;
    
new_slab:
    // 3. 本地缓存耗尽,从 node 或 buddy 获取新 slab
    new_slab();
}

三、Slab 生命周期:分配与释放

3.1 分配的三级阶梯

SLUB 分配始终遵循 "快 → 中 → 慢" 三级路径保证性能:

CPU Cache Freelist (最快, O(1), 无锁)
  │ 耗尽
  ▼
CPU Partial Slab List (快, 同页内跳转)
  │ 耗尽
  ▼
Node Partial List (中, 跨页在node内移动)
  │ 空
  ▼
Buddy System (慢, 分配新页集)

二级缓存(CPU Partial)的设计是 SLUB 相比 SLAB 的最大改进:SLAB 只有一个全局 partial 列表,在高并发下严重争抢;SLUB 为每个 CPU 设置 independent partial 列表,将锁粒度从"全局"细化到"per-CPU",大幅降低多核扩展延迟。

3.2 释放(Put)的回收策略

对象的释放遵循"由快回慢"的策略,优先提升后续分配速度:

释放对象时:
├── 对象来自当前 CPU 的活跃页 → 放回 freelist(最快)
├── 其他 slab 页的全部空闲 → 若 CPU partial < cpu>

四、着色(Cache Coloring)机制

4.1 问题的起源

现代 CPU 的 L1 数据缓存是直接映射或组相联的(通常 64 字节/行)。如果同一 slab 中的所有对象起始地址都落在同一缓存行中,多核同时修改这些对象将导致严重的缓存行乒乓(False Sharing)。

4.2 着色的实现

Slab 通过在 slab 首部引入 colour_off 偏移,让同类型对象的起始地址在不同 slab 中偏移不同的缓存行位置:

slab 布局(假设 size=256, colour=64):

无着色时:      有着色时(colour=1..3):
对象0: obj3    slab1-对象0: 0 → 缓存行 X
对象1: 256     slab1-对象1: 256 → 缓存行 X+4
对象2: 512     slab2-对象0: 64 → 缓存行 X+1  ← 偏移!
对象3: 768     slab2-对象1: 320 → 缓存行 X+5
               slab3-对象0: 128 → 缓存行 X+2  ← 偏移!
               slab4-对象0: 192 → 缓存行 X+3  ← 偏移!

// 计算地址公式
obj_address = page_address + slab_offset + idx * s->size

可用的颜色数由 (slab_size - obj_size * obj_count) / cache_line_size 决定。适度的着色可以提升密集分配对象场景(如每分钟百万次的 dentry 创建)下约 8-15% 的操作速度。

五、NUMA 感知的 Slab 分配

在 NUMA 架构中,本地内存访问延迟比远端低 30-50%。SLUB 通过在每个 NUMA 节点维护独立的 partial 列表和远端回收策略来优化:

5.1 kmem_cache_node 结构

struct kmem_cache_node {
    spinlock_t list_lock;              // 保护下面两个列表
    unsigned long nr_partial;          // partial slab 数量
    struct list_head partial;          // 部分空闲 slab 链表(双向)
#ifdef CONFIG_SLUB_DEBUG
    unsigned long nr_slabs;            // 总 slab 数
    unsigned long total_objects;       // 总对象数
    struct list_head full;             // 全满 slab 链表
#endif
} ____cacheline_aligned_in_smp;

5.2 远端分配处理

当进程在 Node 0 上运行但 Node 0 的 partial 列表全空时,SLUB 的行为由 slab_nomerge 和 GFP flags 决定:

  • 无 GFP_THISNODE:尝试从最"便宜"的远端节点借用 slab,下次分配时优先移入本地
  • 有 GFP_THISNODE:即使远端更宽裕也必须在本地分配,否则返回 NULL
  • SLUB 默认行为:node partial 中不会执行任何形式的跨节点迁移,需依赖 unlikely 路径缓慢收敛

六、Slab 调试与运维工具

6.1 /proc/slabinfo 详解

/proc/slabinfo 是 slab 分配器的实时快照,输出每个缓存池的状态:

# cat /proc/slabinfo | head
slabinfo - version: 2.1
# name         : tunables     
dentry     245024 245600    192   21  2 : tunables  0  0  0 : slabdata  11695  11695
ext4_inode_cache 198730 201900   1088   15  2 : tunables  0  0  0 : slabdata  13460  13460
filp       52107 55296    256   16  1 : tunables  0  0  0 : slabdata   3456   3456
vm_area_struct 69420 70680   208   19  1 : tunables  0  0  0 : slabdata   3720   3720

各列含义:

  • :正在使用的对象数(未释放)
  • :总对象数(active + free)
  • :单个对象大小(含对齐填充)
  • :每个 slab 页中的对象数
  • / :活跃 slab 总数 / 总 slab 数

关键指标:

num_objs - active_objs 过大 = 缓存过度,active_objs / active_slabs 接近1 = slab 利用率高

6.2 内核参数调优

通过 sysctl 可调节 SLUB 行为:

# /etc/sysctl.d/99-tuned.conf

# 控制当空闲 slab 超过多少 CPU partial 对象时保留在缓存中
# 降低 → 减少内存占用,但增加新 slab 分配频率
# 提升 → 复用更多对象,减少碎片(默认 ~half of cpu_partial)
vm.min_slab_ratio = 5           # 默认5,表示默认保留策略

# 调整 CPU partial 上限,影响 CPU 缓存中保留的部分空闲 slab 数量
# 增大后在突发分配时复用多,但占用更多稳态内存
# 在 /sys/kernel/slab//cpu_partial 可直接调整单缓存

6.3 slabtop 实时监控

# 类似 top 的 slab 实时界面:
slabtop -d 1 -s c   # 每秒刷新,按活跃对象数排序

# 输出示例:
Active / Total Objects (% used)    : 2869843 / 3124296 (91.9%)
Active / Total Slabs (% used)       : 112097 / 112097 (100.0%)
Active / Total Caches (% used)      : 125 / 140 (89.3%)
Active / Total Size (used)          : 717.36M / 781.08M (91.8%)
Minimum / Average / Maximum Object  : 0.01K / 0.25K / 16.00K

  OBJS ACTIVE  USE OBJ SIZE  SLABS OBJ/SLAB CACHE SIZE NAME
116676 116676 100%   0.19K   5556       21     44.45M dentry
103130 103130 100%   0.57K   3683       28    117.92M kmalloc-512
 82530  82530 100%   0.13K   2846       29     22.77M kernfs_node
 70980  70980 100%   0.72K   3103       23     99.30M ext4_inode_cache

七、Slab 在容器与 NUMA 下的实战

7.1 容器内存限制下的 Slab 行为

当通过 cgroup v2 memory.max 限制容器内存时,SLAB 分配也受该限制约束——kmem 会计入 cgroup 内存用量。常见 OOM 场景:

  • dentry/inode 缓存暴涨:容器内大量小文件创建(如 NPM install、Docker 层构建)导致 dentry 缓存吃满限制
  • 网络 socket 压力:短连接风暴导致 TCP/uid_cache slab 持续增长
  • 用户态文件描述符限制:filp 缓存快速耗尽,触发 OOM 被提前杀死

排查命令:

# 查看 cgroup 内核内存使用
cat /sys/fs/cgroup/mycontainer/memory.kmem.usage_in_bytes

# 找出最大的 slab 缓存
awk '$3 > 10000 {print $1, $6}' /proc/slabinfo | sort -k2 -nr | head
# 或直接用 slabtop -s c -d 1

7.2 超大规模节点:手动清空 Slab

在 HPC 场景或突发内存压力时,可以通过 /proc 接口主动清空特定 slab 缓存:

# drop_caches 清空顺序:
# 1 → PageCache(文件缓存,此时不影响 slab)
# 2 → dentries + inodes(slab 项),立即释放大量 dentry 内存
# 3 → 以上两者全部

sync; echo 2 > /proc/sys/vm/drop_caches

# 注意:这只是临时清空,slab 会自动重建。反复使用说明:
# 1. 存在 slab 泄漏(某个缓存 active_objs 持续不降)→ 需 debug
# 2. 缓存过大但无热点 → 考虑降低 min_slab_ratio 或 container 限制

7.3 SLUB_DEBUG 的威力与性能代价

SLUB_DEBUG 是内核 slab 调试子系统,启用后在对象周围添加:

  • Redzone:对象头尾的哨兵区域(a5a5a5a5...),越界写入会破坏它
  • Poison:释放后填充 0x6b/0xa5 模式,防止 use-after-free 后的误用
  • Tracking:每个对象的 alloc/free 调用栈记录,slabinfo -v 可验证无泄漏

性能代价:增加约 15-35% 的分配延迟 + 5-10% 的内存开销。生产环境通常禁用(slab_nomerge 或 slub_debug=0),调试内核时启用(slub_debug=FZP)。

八、Slab/kmalloc 调用深度追踪

8.1 kmalloc 的尺寸分级

kmalloc() 是内核态最常用的通用分配器,它不是直接从 buddy 系统获取,而是从预建的 kmem_cache 数组(按 2 的幂大小分级)中分配:

// mm/slab_common.c -- kmalloc_caches 数组(简化)
struct kmem_cache *kmalloc_caches[KMALLOC_SHIFT_HIGH + 1];

// KMALLOC_SHIFT_LOW = 3  (8 bytes)
// KMALLOC_SHIFT_HIGH = 25 (32 MB)
// 常用的 9 级缓存覆盖 64B - 8KB 的大部分对象

// 申请路径:
kmalloc(size) →
  size > KMALLOC_MAX_SIZE ? NULL :
  index = kmalloc_index(size) →      // 找到覆盖 size 的最小索引
  return kmem_cache_alloc(kmalloc_caches[index], flags);

超过 KMALLOC_MAX_SIZE(通常 4MB 或 8MB,取决于配置)的申请应使用 vmalloc(),它通过页表映射非连续物理页。

8.2 kfree vs kmem_cache_free 的区别

kfree() 需要先查找对象所属的缓存(virt_to_cache()),通过页结构体找到 slab 缓存,然后才能归还。这比专用 kmem_cache_free() 多了一次缓存查找。

// kfree 简化路径
kfree(obj) ->
  page = virt_to_head_page(obj)   // 从虚拟地址找到首页
  slab = page_slab(page)           // 判断是否是 slab 页
  if (slab)
    s = page->slab_cache           // 找到所属缓存
    kmem_cache_free(s, obj, _RET_IP_)
  else
    __free_pages(page, compound_order(page))  // 大页直接还 buddy

九、Slab 与 Buddy 边界模糊趋势

从内核 5.x 开始,两种变化正在模糊 Slab 和 Buddy 界限:

1. Slab Folio 化:自 5.17 内核引入 Folio(复合页)概念后,SLUB 开始支持超大 slab 直接从 buddy 分配 2MB/1GB 巨页,避免 TLB miss。对数据库 buffer pool(如 1GB hugepages + SLUB)这一变化减少 40-60% TLB miss。

2. Magna Caching:通过 BPF 或内核钩子自定义 kmem_cache 替换路径,允许将特定类型(如 task_struct)重映射到 CXL-attached memory,实现跨 NUMA 节点的 slab 远端化。 Intel Sapphire Rapids+ 配合 CXL 2.0 的有望让 slab 从本地内存走向异构存储层级。

总结

Slab Allocator 不是一个孤立的技术——它是内核与硬件之间的一层精密匹配:用着色匹配 CPU 缓存拓扑,用 per-CPU 缓存匹配多核硬件特性,用 partial 列表匹配伙伴系统的批量伙伴粒度,用构造函数匹配对象生命周期。对容器化、NUMA 网络存储等现代场景,Slab 的稳定性和高效性直接决定了系统能否在高压下不抖动、不引爆。

理解 Slab 的工程链路与调试工具,可以帮助开发者在面对 cgroup 内存 OOM、NUMA 节点抖动、缓存失效延迟等问题时快速定位根因——不是盲目 drop_caches 或加内存,而是从分配路径、缓存命中、页面迁移动态等维度系统地优化。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论