SLUB:小对象为什么不直接按页分配
上一篇讲了 buddy allocator 如何按阶管理物理页的分配与释放。但 buddy 的最小粒度是一页(4KB)。内核中绝大多数对象远小于 4KB——一个 struct dentry 是 192 字节,一个 struct inode 是 600 字节左右。如果每个小对象都分配一整页,内部碎片率将高达 95% 以上。
核心问题可以压成一句话:
内核对象通常比一页小得多,SLUB 用 slab cache 把页切成同类型对象池,减少碎片和初始化成本。
问题从哪里来
内核不断创建和销毁各种数据结构:每次 open() 分配一个 struct file,每次路径查找分配一个 struct dentry,每次网络包到达分配一个 struct sk_buff。这些对象有两个共同特点:
第一,大小远小于 4KB。如果用 buddy allocator 直接分配,一个 192 字节的 dentry 占用 4096 字节物理页,利用率不到 5%。
第二,同类型对象的分配/释放极为频繁。dentry 在路径解析密集的工作负载中每秒创建/销毁数十万次。每次都调用 buddy allocator 意味着每次都可能竞争 zone->lock。
slab allocator 的解决思路:从 buddy 获取整页(或多页),把这些页预切分成固定大小的对象槽位。同类型对象共享一个"cache",分配时从 cache 的空闲列表取一个槽位,释放时归还槽位。页面在所有槽位都空闲时才归还给 buddy。
Linux 内核历史上有三代 slab 实现:SLAB(经典实现)、SLOB(嵌入式极简)、SLUB(当前默认)。SLUB 在 2007 年成为默认,设计目标是减少 SLAB 的元数据开销和复杂的 per-node 队列。
SLUB 的核心概念
四个层次的抽象:
1 | |
kmem_cache:一个 slab cache 对应一种对象类型。dentry_cache 专门分配 dentry,filp_cachep 专门分配 file 结构体。每个 cache 知道自己对象的大小、对齐要求和可选的构造/析构函数。
slab:一个或多个连续物理页,被切分成固定数量的对象槽位。对象之间通过内嵌的 free pointer 链接成空闲链表。一个 slab 的状态:full(所有对象被使用)、partial(部分使用)、empty(所有对象空闲)。
per-cpu slab:每个 CPU 绑定一个当前活跃的 slab。分配时直接从该 slab 的 freelist 取对象——无需任何锁。这是 SLUB 性能的关键。
partial list:当 per-cpu slab 满了(或被替换),它进入 node 级别的 partial list。需要新 slab 时优先从 partial list 取(比向 buddy 要一个新页便宜)。
分配路径
SLUB 的分配路径从快到慢有三级:
1 | |
fast path 是一个无锁的指针操作:读 freelist 头部、CAS 更新(或依赖 per-cpu 访问的天然排他性)。在典型工作负载中,绝大多数分配走 fast path 完成,延迟在纳秒级。
释放路径
1 | |
对象所在的 slab 通过物理地址确定:virt_to_page(object) 得到对应的 page 结构体,page 结构体中记录了它属于哪个 slab cache。
slab merging:减少 cache 数量
不同内核子系统可能创建大小相同但名称不同的 cache。SLUB 会自动合并兼容的 cache——如果两个 cache 的对象大小、对齐、标志都相同,它们共享底层的同一个 cache。这减少了 partial list 的数量和内存占用。
slabinfo -a 可以查看哪些 cache 被合并到了一起。合并的前提是没有启用 per-cache 的调试选项(红区、毒化等)。
kmalloc:通用小对象分配
kmalloc(size, flags) 是内核中最常用的动态分配接口。它不为特定类型创建 cache,而是使用一组预定义的通用 cache(按大小分级):
1 | |
kmalloc(150, GFP_KERNEL) 会从 kmalloc-192 cache 分配一个 192 字节的槽位。内部碎片最多是"下一个 size class - 实际大小",比按页分配好得多。
超过 8KB(两页)的 kmalloc 请求直接交给 buddy allocator 分配整页。
模式提炼:页分配器管批量,对象分配器管形状
1 | |
| 维度 | buddy allocator | SLUB |
|---|---|---|
| 最小粒度 | 1 页(4KB) | 8 字节 |
| 管理单元 | 连续物理页 | 固定大小对象槽位 |
| 锁策略 | zone->lock(per-cpu pageset 缓解) | per-cpu freelist(无锁 fast path) |
| 碎片类型 | 外部碎片(连续块不够) | 内部碎片(size class 向上取整) |
| 释放行为 | 合并 buddy | 归还槽位,slab 全空时归还页 |
这种两层分配器的设计在用户空间同样存在:glibc 的 malloc 用 mmap/brk 从 OS 获取大块内存(类似 buddy),然后内部用 arena + bin 管理小对象(类似 SLUB)。jemalloc、tcmalloc 也是类似分层。
一个最小实验:观察 /proc/slabinfo
1 | |
编译与运行:
1 | |
如果没有 root 权限,直接读取也可以观察结构:
1 | |
完成实验后清理:
1 | |
读取实验结果
/proc/slabinfo 的关键列含义:
| 列 | 含义 |
|---|---|
| name | cache 名称 |
| active_objs | 当前被使用的对象数 |
| num_objs | cache 中总对象数(含空闲槽位) |
| objsize | 每个对象的字节大小(含对齐填充) |
| objperslab | 每个 slab 中的对象数 |
| pagesperslab | 每个 slab 占多少页 |
观察要点:
utilization = active_objs / num_objs:接近 1.0 表示 cache 几乎满了,接近 0.5 表示有大量空闲槽位。低利用率意味着 partial slabs 较多——对象已释放但页面尚未归还 buddy。
dentry cache 通常是最大的:文件系统路径缓存占据大量 slab 内存。slabtop 命令提供实时排序视图。
kmalloc-N 的分布:反映内核中不同大小的动态分配分布。通常 kmalloc-64 和 kmalloc-128 的对象数最多。
内部碎片计算:如果 kmalloc-192 被用于分配 150 字节对象,每个对象浪费 42 字节(22%)。这比用整页分配 150 字节(浪费 96%)好得多。
模式提炼:池化同类型对象利用局部性
1 | |
SLUB 的性能不仅来自减少碎片,更来自两个局部性优势:第一,同类型对象物理相邻,CPU cache line 预取有效;第二,per-cpu freelist 消除了跨核竞争。这和用户空间 thread-local allocation buffer(TLAB)的思路相同。
源码锚点
| 入口 | 读它的目的 |
|---|---|
mm/slub.c |
__slab_alloc():分配慢路径(per-cpu slab 耗尽时) |
mm/slub.c |
___slab_alloc():新 slab 获取逻辑 |
mm/slub.c |
slab_free():释放路径 |
include/linux/slab.h |
kmalloc()、kfree():公共 API |
include/linux/slub_def.h |
struct kmem_cache 定义 |
mm/slab_common.c |
cache 创建和合并逻辑 |
读 __slab_alloc() 时可以带着一个问题:当 per-cpu slab 耗尽,从 partial list 取新 slab 时,如何选择"最满"还是"最空"的 partial slab?(提示:关注 slab->inuse 计数和选择策略)
研究生迁移表
| Linux 概念 | 一般系统设计 |
|---|---|
| kmem_cache | 对象池(object pool) |
| slab(被切分的页) | 内存 arena 中的 run/chunk |
| per-cpu freelist | 线程本地分配缓冲(TLAB, thread cache) |
| partial list | 半满桶回收池 |
| slab merging | 对象池合并(减少池数量,提高利用率) |
| kmalloc size classes | jemalloc/tcmalloc 的 size class bins |
| freelist poison/redzone | guard pages、canary(缓冲区溢出检测) |
| SLUB debugging | ASan、Valgrind(内存错误检测) |
两层分配器的架构(page allocator + object allocator)在任何需要高频分配固定大小对象的系统中都是标准做法。数据库的 buffer pool 用 page allocator 管页、row allocator 管记录;Go runtime 用 mheap 管 span、mcache 管小对象;Nginx 用 pool allocator 管请求生命周期内的小块分配。
常见误解
第一个误解是认为 kmalloc 和 vmalloc 做同样的事。kmalloc 分配的是物理连续内存(来自 SLUB/buddy),适合 DMA 和需要物理连续的场景。vmalloc 分配的是虚拟连续但物理不一定连续的内存(通过修改内核页表映射),适合大块分配但不能用于 DMA。
第二个误解是认为 SLUB 的 free 立即归还物理内存。slab 中的对象被释放后只是回到 freelist,物理页仍被 slab cache 持有。只有当一个 slab 的所有对象都空闲、且 cache 的 partial 列表已经足够长时,该 slab 对应的页面才可能归还给 buddy。这就是为什么 MemFree 可能很低但系统并不缺内存——大量内存在 slab cache 中作为可回收内存存在。
第三个误解是认为 slab 调试(redzone、poisoning)是免费的。每个对象的前后添加 redzone 意味着有效对象大小增加(更少对象能装进一个 slab)。poisoning 在每次 free/alloc 时填充特殊字节。这些开销在调试时有价值,但生产系统通常不启用。
第四个误解是认为 per-cpu freelist 会导致大量内存被"浪费"在各 CPU 上。实际上每个 CPU 只持有一个活跃 slab(通常一页或几页),总量 = CPU 数 × cache 数 × slab 大小。在 64 核系统上、100 个活跃 cache,每个 slab 一页,总共也只是 64 × 100 × 4KB = 25MB,相对于系统总内存微不足道。
第五个误解是认为用户空间的 malloc 底层就是 kmalloc。用户空间的 glibc malloc 有自己的分配器(ptmalloc2),它通过 brk 或 mmap 从内核获取大块虚拟内存,然后在用户空间自行管理。kmalloc 只在内核空间使用。
练习
第一,用 sudo slabtop -s c(按 cache 大小排序)找出当前系统中占用内存最多的 slab cache。计算这些 cache 的总内存占用与 /proc/meminfo 中 Slab 行的关系。
第二,运行一个创建大量文件的操作(如 find / -name "*.conf" 2>/dev/null),观察 dentry 和 inode_cache 的 active_objs 变化。路径查找密集操作如何反映在 slab 统计中?
第三,查看 /sys/kernel/slab/ 目录下某个 cache(如 dentry)的详细参数:object_size、slab_size、objs_per_slab、cpu_slabs、partial。手动计算:一个 slab 能装多少对象?内部碎片率是多少?
第四,阅读 mm/slub.c 中 ___slab_alloc() 的实现。追踪从"per-cpu freelist 为空"到"获得新 slab"的完整路径,标记每一步可能需要的锁和可能的阻塞点。
第五,比较 SReclaimable 和 SUnreclaim 在 /proc/meminfo 中的值。前者(如 dentry/inode cache)可以在内存压力下被回收(shrink),后者不能。思考:哪些内核对象的 slab cache 是可回收的,哪些不是?
系列导航
- 上一篇:Buddy system:物理页如何按阶分配
- 本文:SLUB:小对象为什么不直接按页分配
- 下一篇:cgroup memory:内存从全局资源变成局部预算
