Linux 6.6内核内存管理深度解析(十三):slab/slub 分配器

引言:内核里 90% 的分配都是小对象,不能每次都去 buddy 拿页

上一篇讲 OOM(#12)时提过"slab 页也算内存"。但 slab 到底是什么?先看一个事实:内核里绝大多数内存分配是小对象 ------一个 struct task_struct(几 KB)、一个 inode、一个 socket 缓冲区,而不是"一整页"。

如果这些小对象都直接走 buddy(#2),会有两个致命问题:

  1. 内部碎片:buddy 按 2 的幂分配,申请 100 字节会给 128 字节(order=0 是 4KB,更夸张),浪费 28 字节。申请 5KB 会给 8KB。
  2. 慢 :每次 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 记住三点

  1. slab = buddy 之上的对象缓存 :解决 buddy 的内部碎片(按 2 的幂给整块)和慢(高频维护复杂状态)。kmem_cache(slub_def.h:98)的 oo 打包"一次拿 order 页切 objects 个对象",释放对象不还 buddy 而是留作复用。

  2. 无锁快路径靠 per-cpu freelist + cmpxchg_double :kmem_cache_cpu->freelist(:50)是内嵌在空闲对象里的单链表(offset 记录 next 指针位置,零额外开销)。__slab_alloc_node(:3329)用 cmpxchg_double 原子推进 freelist + 校验 tid(防 ABA/CPU 迁移),失败 goto redo 重试------全程无锁。

  3. 补货和退货都走 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
相关推荐
乌恩大侠3 小时前
从 AI-RAN 到 AI Agent:基于 NVIDIA Nemotron 构建 5G/6G 智能网络故障诊断系统
服务器·人工智能·5g·o-ru·ai-ran·电信大模型
Decay__3 小时前
04_MySQL DBA 日常运维手册 —— 会话管理、锁与阻塞、事务监控、慢查询与日志巡检
运维·数据库·sql·mysql·架构
丰锋ff4 小时前
压缩命令笔记
linux
江华森5 小时前
数据中心基础设施运维实战:概述与实验环境搭建
运维
迪菲赫尔曼5 小时前
【基础篇 11】把官方源码放到服务器:版本、解压层级和安装位置,一次弄清楚
运维·服务器·开发语言·人工智能·python·yolo·目标检测
GeW6 小时前
产业新优势基础软件的人才底盘,从Linux与数据库开始
linux·数据库
by209996 小时前
Linux GCC/G++ 编译与 Makefile 构建
linux·运维·服务器·经验分享
螺丝钉tina7 小时前
[Playwright][08] Upload 模块自动化:如何设计真实业务测试用例?
运维·自动化·测试用例
迪菲赫尔曼7 小时前
【基础篇 12】把环境与源码接起来:检查 GPU,再安装服务器上的项目
运维·服务器·开发语言·人工智能·python·yolo·目标检测