Linux 内存管理深度剖析(二):Slab/Slub 分配器源码解析
在上一篇文章中,我们梳理了 Linux Buddy 系统如何以页为单位管理物理内存。然而内核中大量数据结构(task_struct、inode、dentry……)都远小于一页(4 KiB),若每次都向 Buddy 系统申请整页,会造成严重的内部碎片。为此 Linux 在 Buddy 之上引入了 Slab 分配器层,专为固定大小的内核对象服务。
本文基于 Linux 6.4-rc1(commit ac9a78681b92)源码,深入剖析三种 Slab 实现的设计哲学、SLUB 的核心数据结构与分配/释放路径,并延伸到 vmalloc 虚拟连续内存分配。
一、三种分配器:slab / slub / slob
Linux 内核历史上存在三套互斥的 slab 实现,通过内核 Kconfig 编译选项选择其中之一。
1.1 SLAB(原始实现)
SLAB 于 1994 年由 Sun Microsystems 的 Jeff Bonwick 设计,1996 年移植到 Linux。其核心思想是对象缓存(object caching):对象释放后不立即归还给系统,而是保留在 cache 中,下次分配时直接复用,省去构造/析构开销。
SLAB 的主要特征:
- 每个 CPU 有独立的 array_cache(本地缓存),加速分配
- 每个 NUMA 节点有 shared cache 和 alien cache,跨节点对象可归还本地节点
- 支持 cache coloring(缓存行着色),减少 L1/L2 cache 行冲突
- 数据结构极为复杂,
struct kmem_cache在 SLAB 下超过 200 字节
SLAB 的缺点也同样明显:多层链表(slabs_partial / slabs_full / slabs_free)维护开销大;SMP 下锁竞争严重;代码可读性差,难以维护。
1.2 SLUB(默认选择)
SLUB 由 Christoph Lameter 于 2007 年编写,Linux 2.6.23 起成为默认分配器。其设计哲学是去掉队列、直接操作 slab:
“SLUB : A Slab allocator without object queues.” ——
mm/slub.c开头注释
SLUB 的核心优势:
- 无全局 full/free 链表:只维护 partial 链表,极大简化了 slab 状态机
- 无锁 fast path:利用
this_cpu_cmpxchg_double原子操作,分配/释放 fast path 完全无锁 - per-CPU partial 链表:减少 NUMA node 层面的
list_lock竞争 - 内存占用更小,代码更清晰(
mm/slub.c约 6500 行 vs SLAB 约 4500+ 行但逻辑更复杂)
1.3 SLOB(嵌入式系统)
SLOB(Simple List Of Blocks)是为内存极端受限的嵌入式系统设计的超简化版本。它将所有空闲内存组织成一个全局的自由块链表,不区分 cache,不区分 CPU,没有任何 per-CPU 结构。
SLOB 特征:
- 代码极短(约 600 行)
- 内存效率最高,开销最小
- 没有任何并发优化,性能差,不适合 SMP
- 从 Linux 6.2 起被标记为 deprecated,预计将来移除
1.4 编译配置
三种分配器在 init/Kconfig 中互斥:
1 | choice |
此外,SLUB 还有两个重要的子选项:
| 选项 | 说明 |
|---|---|
CONFIG_SLUB_DEBUG |
启用 red zone、poison、对象追踪,用于调试 |
CONFIG_SLUB_CPU_PARTIAL |
启用 per-CPU partial 链表,提升多核性能(默认开启) |
CONFIG_SLUB_TINY |
超精简 SLUB,去掉 per-CPU 结构,适合 CONFIG_EXPERT 场景 |
二、SLUB 核心数据结构
2.1 struct kmem_cache
struct kmem_cache 是 slab cache 的”描述符”,在 include/linux/slub_def.h 中定义:
1 | struct kmem_cache { |
关键字段解析:
- **
sizevsobject_size**:object_size是用户请求大小,size还包含 freepointer、red zone、padding 等元数据,size必须对齐到align。 offset:空闲对象的 next 指针存放在对象内部offset字节处(而非独立管理),这是 SLUB “无队列”设计的关键——空闲链表嵌入在对象本身中。- **
min_partial**:node 层面 partial 链表的最低水位线。当 partial 数量超过此值且一个 slab 变为全空时,该 slab 直接释放给 buddy,防止 partial 链表无限增长。 - **
oo**:kmem_cache_order_objects将 buddy order(高位)和该 order 下的对象数(低位)打包进一个unsigned int,查询时用位移即可。
2.2 struct kmem_cache_cpu
每个 CPU 拥有独立的 struct kmem_cache_cpu(include/linux/slub_def.h):
1 | struct kmem_cache_cpu { |
- **
freelist**:指向当前活跃 slab 的空闲对象链表头,fast path 直接从这里取对象。 - **
tid**(transaction ID):每次分配/释放成功后自增。与freelist一起参与this_cpu_cmpxchg_double,保证在无锁操作期间没有被抢占并切换 CPU 或被中断修改。 - **
slab**:当前”冻结”(frozen)的活跃 slab。一个 slab 被某 CPU 的 cpu_slab 持有时处于 frozen 状态,脱离 partial/full 链表管理。
2.3 struct kmem_cache_node
每个 kmem_cache 在每个 NUMA 节点都有一个 kmem_cache_node,在 mm/slab.h 中定义:
1 | struct kmem_cache_node { |
partial 链表通过 slab->slab_list 字段串联所有”部分使用”的 slab。list_lock 是该链表的保护锁,SLUB 设计的核心目标之一就是尽量减少对此锁的竞争。
2.4 slab 对象内存布局
一个 slab 的内存布局(开启 SLUB_DEBUG 时)如下:
1 | ┌──────────────────────────────────────────────────────────────┐ |
- payload:用户实际使用的内存(
object_size字节) - freeptr:对象空闲时,该位置存储指向下一个空闲对象的指针(位于
offset处);对象分配后此位置可被覆盖(除非开启CONFIG_SLAB_FREELIST_HARDENED) - redzone:调试时在对象两侧填充特征字节(
0xbb),释放时检测越界写 - padding:使每个对象对齐到
align字节边界
若开启 CONFIG_SLAB_FREELIST_HARDENED,freepointer 在存储时会与 s->random 进行 XOR 加密,防止指针被恶意篡改。
三、SLUB 分配路径
3.1 调用链总览
1 | kmem_cache_alloc(s, gfpflags) |
3.2 Fast Path:无锁 per-CPU freelist 分配
fast path 的核心在 __slab_alloc_node(mm/slub.c:3298):
1 | static __always_inline void *__slab_alloc_node(struct kmem_cache *s, |
分析:
raw_cpu_ptr获取当前 CPU 的kmem_cache_cpu,不禁用抢占。- 先读
tid,再读freelist/slab(barrier 防止乱序)。如果在这两步之间发生 CPU 迁移,cmpxchg会因 tid 不匹配而失败,重试。 this_cpu_cmpxchg_double原子地验证并替换(freelist, tid)二元组:若当前 CPU 上的值仍等于之前读到的(object, tid),则将其更新为(next_object, next_tid(tid))。若被中断/抢占修改了,则回到redo。- 这一机制完全无锁、无禁用中断,是 SLUB 高性能的关键所在。
3.3 Slow Path:___slab_alloc
当 fast path 失败(freelist 为空,或 slab 不匹配,或不支持 cmpxchg_double),进入 ___slab_alloc(mm/slub.c:3064)。该函数的控制流如下:
1 | ___slab_alloc |
关键路径代码(mm/slub.c:3161):
1 | new_slab: |
new_slab 内部调用 allocate_slab,后者通过 alloc_slab_page 向 Buddy 系统申请页,然后将所有对象串联成单链表(mm/slub.c:2032):
1 | slab->freelist = start; |
3.4 kmalloc:通用内存分配
kmalloc 是内核最常用的内存分配接口,底层使用预创建的 kmalloc_caches 数组:
1 | /* include/linux/slab.h */ |
每种类型(NORMAL、RECLAIM、DMA、CGROUP)都有一套大小分级的 cache,分级逻辑由 __kmalloc_index 确定(include/linux/slab.h:403):
1 | if (size <= 8) return 3; /* kmalloc-8 */ |
kmalloc 调用 kmem_cache_alloc(kmalloc_caches[type][index], flags),其分配路径与普通 slab cache 完全相同。超过 KMALLOC_MAX_CACHE_SIZE(通常为 8KB)的请求直接调用 alloc_pages。
四、SLUB 释放路径
4.1 调用链总览
1 | kmem_cache_free(s, x) |
4.2 Fast Path:归还到 per-CPU freelist
do_slab_free(mm/slub.c:3707)的快路径:
1 | static __always_inline void do_slab_free(struct kmem_cache *s, |
fast path 条件:释放的对象所在 slab 恰好是当前 CPU 的活跃 slab(slab == c->slab)。此时直接将对象头插到 freelist,同样用 this_cpu_cmpxchg_double 原子完成,无锁无禁中断。
4.3 Slow Path:__slab_free
当对象不属于当前 CPU 的活跃 slab 时,进入 __slab_free(mm/slub.c:3573),该函数用 cmpxchg_double_slab 原子操作更新 slab 的 (freelist, counters) 二元组,并按照以下逻辑决定 slab 的去向:
1 | __slab_free 逻辑(简化): |
三条关键决策:
- slab 仍被某 CPU 持有(frozen):只更新 freelist,不动链表,
stat(FREE_FROZEN)。 - slab 从全满变为部分空:若支持 cpu_partial,冻结后加入
per-CPU partial(CPU_PARTIAL_FREE);否则加入node->partial(FREE_ADD_PARTIAL)。 - **slab 全空,且
node->nr_partial >= min_partial**:slab 多余,直接discard_slab返还给 buddy,防止内存长期占用(FREE_SLAB)。
五、vmalloc:虚拟连续内存分配
5.1 vmalloc vs kmalloc
| 特性 | kmalloc |
vmalloc |
|---|---|---|
| 物理内存 | 连续 | 不连续(分散的页帧) |
| 虚拟地址 | 线性映射区(直接映射) | vmalloc 区域(需建页表) |
| 性能 | 极快(fast path 无锁) | 较慢(需分配页表、TLB flush) |
| 大小上限 | 通常 ≤ 8 KiB(超大走 alloc_pages) | 仅受虚拟地址空间限制 |
| 适用场景 | 小型内核对象、DMA 缓冲区 | 内核模块、大块临时缓冲区 |
5.2 struct vmap_area:虚拟地址空间管理
vmalloc 区域的每一段虚拟地址范围都由 struct vmap_area(include/linux/vmalloc.h:63)描述:
1 | struct vmap_area { |
vmalloc 维护两棵红黑树:
- **
free_vmap_area_root**:空闲虚拟地址段,使用 subtree_max_size 做增强型红黑树,可 O(log n) 找到足够大的空洞 - **
vmap_area_root**:已分配的虚拟地址段,关联到vm_struct
5.3 __vmalloc_node 分配流程
1 | /* mm/vmalloc.c:3319 */ |
__vmalloc_node_range(mm/vmalloc.c:3172)的核心步骤:
- 分配虚拟地址区间:调用
__get_vm_area_node,在free_vmap_area_root中找到合适的虚拟地址段,创建vm_struct和vmap_area,插入vmap_area_root。 - 分配物理页:
__vmalloc_node_range内部调用__vmalloc_area_node,逐页向 buddy 申请物理页(alloc_pages_node)。 - 建立页表映射:
vmap_pages_range→vmap_pte_range(mm/vmalloc.c:93):
1 | static int vmap_pte_range(pmd_t *pmd, unsigned long addr, unsigned long end, |
每个物理页分别建立 PTE,使得虚拟地址连续但物理地址可以分散。
1 | /* vmalloc 最终对外接口 */ |
六、SLUB 调试特性
6.1 CONFIG_SLUB_DEBUG
开启后,每个 slab 对象会附加以下验证信息(mm/slub.c 中的 setup_slab_debug):
| 特性 | 说明 |
|---|---|
| Red Zone | 对象左右两侧填充 0xbb,释放时检查越界写(buffer overrun) |
| Poison | 空闲对象填充 0x6b(POISON_FREE),分配后填充 0x5a(POISON_INUSE),检测 use-after-free |
| Track | 记录每个对象最后一次分配/释放的调用栈(存储在 slab 末尾) |
| Consistency Check | 分配/释放前后对 red zone 和 poison 值做一致性验证 |
调试模式下所有分配都走 slow path(持 list_lock),性能会显著下降,仅用于开发调试。
6.2 /proc/slabinfo
1 | $ cat /proc/slabinfo |
各列含义:active_objs(正在使用的对象数)、num_objs(总容量)、objsize(对象大小)、objperslab(每个 slab 含对象数)、pagesperslab(每个 slab 占页数)。
6.3 /sys/kernel/slab/ 目录
每个 cache 在 sysfs 下有独立目录:
1 | /sys/kernel/slab/ |
通过 cat /sys/kernel/slab/<name>/alloc_fastpath 可实时观察 fast/slow path 比例,辅助性能调优。
6.4 kmemleak 内存泄漏检测原理
kmemleak(mm/kmemleak.c)在每次 kmalloc/kmem_cache_alloc/vmalloc 时向自身的红黑树中插入一条记录,记录分配地址和大小。在后台扫描线程中,kmemleak 对所有内存区域做灰色标记扫描(类似 GC 的 mark-and-sweep):
- 标记根集:内核
.data、.bss、栈、CPU 寄存器等中的指针都是根。 - 传播扫描:从根集出发,凡被某个已知指针指向的分配块,标记为”可达”。
- 报告泄漏:扫描结束后,不可达的分配块视为泄漏,输出到
/sys/kernel/debug/kmemleak。
使用方式:
1 | # 开启扫描 |
七、诊断方法
7.1 slabtop:实时监控
slabtop 按内存占用排序,实时刷新(类似 top):
1 | $ slabtop |
7.2 vmstat -m:slab 内存汇总
1 | $ vmstat -m |
vmstat -m 等价于读取 /proc/slabinfo,输出格式更友好。
7.3 BPFtrace 追踪分配事件
1 | # 追踪所有 kmem_cache_alloc,打印 cache 名称和分配大小 |
或者追踪特定 cache 的分配栈:
1 | bpftrace -e ' |
7.4 perf stat 统计 kmalloc 频率
1 | # 统计 10 秒内 kmalloc 事件次数及分布 |
7.5 kmemleak 排查泄漏
1 | # 1. 确认内核开启了 CONFIG_DEBUG_KMEMLEAK |
7.6 常见问题与调优建议
| 现象 | 诊断命令 | 可能原因 |
|---|---|---|
| slab 占用持续增长 | watch -n1 slabtop |
对象泄漏,检查 kmemleak |
alloc_slowpath 比例高 |
/sys/kernel/slab/*/alloc_slowpath |
cpu_partial 太小,调整 cpu_partial 参数 |
| NUMA 节点间 slab 迁移频繁 | alloc_node_mismatch 计数 |
进程绑核策略不合理,或 NUMA 不均衡 |
kmalloc-512 使用率低 |
slabtop 的 USE% 列 |
分配大小离散,考虑专用 cache |
| order_fallback 频繁 | /sys/kernel/slab/*/order_fallback |
内存碎片严重,触发 kcompactd |
八、总结
SLUB 分配器的设计哲学可以用三个词概括:简化、无锁、分层。
- 简化:去掉 per-CPU 队列和全局 full/free 链表,只保留 per-CPU slab + partial 链表,状态机更清晰。
- 无锁:fast path 借助
this_cpu_cmpxchg_double实现无锁分配/释放,即便在高频kmalloc/kfree场景下,锁竞争也极低。 - 分层:
cpu_slab(per-CPU,无锁)→ cpu->partial(per-CPU,本地锁)→ node->partial(NUMA,spinlock)→ buddy(全局,zone lock),分层缓存大幅减少向 buddy 申请的频率。
理解这套机制不仅能帮助内核开发者写出对分配器更友好的代码(例如:优先使用专用 kmem_cache 而非通用 kmalloc,保持对象大小一致),更能在遇到内存问题时快速定位根因。下一篇文章将进入页面回收机制(LRU、kswapd、内存压力通知),探讨内核如何在内存紧张时”腾出”空间。
参考资料
- Linux 6.4-rc1 源码:
mm/slub.c、mm/vmalloc.c、include/linux/slub_def.h、mm/slab.h - Christoph Lameter, SLUB: The unqueued slab allocator, Linux Symposium 2007
- Mel Gorman, Understanding the Linux Virtual Memory Manager, 2004
- lwn.net: The SLUB allocator
- lwn.net: SLUB performance tweaks
- kernel.org: Documentation/vm/slub.rst