Linux内核内存分配器深度工程实战:从SLAB到SLUB的架构演进与生产调优

一、为什么内核需要Slab分配器

Linux内核的内存管理采用分层架构。底层是页分配器(Buddy System),以页(通常4KB)为单位管理物理内存。然而内核中大量分配请求远小于一页——task_struct(约1.7KB)、inode(约580B)、dentry(约192B)等。若每次分配小对象都走路径向Buddy System申请整页,将产生严重的内部碎片并加重TLB压力。

Jeff Bonwick在 Solaris 2.4 中首次提出了 Slab 分配器(1994年),核心思想是对象缓存:每个高频使用的内核结构拥有专属的缓存池,释放时不归还系统而是标记为空闲,下次同类型分配直接从缓存获取。这样消除了内存碎片、减少了构造/析构开销、并利用CPU缓存局部性。

Linux 2.2 引入了 SLAB 实现,后续又加入了 SLUB(2.6.23)和 SLOB。到 Linux 6.8(2024年4月),SLAB 被彻底移除,SLUB 成为唯一的通用实现。这条演进路径背后,是对NUMA大规模部署、内存效率、代码复杂度的持续博弈。

二、SLAB 分配器的内部架构

SLAB 是最经典的实现,它的设计非常"分布式"——为每个缓存维护多层队列,精细但复杂。

2.1 核心数据结构

          struct kmem_cache (缓存描述符)
          ┌────────────────────────────┐
          │  slab_lists[]              │
          │  ├── slabs_full   ──────────► [slab1] → [slab2]
          │  ├── slabs_partial ────────► [slab3] → [slab4]
          │  └── slabs_free   ─────────► [slab5]
          │  cpudata[] per CPU         │
          │  ├── kmem_cache_cpu        │
          │  │   ├── freelist ────────► free obj chain
          │  │   └── page (current slab)
          │  node[] per NUMA node      │
          │  ├── kmem_cache_node       │
          │  │   ├── partial list      │
          │  │   └── spinlock          │
          └────────────────────────────┘

          struct slab (slab 管理头)
          ┌────────────────────────────┐
          │  s_mem      ─── 首个对象地址 │
          │  freelist   ─── 空闲链表头  │
          │  inuse      ─── 已用对象计数│
          │  ...                        │
          └────────────────────────────┘

在 SLAB 中,每个CPU维护一个活跃slab(kmem_cache_cpu),分配首先从CPU本地freelist的无锁链表取出。若本地slab无空闲对象则从Node的partial链表补充。当所有slab都满了,向Buddy System申请新页面。

2.2 三色标记与队列调度

SLAB独创了三色标记法(Empty/Partial/Full),分三个队列管理。这个设计使得CPU空闲对象尽可能快地被复用,但代价是1K+个CPU时,队列的开销本身就成了问题:每个CPU对每种尺寸维护独立的队列,队列指针的总内存开销在大型SMP系统上膨胀到数GB。

2.3 SLAB的缺陷

Google在实际部署中发现的SLAB问题包括:

  • NUMA扩展性差:Node维度的队列锁争用严重
  • 队列内存膨胀:每个CPU每缓存的array_cache指针开销过大
  • 调试信息冗余:当不再需要时,大量调试功能仍然带来开销
  • 合并复杂性:相似尺寸cache的合并逻辑极其复杂

三、SLUB:对SLAB的颠覆性简化

Christoph Lameter在2007年设计SLUB,哲学是:让struct page承担管理职责,砍掉中间层。

3.1 设计原则

SLUB最核心的简化是:不再为每个额外slab维护独立的struct slab管理头。所有元数据都保存在每个页的struct page中。

// include/linux/slub_def.h (简化)
struct kmem_cache {
    struct kmem_cache_cpu __percpu *cpu_slab;  // 每CPU活跃slab
    unsigned long flags;
    unsigned int size;          // 对象总大小(含元数据)
    unsigned int object_size;   // 用户请求大小
    unsigned int offset;        // free指针在对象中的偏移
    
    unsigned int cpu_partial;   // 每CPU partial对象数量阈值
    struct kmem_cache_order_objects oo/max/min; // slab阶数配置
    
    struct kmem_cache_node *node[MAX_NUMNODES]; // NUMA节点partial链表
} ____cacheline_aligned;

// 每CPU活跃slab
struct kmem_cache_cpu {
    void **freelist;    // 空闲对象链表头
    unsigned long tid;  // 事务ID(用于无锁同步)
    struct page *page;  // 当前活跃页
};

3.2 分配路径的分解

SLUB的kmalloc分配路径:

用户调用 kmalloc(size, flags)
         │
         ▼
    查找匹配的 kmem_cache(kmalloc_caches[size_class])
         │
         ▼
    进入 fastpath (开中断, 无锁):
         │
         ├── cpu_slab->freelist 非空?
         │      │
         │      ├── YES:  freelist = object->next, 返回obj
         │      │            (单链表摘除,O(1)无锁)
         │      │
         │      └── NO:  进入 slowpath
         │                    │
         ├── 检查 cpu_slab->page 是否有 partial 对象?
         │      │
         │      ├── YES: 更新 freelist,回到 fastpath
         │      │
         │      └── NO:   检查 node->partial 列表
                │
                ├── 非空: 抓取一个slab作为当前活跃slab
                │
                └── 空:   向 Buddy System alloc_pages(order)
                          初始化freelist,建立对象链表

3.3 释放路径

kfree(obj)
         │
         ▼
    通过 virt_to_head_page(obj) 找到所属的 page
         │
         ▼
    检查当前slab是否就是 CPU 的活跃slab (page == cpu_slab->page)?
         │
         ├── YES: 直接链入 freelist (无锁)
         │
         └── NO: 
                │
                ├── 判断释放后该 slab 是否全空?
                │      │
                │      ├── 全空: 直接归还 Buddy System
                │      │
                │      └── 部分空: 移入 node 的partial列表
                └── 判断是否超过 cpu_partial 阈值?
                       │
                       └── 触发 partial 回收

3.4 freelist的硬核防护

SLUB 在 CONFIG_SLAB_FREELIST_HARDENED 下对空闲链表指针进行加密:

// mm/slub.c
static inline void *freelist_ptr_encode(void *ptr, unsigned long ptr_addr, unsigned long cache_random)
{
    return (void *)((unsigned long)ptr ^ cache_random ^ swab(ptr_addr));
}

static inline void *freelist_ptr_decode(void *encoded, unsigned long ptr_addr, unsigned long cache_random)
{
    return (void *)((unsigned long)encoded ^ cache_random ^ swab(ptr_addr));
}

每个 cache 在创建时生成 64-bit 随机数 cache_random,空闲指针通过 XOR(指针值、随机数、地址字节交换)三重混淆存储。这样即使发生堆溢出,攻击者也无法直接伪造 freelist 指针。freeptr 的存储位置也选在对象中部(object_size/2 处),防止小范围溢出直接覆盖指针。

四、SLOB:极简主义的最后坚持

SLOB(Simple List Of Blocks)使用 first-fit 算法在所有空闲块中查找满足请求的块。其数据结构极其简单——单链表管理所有空闲区域,没有对象缓存、没有per-CPU优化、没有对齐保证。

// mm/slob.c 核心结构
static slob_t *slobfree;    // 全局空闲链表头
static slob_t *slob_pool;   // 当前块内空闲区域链表

typedef slob_t block_t;
struct slob_block {
    int units;                  // 块大小(以SLOB_UNIT为单位)
    struct slob_block *next;    // 下一个空闲块
};

4.1 SLOB的悲剧与价值

SLOB 在 Linux 6.4 中被从通用配置中移除,仅保留对极端嵌入式环境(<16MB RAM)的支持。它的致命缺陷:

  • 路径ological碎片:first-fit 算法在大内存上无法避免碎片累积
  • 线性搜索开销:分配复杂度和空闲块数量成正比
  • 无缓存复用:没有对象池化,每次分配都重新搜索

但 SLOB 的存在仍具价值:它证明了"极简"不一定优于"精心工程化",也验证了 Bonwick 对象缓存理论在小内存设备上的正确性。

五、Linux 6.8 移除 SLAB:一次迟到多年的决定

SLAB在2.6.23起就不再是默认分配器(SLUB替代),但其代码一直保留到 6.8。移除的背景:

5.1 技术债清理

三个分配器意味着三倍的 bug 面和维护负担。Google 报告 SLUB 在某些场景下优于 SLAB(Google 在自研内核版本上使用 SLUB 后解决了一些 SLAB 存在的抖动问题),而大部分主流发行版十年来一直默认使用 SLUB。

5.2 移除的数据影响

Vlastimil Babka 在 2024 LSFMM+BPF 峰会上宣布移除 SLAB 时指出:Linus 对 memcg 统计开销非常不满,而 SLUB 的简化设计让这类问题的修复变得更容易。移除 SLAB 的工作量:

  • 删除约 8000 行代码
  • 取消 CONFIG_SLAB 编译选项
  • 简化 mm/slab_common.c 中的共享逻辑
  • 统一 kmalloc 路径的 tracepoint

5.3 嵌入式场景的最终收敛

嵌入式开发者曾抱怨 SLAB 移除太快——但调查表明主要痛点是文档和过渡指南缺失,而非 SLUB 本身的功能不足。对于真正需要 SLOB 的低内存场景,SLOB 已完全独立化,不依赖 SLAB。

六、SLUB Sheaves:2025年后的新方向

Linux 6.18(2025年底)引入了 SLUB 的"sheaves"优化,是针对 RCU 密集、高并发场景的性能飞跃。

6.1 Sheaves 设计

Sheaves 本质是为每个CPU引入一个批量化的本地stash:传统 fastpath 每次只从 freelist 取一个对象,sheaves 允许一次预取 2^n 个对象到 CPU 缓存,后续分配完全本地化:

Without sheaves:   开中断 → 检查freelist → 取一个 → 关中断
With sheaves:      开中断 → 检查local_sheaf(无锁)→ 取一个 → 关中断
                    仅在sheaf耗尽时触发批量补充(带锁)

相关内核 patch(mm/slub.c):

struct sheaves {
    void *objects[BSD_SHEAVES_MAX];   // 本地stash数组
    unsigned int count;                // 当前可用数量
};

static DEFINE_PER_CPU(struct sheaves, sheaf_cache);

6.2 实测收益

LSFMM+2025 报道 sheaves 在 RCU-heavy 工作负载下提升显著,与 RCU RCU-bounded freelist 思想一脉相承——减少跨CPU同步的核心是"让数据准备好,而不是在需要时同步"。

七、生产环境调优实战

7.1 /proc/slabinfo 解读

slabinfo -:
name            active_objs num_objs objsize objperslab pagesperslab
kmalloc-96            15234   15234      96      42         1
kmalloc-64            48291   48960      64      64         1
dentry              108288  109200     192      21         1
inode_cache         102346  103296     584      12         1
task_struct           1842    1938    5376       2         4

关键字段分析:

  • objperslab:每个slab容纳对象数。值大→碎片少但单次分配页多
  • pagesperslab:构成slab的页数。task_struct 太大,需要4个连续页
  • active_objs/num_objs:活跃率。低于20%说明缓存过度增长,可能需分析泄漏

7.2 slabtop 实时分析

$ slabtop -o --sort=c
  OBJS ACTIVE USE OBJ SIZE SLABS OBJ/SLAB CACHE SIZE NAME
 10234  10234 100%  0.58K     12      72     432K dentry
  8467   7896  93%  0.19K      4      88     152K kmalloc-192

USE=100% 全活跃 → 正常。若远低于100% → 可能初始化时 slab 过大(pagesperslab偏高)导致浪费。

7.3 sysctl 调优参数

# /proc/sys/vm 中 slab 相关参数

# slab 回收阈值(low_reclaim_limit / min_free_reached)
# 当空闲 slab 命中率低时触发回收

# 通过 slub_debug 开启调试
echo 1 > /sys/kernel/slab/kmalloc-64/trace     # 追踪该cache所有分配/释放

# 通过 red_zone 在对象两端添加保护区域(检测越界写)
echo 1 > /sys/kernel/slab/dentry/red_zone

# 开启 poisoning(释放后填充特定字节,检测UAF)
echo 1 > /sys/kernel/slab/inode_cache/poison

7.4 内核配置选择

CONFIG_SLAB_MERGE_DEFAULT=y         ← 默认启用slub
CONFIG_SLUB=y                       ← 默认分配器
CONFIG_SLUB_TINY=y                  ← 替代SLOB的微型分配器
CONFIG_SLUB_DEBUG=y                 ← 调试基础设施
CONFIG_SLUB_CPU_PARTIAL=y           ← CPU partial拉伸
CONFIG_HARDENED_USERCOPY=y          ← 内核/用户边界sheaves

嵌入式场景应选用 CONFIG_SLUB_TINY(替代SLOB),而非编译SLOB。

八、SLUB 与 SLAB 最终对比总结

维度 SLAB SLUB SLOB
设计哲学 精细队列管理 极简 + 元数据嵌入页 线性搜索
freelist结构 每CPU array_cache + slab自由链 每CPU单链表嵌入page 全局 first-fit
NUMA支持 kmem_cache_node锁争用 独立partial链表 无
内存开销 高(每CPU、每Node队列) 低(利用page结构) 极低
分配复杂度(fastpath) O(1)但有锁竞争风险 O(1)无锁 O(n)线性
代码复杂度 ~8000行 ~4000行 ~600行
生产部署 已移除(6.8起) 默认 仅极端嵌入式
调试能力 基础 red_zone/poison/SHMEM 无

九、实战代码分析:一个 kmalloc-64 的完整生命周期

# 在 Ubuntu 24.04 (kernel 6.8) 上观察
$ sudo cat /sys/kernel/slab/kmalloc-64/alloc_calls | head -20
# 找到最高频调用栈

# 开启 slab event trace
$ sudo trace-cmd record -e kmem:kmalloc -e kmem:kfree -c &
$ stress-ng --malloc 10 --malloc-bytes 64k --timeout 30s

kmalloc-64 在大多数桌面/服务器系统中是最大量使用的通用cache,绝大多数小于64字节的分配都会命中它。它的 slab 配置通常是 objperslab=64, pagesperslab=1——正好一页 64×64=4096 字节,利用完美。

高压下观察指标:

$ cat /proc/slabinfo | grep kmalloc-64
kmalloc-64    48291   48960   64  64  1    0    0      0

# 最后几列含义:active_slabs, num_slabs, active_objs_limit
# 如果 active_objs 长期 == num_objs,说明 slab_lock 可能争用

十、迭代路上的设计启示

从 SLAB 到 SLUB 再到 sheaves,内核内存分配器的演进给我们三条工程铁律:

  1. 让页承担管理职责——Linux 这个"越改越简单"的核心思想:与其在 page 上堆砌元数据,不如让 page 自己知道"我是谁的数据"。
    1. 合并的胜利——最少cache原则——SLAB 最后维护了数千个合并后的 cache,SLUB 进一步强制合并,运维成本大幅降低。
      1. 无锁是唯一的出路——从 SLAB 的全局队列锁 → SLUB 的每CPU无锁 → sheaves 的批量stash,每次性能飞跃都来自同步开销的消除。
      2. 内核没有银弹,但 SLUB 的故事证明:简洁的架构比臃肿的优化更容易赢在未来。


        延伸研究:本文聚焦内核态 slab 机制。用户空间的内存分配器(jemalloc/tcmalloc/mimalloc)采用了不同的设计思路(线程本地缓存、size class对齐),后续文章会继续展开。
点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部