引言:内核里 90% 的分配都是小对象,不能每次都去 buddy 拿页
上一篇讲 OOM(#12)时提过"slab 页也算内存"。但 slab 到底是什么?先看一个事实:内核里绝大多数内存分配是小对象 ------一个 struct task_struct(几 KB)、一个 inode、一个 socket 缓冲区,而不是"一整页"。
如果这些小对象都直接走 buddy(#2),会有两个致命问题:
- 内部碎片:buddy 按 2 的幂分配,申请 100 字节会给 128 字节(order=0 是 4KB,更夸张),浪费 28 字节。申请 5KB 会给 8KB。
- 慢 :每次
kmalloc都走 buddy 的分配/释放,还要频繁切分/合并,高频路径开销大。
解决这两个问题的就是 slab 分配器 :从 buddy 一次性拿几页,切成固定大小的对象缓存起来,释放的对象不还给 buddy、而是留作复用。这篇讲的就是:对象缓存 → freelist → per-cpu 无锁快路径 → 从 buddy 补货。
全景图
#mermaid-svg-eCJnsoQgAGWxuCqV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-eCJnsoQgAGWxuCqV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-eCJnsoQgAGWxuCqV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-eCJnsoQgAGWxuCqV .error-icon{fill:#552222;}#mermaid-svg-eCJnsoQgAGWxuCqV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-eCJnsoQgAGWxuCqV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-eCJnsoQgAGWxuCqV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-eCJnsoQgAGWxuCqV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-eCJnsoQgAGWxuCqV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-eCJnsoQgAGWxuCqV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-eCJnsoQgAGWxuCqV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-eCJnsoQgAGWxuCqV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-eCJnsoQgAGWxuCqV .marker.cross{stroke:#333333;}#mermaid-svg-eCJnsoQgAGWxuCqV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-eCJnsoQgAGWxuCqV p{margin:0;}#mermaid-svg-eCJnsoQgAGWxuCqV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-eCJnsoQgAGWxuCqV .cluster-label text{fill:#333;}#mermaid-svg-eCJnsoQgAGWxuCqV .cluster-label span{color:#333;}#mermaid-svg-eCJnsoQgAGWxuCqV .cluster-label span p{background-color:transparent;}#mermaid-svg-eCJnsoQgAGWxuCqV .label text,#mermaid-svg-eCJnsoQgAGWxuCqV span{fill:#333;color:#333;}#mermaid-svg-eCJnsoQgAGWxuCqV .node rect,#mermaid-svg-eCJnsoQgAGWxuCqV .node circle,#mermaid-svg-eCJnsoQgAGWxuCqV .node ellipse,#mermaid-svg-eCJnsoQgAGWxuCqV .node polygon,#mermaid-svg-eCJnsoQgAGWxuCqV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-eCJnsoQgAGWxuCqV .rough-node .label text,#mermaid-svg-eCJnsoQgAGWxuCqV .node .label text,#mermaid-svg-eCJnsoQgAGWxuCqV .image-shape .label,#mermaid-svg-eCJnsoQgAGWxuCqV .icon-shape .label{text-anchor:middle;}#mermaid-svg-eCJnsoQgAGWxuCqV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-eCJnsoQgAGWxuCqV .rough-node .label,#mermaid-svg-eCJnsoQgAGWxuCqV .node .label,#mermaid-svg-eCJnsoQgAGWxuCqV .image-shape .label,#mermaid-svg-eCJnsoQgAGWxuCqV .icon-shape .label{text-align:center;}#mermaid-svg-eCJnsoQgAGWxuCqV .node.clickable{cursor:pointer;}#mermaid-svg-eCJnsoQgAGWxuCqV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-eCJnsoQgAGWxuCqV .arrowheadPath{fill:#333333;}#mermaid-svg-eCJnsoQgAGWxuCqV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-eCJnsoQgAGWxuCqV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-eCJnsoQgAGWxuCqV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eCJnsoQgAGWxuCqV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-eCJnsoQgAGWxuCqV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eCJnsoQgAGWxuCqV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-eCJnsoQgAGWxuCqV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-eCJnsoQgAGWxuCqV .cluster text{fill:#333;}#mermaid-svg-eCJnsoQgAGWxuCqV .cluster span{color:#333;}#mermaid-svg-eCJnsoQgAGWxuCqV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-eCJnsoQgAGWxuCqV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-eCJnsoQgAGWxuCqV rect.text{fill:none;stroke-width:0;}#mermaid-svg-eCJnsoQgAGWxuCqV .icon-shape,#mermaid-svg-eCJnsoQgAGWxuCqV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eCJnsoQgAGWxuCqV .icon-shape p,#mermaid-svg-eCJnsoQgAGWxuCqV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-eCJnsoQgAGWxuCqV .icon-shape .label rect,#mermaid-svg-eCJnsoQgAGWxuCqV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eCJnsoQgAGWxuCqV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-eCJnsoQgAGWxuCqV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-eCJnsoQgAGWxuCqV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} per-cpu 空了
slab 也空了
slab 全空
kmalloc / kmem_cache_alloc
kmem_cache_alloc slub.c:3500
slab_alloc_node :3453
per-cpu 无锁快路径
__slab_alloc_node :3329
cmpxchg_double 推进 freelist
___slab_alloc :3095
慢路径
get_freelist :3050
从当前 slab 拿
new_slab :2063
→ allocate_slab :1996
从 buddy 拿页(接 #2)
kmem_cache_free :3825
→ slab_free :3800
do_slab_free :3734
对象放回 freelist
还给 buddy
__slab_free :3600
一句话:分配走 per-cpu 无锁快路径(cmpxchg_double 推进 freelist),空了走慢路径从当前 slab 拿,再空 new_slab 从 buddy 拿页切成对象;释放把对象放回 freelist,slab 全空且 partial 缓存够多才还给 buddy。
一、为什么需要 slab:buddy 的两个短板
先回到 buddy(#2):它按 order(2 的幂)分配整页块,最小单位是 4KB(order=0)。这对"一整页/多页"的分配很合适,但对"几百字节的小对象"很别扭:
| 问题 | 说明 |
|---|---|
| 内部碎片 | 申请 100 字节,buddy 最小给一整页 4KB,浪费 3996 字节;slab 切成精确对象(100→128 桶只浪费 28 字节) |
| 慢 | 高频 kmalloc/kfree 每次都走 buddy 的 __rmqueue/__free_one_page,还要维护伙伴关系、水位线 |
slab 的思路是在 buddy 之上加一层对象缓存 :buddy 只管"给 slab 几页"(低频),slab 把这几页切成同尺寸对象、用 freelist 串起来,kmalloc 直接从 freelist 拿(高频、O(1))。释放的对象不还给 buddy,而是放回 freelist 复用------这样既减少了内部碎片(对象尺寸精确匹配),又把 buddy 的慢操作摊薄成低频的"补货"。
Why 对象缓存能同时解决碎片和速度 :内部碎片的根源是"buddy 只能按 2 的幂给整块",而 slab 把一页切成 N 个精确尺寸的对象,每字节都被对象利用;速度的根源是"buddy 分配要维护复杂状态",而 slab 的 freelist 就是一条单链表,分配 = 取头结点、释放 = 插回,O(1)。本质是把"按大小分块"的重活交给低频的补货,把"拿一个/还一个"的轻活留给高频的 freelist。
二、三种实现:SLUB 是默认
内核历史上出过三代 slab 实现,v6.6 里只剩两种(mm/Kconfig:230 二选一,默认 SLUB :232):
| 实现 | 状态 | 特点 |
|---|---|---|
| SLUB | v6.6 默认 | per-cpu freelist 无锁快路径、简化设计、增强诊断(redzone/poisoning) |
| SLAB | 备选(SLAB_DEPRECATED :236) |
经典实现,per-node 缓存 + cache coloring,内存开销更大 |
| SLOB | 已移除 | 极简嵌入式版,内存最小但性能最差(v6.4 起删除) |
SLUB 赢在简单 + 快 :它放弃了 SLAB 的复杂着色(cache coloring)和 per-node 队列,把核心就做成"一个 freelist + per-cpu 缓存",靠无锁的 cmpxchg_double 拿到接近"分配一个指针"的开销。这篇只讲 SLUB。
三、核心结构:kmem_cache + per-cpu freelist
3.1 kmem_cache:一种对象尺寸一个缓存
每种对象尺寸(或每种内核结构类型)有一个 struct kmem_cache(slub_def.h:98):
c
struct kmem_cache {
struct kmem_cache_cpu __percpu *cpu_slab; // 每个 CPU 一份(freelist)
unsigned int size; // 对象大小(含 metadata/redzone)
unsigned int object_size; // 对象大小(不含 metadata)
unsigned int offset; // free pointer 在对象内的偏移
unsigned int inuse; // 元数据偏移
unsigned int align; // 对齐
struct kmem_cache_order_objects oo; // order + objects 打包(一页切几个)
struct kmem_cache_order_objects min; // 最小 order 回退
gfp_t allocflags; // 每次补货的 gfp flags
struct kmem_cache_node *node[MAX_NUMNODES]; // 每 NUMA node 的 partial 链表
};
oo 里打包了两个信息:这个缓存一次从 buddy 拿 order 页、切成 objects 个对象 。比如 size=128 的缓存,oo 是 order=0 + 32 个对象(4KB / 128)。
3.2 kmem_cache_cpu:per-cpu 的 freelist
快路径的核心是 struct kmem_cache_cpu(slub_def.h:50),每个 CPU 一份:
c
struct kmem_cache_cpu {
union {
struct {
void **freelist; // 空闲对象链表头(下个可用对象)
unsigned long tid; // 事务 id(防 ABA + CPU 迁移)
};
freelist_aba_t freelist_tid;
};
struct slab *slab; // 当前正在分配的 slab
struct slab *partial; // 部分空闲的 slab 链表
local_lock_t lock; // 保护上面字段
};
关键在 freelist:它是一条内嵌在空闲对象里的单链表 。每个空闲对象的前 offset 字节存着"下一个空闲对象的地址"(set_freepointer),freelist 指向链表头。分配 = 取头、把 freelist 推到下一个;释放 = 把对象插回链头。
Why freelist 内嵌在对象里 :如果空闲对象用独立的链表节点,每个对象还要额外占内存存"next 指针",反而浪费。SLUB 把 next 指针存在对象自己内部 (反正对象现在是空闲的,前几个字节随便用),
offset记录这个指针在对象内的位置------零额外内存开销。
四、分配:无锁快路径 + 慢路径
4.1 快路径:cmpxchg_double 无锁推进
kmem_cache_alloc(:3500)→ slab_alloc(:3483)→ slab_alloc_node(:3453)→ __slab_alloc_node(:3329),快路径在这里:
c
// __slab_alloc_node(slub.c:3329)------ 无锁快路径
c = raw_cpu_ptr(s->cpu_slab);
tid = READ_ONCE(c->tid); // 先读 tid
barrier();
object = c->freelist; // 读 freelist 头
slab = c->slab;
if (!USE_LOCKLESS_FAST_PATH() ||
unlikely(!object || !slab || !node_match(slab, node))) {
object = __slab_alloc(s, gfpflags, node, addr, c, orig_size); // 慢路径
} else {
void *next_object = get_freepointer_safe(s, object); // 下个空闲对象
/*
* cmpxchg_double 原子地(无锁):
* 1. 校验 tid 和 freelist 没变(防并发/CPU 迁移)
* 2. 把 freelist 推进到 next_object
*/
if (unlikely(!__update_cpu_freelist_fast(s, object, next_object, tid))) {
note_cmpxchg_failure("slab_alloc", s, tid);
goto redo; // 被并发打断,重试
}
prefetch_freepointer(s, next_object);
stat(s, ALLOC_FASTPATH);
}
return object;
快路径的精髓:不抢锁 ,用 cmpxchg_double 一次性原子更新 tid 和 freelist 两个值。如果期间发生并发操作(中断、抢占切到别的 CPU),tid 变了,cmpxchg 失败就 goto redo 重试。tid(事务 id)就是这个用途------检测"我读到的 freelist 还新不新"。
4.2 慢路径:___slab_alloc
per-cpu freelist 空了,走 ___slab_alloc(:3095):
c
// ___slab_alloc(slub.c:3095)------ 关键片段
slab = READ_ONCE(c->slab);
if (!slab)
goto new_slab; // 连当前 slab 都没有
freelist = c->freelist;
if (freelist)
goto load_freelist; // freelist 又有了(被并发填充)
freelist = get_freelist(s, slab); // 从当前 slab 的 freelist 拿一批
if (!freelist) {
c->slab = NULL; // 当前 slab 也空了
goto new_slab; // 去拿新 slab
}
三步:当前 slab 还有对象就 get_freelist 拿一批 → 当前 slab 空了就从 partial 链表找部分空闲的 slab → 都没有就 new_slab 从 buddy 拿新页。这正是全景图里"per-cpu 空 → get_freelist → new_slab"的递进。
五、释放:放回 freelist,slab 全空才还 buddy
kmem_cache_free(:3825)→ slab_free(:3800)→ do_slab_free(:3734):
- 快路径 :对象直接放回 per-cpu 的 freelist 头(同样用 cmpxchg_double 无锁推进,
do_slab_free:3766)。 - 慢路径
__slab_free(:3600) :当释放的对象导致 slab 变成"部分空闲"时,把它移到 partial 链表(put_cpu_partial:2696);当整个 slab 都空闲了,且 node 的 partial 缓存已经够多 (!new.inuse && n->nr_partial >= s->min_partial,:3686)时,才discard_slab(:3715)把这一整页还给 buddy------否则留着当 partial 缓存继续复用。
这个"攒着不还 "是 slab 的核心价值:一个高频分配/释放的 task_struct,它的页在 buddy 和 slab 之间反复横跳是浪费,slab 让它留在缓存里,下次直接复用,buddy 只负责"缓存空了补货 / 缓存太满退货"。
六、slab 从 buddy 拿页:allocate_slab
new_slab(:2063)→ allocate_slab(:1996)是 slab 和 buddy(#2)的接口:
c
// allocate_slab(slub.c:1996)------ 关键片段
struct kmem_cache_order_objects oo = s->oo;
...
slab = alloc_slab_page(alloc_gfp, node, oo); // ① 从 buddy 拿 order 页
if (unlikely(!slab)) {
oo = s->min; // ② 高 order 失败,回退到 min
slab = alloc_slab_page(alloc_gfp, node, oo);
...
stat(s, ORDER_FALLBACK);
}
slab->objects = oo_objects(oo); // ③ 切成多少个对象
...
start = slab_address(slab); // ④ 对象起始地址
if (!shuffle) {
start = fixup_red_left(s, start);
start = setup_object(s, start);
slab->freelist = start; // ⑤ 串 freelist
for (idx = 0, p = start; idx < slab->objects - 1; idx++) {
next = p + s->size;
next = setup_object(s, next);
set_freepointer(s, p, next); // 每个对象存下个对象地址
p = next;
}
set_freepointer(s, p, NULL); // 最后一个指向 NULL
}
alloc_slab_page 底层就是 buddy 的 alloc_pages(接 #2),拿回 order 页后,从 start 开始每 s->size 一个对象,set_freepointer 把它们串成 freelist。
6.1 kmalloc:预建的 kmalloc-caches
内核启动时预建了一组 kmalloc-caches(kmalloc_caches slab_common.c:677),覆盖 kmalloc-8/16/32/.../8192(kmalloc_info :824),最大 KMALLOC_SHIFT_HIGH = PAGE_SHIFT + 1(slab.h:309,即 8192 = 2^13)。kmalloc(size) 用 kmalloc_index(slab.h:485)把 size 向上取整到最近的桶,找到对应的 kmem_cache 再走 slab 分配。所以 kmalloc(100) 实际用的是 kmalloc-128 这个缓存。
七、与 AI Infra 的关系 + 记住三点
7.1 为什么 AI Infra 关心 slab
- 高频分配的性能底座:网络收发(skb)、块设备 IO(bio)、RDMA/NVMe 队列里频繁的小对象分配,全靠 slab 的无锁快路径扛着。slab 慢一点,整个 IO 路径就慢。
- 内核内存的"内存大户" :
/proc/meminfo里的Slab项就是所有 slab 缓存的总量。训练机上 slab 占用过高(比如 dentry/inode 缓存)会挤压可用内存,是排查"内存去哪了"的常见线索。 - 与前面各篇接轨 :#2 buddy(slab 的页来源)、#11 memcg(slab 对象也按 memcg 记账,
kmem账户)、#8 页回收(shrink_slab回收 slab 缓存)。
7.2 记住三点
-
slab = buddy 之上的对象缓存 :解决 buddy 的内部碎片(按 2 的幂给整块)和慢(高频维护复杂状态)。
kmem_cache(slub_def.h:98)的oo打包"一次拿order页切objects个对象",释放对象不还 buddy 而是留作复用。 -
无锁快路径靠 per-cpu freelist + cmpxchg_double :
kmem_cache_cpu->freelist(:50)是内嵌在空闲对象里的单链表(offset记录 next 指针位置,零额外开销)。__slab_alloc_node(:3329)用cmpxchg_double原子推进 freelist + 校验tid(防 ABA/CPU 迁移),失败goto redo重试------全程无锁。 -
补货和退货都走 buddy :快路径空了走慢路径
___slab_alloc(:3095,get_freelist → partial → new_slab),new_slab→allocate_slab(:1996)从 buddy 拿页、set_freepointer串 freelist;释放kmem_cache_free(:3825)→do_slab_free(:3734)放回 freelist,slab 全空且 partial 缓存够多(:3686)才discard_slab(:3715)还给 buddy。kmalloc走预建的kmalloc-caches(kmalloc-8~kmalloc-8192,slab_common.c:677)。
附:本篇关键源码索引
| 符号 | 位置 |
|---|---|
struct kmem_cache_cpu / struct kmem_cache |
include/linux/slub_def.h:50 / :98 |
kmem_cache_alloc / slab_alloc / slab_alloc_node |
mm/slub.c:3500 / :3483 / :3453 |
__slab_alloc_node(无锁快路径) / ___slab_alloc(慢路径) |
mm/slub.c:3329 / :3095 |
__update_cpu_freelist_fast / set_freepointer |
mm/slub.c:3031 / :443 |
allocate_slab / new_slab(从 buddy 拿页) |
mm/slub.c:1996 / :2063 |
get_freelist / put_cpu_partial |
mm/slub.c:3050 / :2696 |
kmem_cache_free / slab_free / do_slab_free / __slab_free |
mm/slub.c:3825 / :3800 / :3734 / :3600 |
kmem_cache_create / kmalloc_caches / kmalloc_info |
mm/slab_common.c:392 / :677 / :824 |
KMALLOC_SHIFT_HIGH / kmalloc_index |
include/linux/slab.h:309 / :485 |