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,内核内存分配器的演进给我们三条工程铁律:
- 让页承担管理职责——Linux 这个"越改越简单"的核心思想:与其在 page 上堆砌元数据,不如让 page 自己知道"我是谁的数据"。
- 合并的胜利——最少cache原则——SLAB 最后维护了数千个合并后的 cache,SLUB 进一步强制合并,运维成本大幅降低。
- 无锁是唯一的出路——从 SLAB 的全局队列锁 → SLUB 的每CPU无锁 → sheaves 的批量stash,每次性能飞跃都来自同步开销的消除。
内核没有银弹,但 SLUB 的故事证明:简洁的架构比臃肿的优化更容易赢在未来。
延伸研究:本文聚焦内核态 slab 机制。用户空间的内存分配器(jemalloc/tcmalloc/mimalloc)采用了不同的设计思路(线程本地缓存、size class对齐),后续文章会继续展开。

发表评论 取消回复