一、SLUB 分配器的设计哲学与起源
SLUB(Simple List of Unused Blocks)是 Linux 内核中最广泛使用的内存分配器,从 2.6.23 内核(2007 年)起取代 SLAB 成为默认分配器。它的设计者 Christoph Lameter 在提交补丁时明确表述了目标:消除 SLAB 管理结构中的复杂队列,转而利用 struct page 本身存储管理信息,以此简化内存分配路径、提升大规模并发场景下的可扩展性。
SLUB 的核心思想可以用一句话概括:每个 CPU 变量持有自己的 partial slab 列表和 full slab 列表,分配操作在无锁情况下优先从 CPU-local 缓存获取对象,仅在缓存不足时才与全局 kmem_cache 交互。这种设计使得单步分配的平均指令数从 SLAB 的约 380 条降至 150 条左右,在四路服务器上的扩展效率显著优于 SLAB。
1.1 三大分配器的演进历程
Linux 内核内存分配器的演变聚焦于三个指标:内存碎片率、多核扩展性、分配延迟。SLAB 引入面向对象缓存的思想,将高频分配的结构体(如 task_struct、file、inode)缓存化,减少了初始化开销,但每 CPU 管理结构在多核下产生大量队列竞争。SLUB 引入 per-cpu partial slab 解决扩展性问题,但 debug 配置下的内存开销较高。SLOB(Simple List Of Blocks)面向嵌入式小内存场景,使用首次适应(first-fit)算法,完全不考虑并发性能。
SLUB 在 4.x 内核之后经历了一次重大重构:引入了 slub_debug 的通用追踪框架、quotas 硬限制 CPU 缓存总量以降低内存浪费、以及 defragmentation 机制支持 NUMA 节点的 slab 迁移。在 ARM64 平台上,SLUB 通过 kmem_cache_cpu 的 node 字段远程感知 NUMA 拓扑,进一步优化了跨节点分配。
二、SLUB 的内存组织与关键数据结构
SLUB 面向的最小分配单元是 slab:每个 slab 由连续物理页组成(通常 order-0,即 4KB 一页),被等分成固定大小的对象。管理结构 kmem_cache 持有该缓存的全局信息,每个 NUMA 节点维护独立的部分空闲列表。
2.1 kmem_cache 结构体
kmem_cache 是每类对象缓存的控制中枢,其核心字段包括:
- flags:行为标志位,如 SLAB_ACCOUNT(计入 cgroup 内存)、SLAB_TYPESAFE_BY_RESUME(快照式的类型安全重建)、SLAB_PANIC(路径失败触发 panic)
- size / object_size / inuse:前者是用户请求对齐后的分配大小(含元数据),后者是纯有效载荷大小,inuse 标记被 init 过的区域大小
- offset:freelist 指针在对象内部的偏移
- allocflags:kmalloc 时的 GFP 标志传递
- oo:高位为 slab 每页最大对象数,低位为 slab 分配阶(order),编码在同一个 32 位字中以减少内存占用
- min:partial slab 列表的最小对象数,低于此值的 slab 被回收至节点管理
- cpu_slab:每 CPU 结构的指针,其 freelist 字段直接指向下一个可分配对象
- node[]:每 NUMA 节点的 kmem_cache_node 数组,管理 full/partial slab
2.2 slab 元数据的存储方式
SLUB 的所有元数据都存储在 struct page 结构体中,不依赖元数据 slab(meta-data slab)。具体映射为:
- page->freelist:指向 slab 的第一个空闲对象(首次分配时的起始指针)
- page->objects:该 slab 的总对象数
- page->inuse:已分配对象数
- page->frozen:是否为 CPU-local 缓存(冻结状态表示该 slab 仅归属某 CPU,不被全局回收)
- page->next / page->pages:partial slab 链表指针和一个短链接的后继页数
这种设计消除了 SLAB 中 colour 和 bufctl 的复杂计算,同时使得 get_freepointer / set_freepointer 的实现变得极其轻量。
2.3 CPU 无锁分配路径
SLUB 最关键的优化是 CPU-local 缓存。kmem_cache_cpu 结构体的三个核心字段:
- freelist:指向下一个可直接分配的对象(链表头),NULL 表示 CPU 缓存已耗尽
- page:当前绑定的 slab,所有分配从该页内取对象
- partial:per-cpu 的部分空闲 slab 头,当当前 page 分配完后,从此列表中取出下一个 slab
分配路径如下:检查 freelist 是否为 NULL → 不为 NULL 直接取头、更新 freelist = get_freepointer(object);为 NULL → 检查 partial 是否非空 → 非空则取出新的 page 绑定并重置 freelist;partial 也为空 → 从 NUMA 节点的 partial 列表请求 slab;node partial 也为空 → 向伙伴系统申请新 slab。整条热路径无任何锁或原子操作。
三、SLUB 分配与释放的代码实现
3.1 快路径:new_slab() 与早期分配
当 page 耗尽后,SLUB 通过 new_slab() 从伙伴系统申请新的 slab 页。代码逻辑大致如下:
struct page *new_slab(struct kmem_cache *s, gfp_t gfpflags, int node)
{
struct page *page;
unsigned intorder = oo_order(s->oo);
page = allocate_slab(gfpflags, order, s, node);
if (!page)
return NULL;
page->freelist = page_address(page); // 对象的起始
page->inuse = 0;
page->objects = oo_objects(s->oo);
page->frozen = 1;
start_new_slab(s, page); // 进行 object 对齐划分和初始化
return page;
}
核心的 allocate_slab() 通过 alloc_pages_node 调用伙伴系统的 __alloc_pages 函数,指定 order 为 oo_order(s->oo)。在 SLUB 默认策略下,order 通常为 0,即每 slab 1 页(4KB),对象数由 size 决定(如 kmalloc-64 就是 4KB/(64+redzone) ≈ 60 个)。
3.2 早期分配对象:freelist 链表构建
slab 刚创建时,所有对象都是空闲的,通过指针串联成 freelist 链表。具体做法是在对象内部偏移 offset 处存放下一个空闲对象的地址,最后一个为 NULL(或特殊 sentinel)。set_freepointer() 宏通过直接写入实现,由于该操作发生在分配之前就完成了,因此没有任何并发问题。
SLUB 对象的实际内存布局为:
- 当 SLAB_RED_ZONE 开启时:实际分配大小为
size + 2 * sizeof(unsigned long),前后各有一个红色警戒区 - 当 SLAB_POISON 开启时:空闲对象被填充 0x5a5a5a5a,分配后才会清零或写入
- 当 SLAB_STORE_USER 开启时:每个对象中记录最后一次分配和释放的栈帧地址
3.3 常规释放路径:put_cpu_partial()
释放对象时,SLUB 首先将对象重新链入 CPU-local 的 freelist 链表,然后检查 inuse 是否变为 0(即该 slab 的所有对象都已归还)。如果 inuse == 0 且 slab 不在 full 列表中,则将 slab 链入 page->node 的 partial 列表。如果 inuse == 0 且 slab 已在 partial 列表中,则进行以下决策:
- per-cpu partial 不足 → 直接挂入 per-cpu partial 列表
- per-cpu partial 达到 min_partial 上限 → 挂入 node partial 列表(可能被其他 CPU 或系统空闲回收使用)
- slab 总数超额 → 直接归还给伙伴系统
SLUB 释放的 fast path 是一个简单的指针交换操作。在单次释放的代码路径中,SLUB 需要避免两个竞争:一是并发分配可能导致 freelist 链表断裂(通常通过 preempt_disable 避免本地 CPU 竞争,跨 CPU 竞争通过 page->frozen 标志和 slab_free_hook 回调处理);二是 partial slab 列表的修改需要关中断(local_irq_save)来保护。
四、SLUB 的内部碎片与外部碎片管理
4.1 内部碎片的量化模型
内部碎片是指分配给对象的实际内存大于请求大小的浪费部分。SLUB 通过最佳阶数匹配(best-fit order)策略选择不同的 slab 缓存(kmalloc-8、kmalloc-64、kmalloc-256 等)。这些预定义缓存的大小通常是 2 的幂次,边界处可能产生高达 50% 的浪费。
一个关键的性能权衡是“缓存数量 vs 碎片率”:缓存越小(如 8 字节),碎片率趋近于 0,但管理开销(每个 slab 的对象数增加、元数据占用比上升)变大;缓存越大(如 4096 字节以上),大对象直接走伙伴系统(kmalloc_large)。SLUB 在初始化时调用 create_boot_cache 和 create_kmalloc_cache 预定义 72 个左右的通用缓存槽位。
4.2 外部碎片与页面回收
外部碎片是指空闲内存总量能满足请求,但被分割成不连续的小块而无法分配大阶连续页面。SLUB 通过 memory compaction(/proc/sys/vm/compact_memory)和 __GFP_RETRY_MAYFAIL 标志来处理外部碎片问题。当 NODE 内存紧张时,SLUB 的 shrink 调用链(kmem_cache_shrink)会释放 CPU-local partial slab 和 node partial slab 给伙伴系统,最终的伙伴系统通过 __alloc_pages_slowpath 中的 compaction 机制尝试合并零散页。
在 NUMA 架构下,SLUB 还会执行 node-based 的 slab 迁移。当某个 NUMA 节点发生跨节点分配时,SLUB 的 __slab_alloc_node 会检查并优先从本地节点的 partial 列表获取对象。kmem_cache_alloc_node / kmem_cache_alloc_trace 接口显式传递 node id,SLub 的 node fallback 策略(alloc_from_partial_node_s_rr)按 NUMA distance 降序遍历节点。
五、SLUB 的 Debug 机制
5.1 KASAN 与 Redzone
KASAN (Kernel Address Sanitizer) 是内核空间内存越界检测的核心工具,它依赖 SLUB 的 redzone 扩展和对每个对象的影子内存(shadow memory)存储。在 KASAN 模式下,SLUB 实际分配大小变为:
aligned_size + 2 * kasan_red_size + 影子内存映射区域(可选)
KASAN 的 shadow memory 按 1:8 比例分配(每个字节对应 8 字节内存),用 0xFF 表示红区、0x01-0x07 表示已使用对象的可用尾区、0x00 表示完全空闲。每次 slab 分配和释放都会更新 shadow 映射,通过 check_memory_region 在每次访问时比对 shadow 值判断是否越界。由于 kasan_red_size 通常等于 32 字节,KASAN 模式下的 SLUB 比普通模式额外消耗约 12.5% 的内存。
5.2 slub_debug 的追踪框架
slub_debug 提供了丰富的运行时检测选项:-p 开启 poison/redzone,-u 开启 user store(记录 trace),-a 开启 quarantine(释放后防复用),-t 开启 trace(打印 slab 分配记录)。例如启动参数 slub_debug=PUZ 同时开启 poison、user store 和 redzone:
[ 5.234123] =============================================================================
[ 5.234456] BUG: KASAN: slab-out-of-bounds in io_thread+0x1a0/0x550
[ 5.234789] Read of size 8 at addr ffff888076a3b540 by task swapper/0
...
[ 5.235123] 0x0000000003957b8c: deadbeefdeadbeef
[ 5.235456] ^
[ 5.235789] Padding ffff888076a3b538: 5a 5a 5a 5a 5a 5a 5a 5a |aaaaaaaa |
调试信息会显示越界地址相对于对象起始的偏移、以及属于红区字段还是对象的有效载荷。slub_debug 的 quarantine 通过 s->cpu_slab's node 实现内存对象的延迟释放,释放的对象先进入 quarantine 队列(LIFO),直到队列满或内存压力触发才真正归还。
六、SLUB 在 NUMA 与并发下的优化
6.1 CPU slab 的无锁设计
SLUB 的核心并发假设是:同一个 CPU 上的分配与释放不会真正并发关中断——关中断后不允许调度。因此,slab 的 freelist 操作可以不使用原子指令(CAS/LL-SC),而仅仅依靠 preemption disable 来保证互斥。这一假设在以下场景下不成立:
- 中断处理程序也在同一 CPU 上执行 kmalloc/kfree,导致嵌套访问同一 slab
- 硬中断上下文的 NAPI 分配和软中断的 kfree 访问同一 slab
为此 SLUB 在关中断的分配点使用 slab_alloc_node(s, GFP_ATOMIC, ...),通过 __slab_alloc() 路径访问 kmem_cache_cpu。这种设计虽然消除了大部分锁争用,但在硬中断触发的分配中仍然会引入不可预期的延迟。为解决这一问题,Linux v5.2 引入了 preempt-rt 兼容版本和 __GFP_ACCOUNT 控制中位,将原子分配迁移到中断安全路径。
6.2 NUMA 的实现细节
SLUB 的 NUMA 支持涉及三个层面的优化:
- CPU-local slab 的 node 感知:kmem_cache_alloc_node 通过指定 node id,如果本地节点有可用 slab 则直接返回,否则 fallback 其他节点
- kmalloc_node 与 vmalloc_node:直接传递 NUMA node 信息绕过感知
- memory cgroup 的 memcg_kmem:memcg 统计并限制每个 cgroup 的 kmem 用量,通过 obj_cgroup (objcg) 在每个对象头部附加 cgroup 归属
AMD EPYC 8004 代平台上的 NUMA 拓扑变化(从 2 路 8-NUMA 到 12 路 1-NUMA)曾暴露出 SLUB partial slab 分布不均的问题:某节点持有的 partial slab 过多导致另一节点持续 cross-node 分配。内核社区引入的 kmem_cache_cpu's node 动态迁移机制虽然缓解了这一问题,但无法彻底消除。
七、SLUB 与其他分配器的量化对比
在 SPEC2017 和自定义的 slab-microbenchmark 测试下,三类分配器的主要指标差距如下:
- 单次 kmalloc/kfree 延迟(中等对象 size):SLUB 约 75ns / 110ns,SLAB 约 85ns / 140ns,SLOB 约 120ns / 160ns(x86-64 16 核)
- 8 核并发线性扩展:SLUB 8x 核数下吞吐量约为单核的 6.2 倍,SLAB 仅 4.8 倍,SLOB 约 2.1 倍
- 内存开销(kmem_cache + user_object):SLUB 约 6.5%,SLAB 约 7.1%,SLOB 约 0.8%
- 平均内部碎片率(mixed workload):SLUB 约 12%,SLAB 约 14%,SLOB 约 38%
- 冷启动调用次数(每次 slab 初始化后首次分配):SLUB 约 350 条指令,SLAB 约 780 条指令,SLOB 约 220 条指令
SLUB 的核心优势在于并发场景下的分配吞吐量和对多核 NUMA 的友好支持。然而,它在单线程、小内存碎片敏感的场景下并不一定是最优选择。例如嵌入式系统更倾向于使用 SLOB 或者完全禁用 SLUB 的 redzone 以减少内存消耗。
八、SLUB 源码阅读的工程化建议
对于内核开发者而言,阅读 SLUB 源码需要关注以下关键入口:
- mm/slub.c:核心实现文件,包含 slab_alloc、slab_free、__slab_alloc_node、new_slab、discard_slab 等核心函数
- include/linux/slub_def.h:struct kmem_cache、struct kmem_cache_cpu、struct kmem_cache_node 的定义
- mm/slab_common.c:kmalloc()、kfree() 的通用入口,负责缓存查找和跨缓存转发
- Documentation/vm/slub.rst:SLUB 的运行时参数和调试接口说明
调试 SLUB 的运行时可读参数位于 /sys/kernel/slab/<cache_name>/,包括:cpu_partial、min_partial、objects、object_size、inuse、poison、redzone、store_user、trace 等。通过 cat /proc/slabinfo 可以查看全局的使用统计,包括活跃对象数、总对象数、每个 slab 的页数、每个对象的大小等。对于性能分析,/proc/meminfo 中的 Slab、SReclaimable、SUnreclaim 字段给出了 slab 内存占用的总体视角。
总之,SLUB 作为 Linux 内存管理子系统中的核心组件,其设计糅合了计算机科学中的缓存友好、最小化元数据、NUMA 感知、无锁并发等原则。理解 SLUB 的工作原理不仅能帮助开发者写出更高效的内存敏感代码,也为调试内核泄漏、越界访问等内存问题提供了必要的理论基础。

发表评论 取消回复