上一篇讲了 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
2
3
4
5
6
7
8
kmem_cache (slab cache)
├── cpu_slab (per-cpu)
│ └── 当前正在服务的 slab (page/folio)
│ └── freelist → object → object → object → ...
├── partial list (per-node)
│ └── 部分使用的 slabs
└── buddy allocator
└── 空 slab 归还给 buddy; 新 slab 从 buddy 获取

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kmem_cache_alloc(cache, gfp_flags):

Fast path (per-cpu, 无锁):
1. 读 cpu_slab->freelist
2. 非空? 取头部对象,更新 freelist,返回
3. 空? → slow path

Slow path (per-cpu slab 耗尽):
4. 尝试从当前 cpu_slab 的 page->freelist 获取新一批对象
5. 成功? 替换 cpu_slab->freelist,回到 fast path
6. 失败? → 当前 slab 真的满了

New slab path:
7. 从 node 的 partial list 取一个 partial slab
8. partial list 也空? 从 buddy allocator 申请新页
9. 把新 slab 设为当前 cpu_slab
10. 回到 fast path

fast path 是一个无锁的指针操作:读 freelist 头部、CAS 更新(或依赖 per-cpu 访问的天然排他性)。在典型工作负载中,绝大多数分配走 fast path 完成,延迟在纳秒级。

释放路径

1
2
3
4
5
6
7
8
9
10
11
12
13
kmem_cache_free(cache, object):

Fast path (对象属于当前 cpu 的 slab):
1. 把 object 的 free pointer 指向当前 freelist 头
2. 更新 freelist = object
完成(无锁)

Slow path (对象属于其他 CPU 的 slab 或 partial slab):
3. 找到对象所在的 slab (通过 page 结构体)
4. 取 slab 的锁
5. 把 object 链入该 slab 的 freelist
6. 如果 slab 从 full 变为 partial: 加入 partial list
7. 如果 slab 所有对象都空闲: 考虑归还给 buddy

对象所在的 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
2
3
kmalloc-8, kmalloc-16, kmalloc-32, kmalloc-64, kmalloc-96,
kmalloc-128, kmalloc-192, kmalloc-256, kmalloc-512, kmalloc-1k,
kmalloc-2k, kmalloc-4k, kmalloc-8k

kmalloc(150, GFP_KERNEL) 会从 kmalloc-192 cache 分配一个 192 字节的槽位。内部碎片最多是"下一个 size class - 实际大小",比按页分配好得多。

超过 8KB(两页)的 kmalloc 请求直接交给 buddy allocator 分配整页。

模式提炼:页分配器管批量,对象分配器管形状

1
page allocator for bulk, object allocator for shape
维度 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

static void show_slab_stats(const char *cache_name) {
FILE *f = fopen("/proc/slabinfo", "r");
if (!f) { perror("fopen /proc/slabinfo"); return; }

char line[512];
/* skip header lines */
if (fgets(line, sizeof(line), f) == NULL) { fclose(f); return; }
if (fgets(line, sizeof(line), f) == NULL) { fclose(f); return; }

printf(" %-25s %10s %10s %8s %6s\n",
"name", "active_obj", "num_obj", "objsize", "objper");
printf(" %-25s %10s %10s %8s %6s\n",
"----", "----------", "-------", "-------", "-----");

while (fgets(line, sizeof(line), f)) {
char name[64];
unsigned long active, total, size, objs_per_slab;
if (sscanf(line, "%63s %lu %lu %lu %lu",
name, &active, &total, &size, &objs_per_slab) >= 5) {
if (cache_name == NULL ||
strstr(name, cache_name) != NULL) {
printf(" %-25s %10lu %10lu %8lu %6lu\n",
name, active, total, size, objs_per_slab);
}
}
}
fclose(f);
}

int main(void) {
printf("=== SLUB cache statistics (selected caches) ===\n\n");

printf("Kernel object caches:\n");
show_slab_stats("dentry");
printf("\n");
show_slab_stats("inode");
printf("\n");

printf("kmalloc size-class caches:\n");
show_slab_stats("kmalloc");
printf("\n");

printf("Key observations:\n");
printf(" - active_objs < num_objs: some slots are free (internal slack)\n");
printf(" - objperslab: how many objects fit in one slab allocation\n");
printf(" - objsize: actual bytes per slot (includes alignment padding)\n");
printf("\n");
printf("Try: cat /proc/slabinfo | sort -k3 -n -r | head -20\n");
printf(" (shows caches with most allocated objects)\n");

return 0;
}

编译与运行:

1
2
cc -Wall -Wextra -O2 slub_demo.c -o slub_demo
sudo ./slub_demo # 需要 root 读取 /proc/slabinfo

如果没有 root 权限,直接读取也可以观察结构:

1
2
3
4
5
6
7
8
# 查看 slabinfo 头部
sudo cat /proc/slabinfo | head -5

# 按对象数量排序,找最活跃的 cache
sudo cat /proc/slabinfo | tail -n +3 | sort -k2 -n -r | head -10

# 查看特定 cache 的详情
sudo cat /proc/slabinfo | grep dentry

完成实验后清理:

1
rm -f slub_demo slub_demo.c

读取实验结果

/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
2
same-type objects in contiguous memory -> cache-line friendly
per-cpu pools -> eliminates cross-core contention on hot paths

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 管请求生命周期内的小块分配。

常见误解

第一个误解是认为 kmallocvmalloc 做同样的事。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),它通过 brkmmap 从内核获取大块虚拟内存,然后在用户空间自行管理。kmalloc 只在内核空间使用。

练习

第一,用 sudo slabtop -s c(按 cache 大小排序)找出当前系统中占用内存最多的 slab cache。计算这些 cache 的总内存占用与 /proc/meminfoSlab 行的关系。

第二,运行一个创建大量文件的操作(如 find / -name "*.conf" 2>/dev/null),观察 dentryinode_cache 的 active_objs 变化。路径查找密集操作如何反映在 slab 统计中?

第三,查看 /sys/kernel/slab/ 目录下某个 cache(如 dentry)的详细参数:object_sizeslab_sizeobjs_per_slabcpu_slabspartial。手动计算:一个 slab 能装多少对象?内部碎片率是多少?

第四,阅读 mm/slub.c___slab_alloc() 的实现。追踪从"per-cpu freelist 为空"到"获得新 slab"的完整路径,标记每一步可能需要的锁和可能的阻塞点。

第五,比较 SReclaimableSUnreclaim/proc/meminfo 中的值。前者(如 dentry/inode cache)可以在内存压力下被回收(shrink),后者不能。思考:哪些内核对象的 slab cache 是可回收的,哪些不是?

系列导航

参考资料