引言:为什么内核不能直接用伙伴系统?
在 Linux 内核中,物理内存的管理由伙伴系统(Buddy System)负责,它以 2 的 n 次方个页框为单位分配内存。但内核运行时大量需要的不是整页,而是小对象:task_struct(约 2KB)、inode(约 600B)、dentry、文件描述符、网络缓冲区头部、各种私有数据结构。如果每个对象都分配一页(4KB),内存碎片和内部浪费将变得不可接受,且频繁的页分配和释放会带来严重的锁争用。
SLAB 分配器家族(SLAB/SLUB/SLOB)正是为解决这一问题而生:它在伙伴系统之上构建了一层对象缓存层,让内核可以高效地分配和释放固定大小的小对象。
本文将从第一性原理出发,完整拆解 SLUB 分配器(当前 Linux 内核的默认实现)的核心机制,包括:缓存层级结构、本地 CPU 缓存、Freelist 管理、Slab 着色、RCU 释放、Freelist 随机化安全机制、以及调试与性能调优。
一、分配器家族简史
Linux 内核先后支持三种 SLAB 分配器实现:
| 分配器 | 引入版本 | 特点 | 适用场景 |
|---|---|---|---|
| SLAB | 1.0+ (1994) | Jeff Bonwick 原始设计,每 CPU 数组 + 共享缓存 | 早期内核 |
| SLOB | 2.6.22 | 极简实现,首次适应算法 | 嵌入式(无 MMU) |
| SLUB | 2.6.23 (默认) | 直接每 CPU Freelist,简化设计 | 通用(服务器/桌面) |
SLUB 由 Christoph Lameter 于 2007 年贡献,其核心理念是:移除 SLAB 中复杂的每 CPU 数组(array_cache),让每个 slab 页面直接维护自己的 freelist,并通过 per-CPU 指针加速分配。这一简化使其在 SMP 系统上的性能大幅领先 SLAB,成为目前绝大多数 Linux 发行版的默认分配器。
二、SLUB 核心数据结构
SLUB 分配器围绕三个层次的对象组织内存:
2.1 缓存(kmem_cache)
每一种需要频繁分配的对象类型对应一个 kmem_cache 实例。内核启动时会预创建数百个 kmem_cache(如 kmem_cache 本身、dentry_cache、inode_cache、vm_area_struct 等):
struct kmem_cache {
struct kmem_cache_cpu __percpu *cpu_slab; // 每 CPU 热缓存
struct kmem_cache_node *node[MAX_NUMNODES]; // 每个 NUMA 节点的半满/空 slab
unsigned long flags; // SLAB Flags(如 SLAB_POISON, SLAB_RED_ZONE)
unsigned int object_size; // 对象实际大小(含元数据对齐)
unsigned int size; // 对象对齐后大小(含调试字段)
unsigned int offset; // Freelist 指针在对象内的偏移
struct kmem_cache_order_objects oo; // 每个 slab 的阶数与对象数
const char *name; // 缓存名称(如"dentry")
struct list_head list; // 全局缓存链表
} ____cacheline_aligned;
每个 kmem_cache 通过 oo(order + objects)决定每个 slab 页的阶数(2 的 n 次方页)与可容纳的对象数量:
struct kmem_cache_order_objects {
unsigned int x; // 高 16 位:阶数(order),低 16 位:对象计数
};
2.2 Slab 页面(struct page 复用)
SLUB 不引入新的页描述符,而是复用 struct page 中的字段来编码 slab 元数据:
// 在 struct page 中,SLUB 使用以下字段:
union {
struct {
void *freelist; // 空闲对象链表头
unsigned inuse; // 已使用对象计数
unsigned objects; // 总对象数
};
};
当一页被 SLUB 用作 slab 页面时,PageSlab(page) 标志被设置,page->freelist 指向该 slab 中的第一个空闲对象。
2.3 每 CPU 热缓存(kmem_cache_cpu)
这是 SLUB 分配加速的核心:每个 CPU 都维护一个私有的小型对象缓存,无需加锁即可分配:
struct kmem_cache_cpu {
void **freelist; // 指向下一个空闲对象的指针
unsigned long tid; // 全局版本号,用于检测 CPU 迁移
struct page *page; // 当前正在使用的 slab 页面
};
分配路径第一层:分配对象时,SLUB 首先检查 this_cpu_ptr(cache->cpu_slab)->freelist,如果非空,直接 pop 一个返回,全程无锁、无原子操作。
三、对象分配与释放的完整路径
3.1 快速分配路径(Fastpath)
分配对象时,SLUB 按以下优先级搜索空闲对象:
- 每 CPU Freelist:如果 cpu_slab->freelist 不为 NULL,直接 pop 第一个对象,返回。无锁、无原子操作,性能极高。
- 当前 slab 页面:如果 CPU 当前持有的 slab 页有空闲对象(page->freelist 不为 NULL),将 freelist 指针切换到每 CPU 寄存器。
- Partial slab 链表:从 node->partial 链表中取出一个半满 slab,设置为当前活动页。
- 新 slab 分配:从伙伴系统分配新的 slab 页(alloc_slab()),初始化 freelist。
快速路径通常只需 10-20 个 CPU 周期,比 malloc 快一个数量级。
3.2 释放路径(Slowpath)
释放对象时,SLUB 将其插回 slab 的 freelist 链表头:
// 简化后的释放逻辑
*object = page->freelist;
page->freelist = object;
page->inuse--;
// 如果 slab 变空 - 归还伙伴系统或加入 node empty 链表
关键优化:释放对象时不立即合并 slab。当一个 slab 的所有对象都被释放(inuse 等于 0)时,SLUB 将其加入 node 的空闲列表,延迟归还伙伴系统。
四、Slab 着色(Slab Coloring)
Slab 着色是 SLAB/SLUB 中最精巧的性能优化之一,解决的是 CPU Cache 冲突(Cache Thrashing) 问题。
4.1 问题根源
多个 slab 页面中,相同偏移位置的对象倾向于映射到 CPU Cache 的同一 set。当这些对象被不同 CPU 线程交替访问时,会触发 Cache Line 的反复失效与重新加载(cache line bouncing)。
4.2 着色机制
SLUB 在每个 slab 页的起始处插入一个着色偏移(colour offset),使得不同 slab 内的对象起始地址相对于 cache line 发生偏移:
// slab 对象实际起始地址 = 页基址 + colour_offset
void *base = page_address(page);
void *start = base + s->colour_off;
// 每个后续 slab 页的 colour_off 增加 cache_line_size 的倍数
s->colour_off += s->colour;
if (s->colour_off >= s->colour_realign)
s->colour_off = 0;
s->colour 初始值为 CPU L1 Cache Line 大小(通常 64B),这意味着同一对象在不同 slab 页中会被放置在不同的 cache line set 上,极大减少了多核环境下的 cache 冲突。
4.3 实际收益
在高并发内存分配场景(如网络数据包接收),slab coloring 可以将 L1 Cache Miss 率降低 30% 到 50%,对 sk_buff 这类网络关键结构的处理尤为明显。
五、Freelist 随机化与安全防护
5.10+ 内核引入了 CONFIG_SLAB_FREELIST_RANDOM 和 CONFIG_SLAB_FREELIST_HARDENED,分别针对信息泄露和利用攻击进行防护。
5.1 随机化(SLAB_FREELIST_RANDOM)
传统 SLUB 的 freelist 使用 LIFO(后进先出)策略。释放的对象以链表形式串联,下一个分配将复用最近释放的对象。这种模式虽然缓存友好,但对攻击者可预测。
启用随机化后,SLUB 在 slab 初始化时会将空闲对象链表随机打乱(Fisher-Yates 洗牌算法),使得每次新 slab 的对象分配顺序都不相同。
5.2 加固(SLAB_FREELIST_HARDENED)
更激进的防护:SLUB 将 freelist 指针与一个全局随机密钥进行 XOR 编码:
// 编码后的 freelist 指针
ptr = ptr XOR s->random;
// 读取下一个空闲对象时需先解码
next = *(void **)ptr XOR s->random;
这种机制使得攻击者无法通过越界读取或 UAF 漏洞泄露 freelist 指针来推算 slab 布局,有效缓解堆喷(heap spraying)利用。
六、NUMA 感知与节点层级管理
SLUB 通过 kmem_cache_node 结构在每个 NUMA 节点上维护 partial 和 empty slab 列表:
struct kmem_cache_node {
spinlock_t list_lock; // 节点层锁(跨 CPU 访问时需要)
unsigned long nr_partial; // 该节点 partial slab 数量
struct list_head partial; // 半满 slab 链表(本地分配优先)
};
分配优先级(NUMA-aware 模式):
- 本地 CPU 热缓存(per-CPU freelist)
- 当前 NUMA 节点的 partial slab
- 远程 NUMA 节点(仅当本地无可用内存时)
通过 node_reclaim_mode 和 mempolicy,SLUB 可以严格控制跨节点访问,确保高频内存分配尽可能发生在本地 NUMA 节点。
七、调试与性能分析工具
7.1 /proc/slabinfo
内核提供了 slab 分配器的运行时统计接口:
$ cat /proc/slabinfo | head -5
dentry 25342 26412 192 21 1
inode_cache 18761 19008 608 6 1
kmalloc-512 10240 12288 512 8 1
关键字段含义:
active_objs:当前已使用对象数num_objs:总对象容量objsize:单个对象大小(含对齐/元数据)objperslab:每个 slab 含多少对象num_slabs:已分配的 slab 页组数
7.2 slabtop
类似 top 的实时监控工具:slabtop(1) 按活跃对象数降序显示各 kmem_cache 的实时使用情况。
7.3 kmemleak
CONFIG_DEBUG_KMEMLEAK 实现的内存泄漏检测器:它会扫描所有已分配内存区域(包括 slab、page、percpu 等),找出无指针引用的孤儿对象。
unffff880001234567 0x00000000000000ff 128
comm "my_module", pid 1234, age 1234567890 uscs
backtrace:
[<ffffffff81234567>] my_module_alloc+0x123/0x456
[<ffffffff81234abc>] init_module+0x78/0x100
7.4 KASAN(Kernel Address Sanitizer)
KASAN 在 SLUB 分配时添加Redzone(对象前后毒药区)和Shadow Memory来检测越界访问、UAF 和 Double-Free。它在 kmem_cache_create() 时将 object_size 扩展(增加 redzone 区域),并在 kmem_cache_free() 时将已释放对象标记为不可用。
八、通用 kmalloc 分配器
除了专用缓存,SLUB 还提供通用的 kmalloc() 接口,用于分配任意大小的内存。其实现策略是预建一组特殊大小的 kmem_cache(如 8B、16B、32B、64B、96B、128B、...、8KB、16KB),kmalloc 请求时选择不小于请求大小的最小缓存。
这意味着 kmalloc(50) 实际会分配一个 56B 的对象,存在约 12% 的内部碎片。对于高频分配场景,建议创建专用缓存以精确匹配对象大小。
九、生产环境最佳实践
9.1 选择正确的分配大小
- kmalloc() 适用于不超过 8KB 的小分配
- 超过一页的分配应使用 vmalloc()(不保证物理连续)或 alloc_pages()(物理连续)
- 频繁创建/销毁的对象应使用专用 kmem_cache_create()
9.2 GFP 标志选择
| 标志 | 含义 | 使用场景 |
|---|---|---|
| GFP_ATOMIC | 不睡眠,允许从紧急池中分配 | 中断上下文、自旋锁内 |
| GFP_KERNEL | 标准标志,可睡眠(触发回收) | 进程上下文(最常见) |
| GFP_NOIO | 可睡眠但不触发 I/O | 块层代码(避免递归) |
| GFP_NOFS | 可睡眠但不触发 FS 操作 | 文件系统代码(避免死锁) |
| GFP_ZERO | 分配后清零 | 需要零初始化的结构 |
9.3 NUMA 策略
在 NUMA 系统上,确保内存分配发生在访问它的 CPU 所在节点:
// 方法 1:设置进程内存策略
set_mempolicy(MPOL_PREFERRED, &node_mask, MAX_NUMNODES);
// 方法 2:使用绑定节点的 kmem_cache
kmem_cache_alloc_node(cache, GFP_KERNEL, target_node);
9.4 Slab 合并调优
调试时应禁用合并(slab_nomerge),使每种对象类型拥有独立缓存便于追踪;生产环境可启用合并,SLUB 会合并相似大小的缓存以获得稍好的性能。
十、SLUB 与 SLAB 的性能对决
在 5.15+ 内核的标准基准测试中:
| 指标 | SLUB | SLAB | 差距 |
|---|---|---|---|
| 单线程 kmalloc(512) | 约 15ns | 约 18ns | SLUB 快 17% |
| 8 线程争用 kmalloc(512) | 约 22ns | 约 38ns | SLUB 快 42% |
| 大规模 dentry 创建/删除 | 约 5.2s | 约 7.1s | SLUB 快 27% |
| 内存碎片率(8小时后) | 约 8% | 约 15% | SLUB 降低近一半 |
数据表明 SLUB 在多核并发和长时间运行两个维度上都具有显著优势,这也解释了它成为默认分配器的原因。
十一、未来演进
Linux 社区对 SLUB 的仍在持续改进,近期值得关注的方向:
- Tiny RCU(SRCU for slab):减少 slab 释放路径中的 RCU 延迟
- CPU Local 空闲链表:将空闲对象分布到不同 CPU 以减少争用
- Compaction 友好分配:使 SLUB 分配的 slab 页组更易被内存规整回收
- BPF 追踪点增强:通过 tracepoint/slab 提供更多运行时诊断能力
- Cgroup 内存压力分级:与 cgroup v2 内存压力事件更紧密集成的 slab 回收策略
总结
SLUB 分配器作为 Linux 内核对象内存管理的基石,通过精巧的设计将对数级复杂度的物理页管理简化为近乎 O(1) 的对象分配。其每 CPU 热缓存、Slab 着色、NUMA 感知三层加速机制,使得从嵌入式设备到大型 NUMA 服务器都能获得稳定的性能表现。
理解 SLUB 不仅是内核开发的基本功,也是系统性能调优的关键:当 /proc/slabinfo 中某个缓存 active_objs 持续接近 num_objs 时,意味着该缓存可能成为瓶颈;当 kmemleak 报告未引用对象时,需要审视代码中是否存在丢失的 kfree() 调用;当远程 NUMA 节点的 slab 分配频繁出现时,需要重新审视内存策略。
掌握了这三层视图,你就建立了从伙伴系统到 Slab 页面再到单个对象的完整内存管理心智模型。

发表评论 取消回复