☰
SLUB分配器源码导读:从数据结构到分配路径与调试
2026/10/2 22:16:13 网站建设 项目流程

前几天又有同事问我,源码里的SLUB分配器到底应该从哪个函数读起。这其实是个好问题,因为SLUB的代码初看会劝退不少人:结构体套结构体、一堆__always_inline、还有让人头疼的this_cpu_cmpxchg_double。但如果你真把它啃下来,再看其他内存分配器、甚至自己写无锁数据结构,都会有豁然开朗的感觉。

这篇文章算是我自己读SLUB源码的一份导读笔记。目标不是逐行翻译,而是把分配器的主干逻辑、关键数据结构和经典的调试手段串起来,让准备入坑Linux内核内存管理的人知道该往哪儿使劲。内容基于当前主线内核(6.x)的代码形态展开,也会适当对比一些旧版本差异,毕竟SLUB从诞生到现在一直在演进。

1. 为什么要读SLUB:从SLAB到SLUB的取舍逻辑

Linux内核早期的分配器叫SLAB,名字来自“slab”这个单位。SLUB是它的继任者,2007年前后由Christoph Lameter提交,随后逐步成为默认分配器。今天你打开内核的mm/slub.c,就是这套东西。

很多初学者会问:既然SLAB能用,为什么还要搞SLUB?答案不是“更快”这么简单,而是SLAB的复杂度已经变成负担。

1.1 老SLAB的问题在哪里

SLAB的设计目标很宏大:它想通过“着色”(colour)利用硬件Cache,通过每CPU的arm_cache数组减少锁竞争,通过三态队列管理slab生命周期。听起来很完美,但实际运行中问题不少。

首先是数据结构臃肿。每个缓存要维护per-CPU的arm_cache、per-node的三个队列、各种统计字段和回调节点。内核在内存管理上是“用自己管理自己”,分配器的元数据越复杂,它自身消耗的内存和初始化成本就越高。

其次是锁竞争。虽然SLAB有per-CPU缓存,但一旦缓存miss,就要去碰node级别的队列锁。在高性能计算和NUMA场景下,这个锁很容易成为热点。尤其是数据库、网络协议栈这类高频分配释放的路径,锁竞争会被无限放大。

还有一个关键问题:SLAB的着色机制虽然理论上能优化Cache利用,但在大内存、大Cache的现代CPU上收益已经不明显,反而增加了计算和布局的复杂度。到了多核时代,简单和局部化才是王道,SLUB的“减法”思维正是冲着这一点去的。

1.2 SLUB的设计哲学:复杂度从运行时搬到编译期

SLUB的核心思想可以概括为:把能在编译期或者创建期算好的东西,绝不留到运行期。比如对象大小、对齐、slab的order、freelist指针偏移,这些都在kmem_cache_create时算死,分配路径里只管用。

另一个重点是放弃全局队列的精细管理。SLUB不维护全空、部分空、全满三态队列,只保留一个partial列表,并且把partial也拆成per-CPU和per-node两级。甚至很多空slab会直接还给伙伴系统,而不是留在缓存里“备用”。这样设计的好处是,常规分配释放几乎不碰任何全局锁,只有跨CPU、跨节点或者批量操作时才需要同步。

打个比方:SLAB像一个管理严格的仓库,物品按状态分区域存放,存取要登记;SLUB像一个流水线旁的零件盒,操作员拿完就走,盒子空了再去仓库领一箱,整箱用完直接扔回回收站。前者精细但费人,后者粗糙但效率拉满。

这种哲学贯穿整个源码,你读的时候会发现,绝大多数函数都在追求一个目标:让快速路径尽可能短,让慢速路径的代价集中于批量操作。

2. 三个核心数据结构:SLUB源码的阅读入口

源码读不懂十有八九是数据结构没建立起来。SLUB虽然简化了,但仍有三个关键结构必须烂熟于心:struct kmem_cache、struct slab(旧内核叫struct page)以及per-CPU的struct kmem_cache_cpu。

2.1 kmem_cache:分配器的总控台

struct kmem_cache描述一种“类型”的对象池,比如kmalloc-64就是一个缓存,专门分配64字节对象。它的字段很多,但核心就几项:

struct kmem_cache { struct kmem_cache_cpu __percpu *cpu_slab; unsigned long flags; unsigned long min_partial; int size; // 对齐后的大小,含redzone、padding unsigned int object_size; // 原始对象大小 unsigned int offset; // freelist指针在对象内的偏移 unsigned int cpu_partial; // CPU partial最多挂几个slab struct kmem_cache_order_objects oo; // 目标order和对象数 struct kmem_cache_order_objects min; struct kmem_cache_order_objects max; struct kmem_cache_node *node[MAX_NUMNODES]; ... };

oo这个字段很有意思,它把order和object数量打包在一个变量里:高16位是order,低16位是对象数。kmem_cache_create时通过calculate_slab_order从大到小尝试不同order,找一个浪费最少的组合。

offset决定了freelist指针塞在对象的哪个位置。默认情况下,SLUB把空闲链表指针直接存在对象的起始处;开启CONFIG_SLAB_FREELIST_HARDENED后,这个指针会被偏移到对象中间的一个随机位置,并且内容会被加密,防止攻击者利用空闲对象伪造指针。

2.2 slab页框与freelist:对象是怎么串起来的

无论SLAB还是SLUB,最终都要靠物理页框装对象。在SLUB里,一个slab就是一页或几页连续的物理内存,上面瓜分出若干等大小对象。5.17之后内核把它独立成struct slab:

struct slab { unsigned long __page_flags; union { struct list_head slab_list; struct rcu_head rcu_head; }; struct kmem_cache *slab_cache; void *freelist; // 空闲对象链表头 unsigned int inuse; // 已分配对象数 unsigned int objects; // 对象总数 unsigned int frozen; // 是否被CPU冻结 ... };

freelist是这个结构的灵魂。它指向当前空闲对象链表的头,每个空闲对象的前size字节(按offset的值)存着下一个空闲对象的地址。分配对象就是取出头节点,释放对象就是把对象插回头部。这套机制简单到什么程度?它甚至不需要能把对象从链表中摘除的中间操作,只做头插和头取。

frozen字段很多人看不懂,它的含义是:这个slab是否已被某个CPU“认领”。如果frozen=1,说明该slab被某个CPU独占使用,其他CPU不能动它;等于0时,slab在node的partial列表里“待命”。

2.3 per-CPU缓存与node节点:免锁的底气

SLUB快速路径之所以能免锁,靠的是struct kmem_cache_cpu:

struct kmem_cache_cpu { void **freelist; // 当前CPU可直接取用的对象链表 unsigned long tid; // 事务ID,用于保护快速路径 struct slab *slab; // 当前CPU正在使用的slab #ifdef CONFIG_SLUB_CPU_PARTIAL struct slab *partial; // 当前CPU的partial链表头 #endif ... };

每个CPU持有自己的freelist,意味着单CPU上的分配释放几乎不碰锁。tid字段是全套设计里最精巧的地方:每次操作freelist前,先读一次tid;操作完成后用cmpxchg确认tid没变。如果变了,就说明被中断或抢占打断了,需要重试或直接退到慢速路径。

node节点则是NUMA层面的“公共仓库”,保存了nr_partial、partial链表和一把spinlock。非本节点的内存分配、CPU partial溢出时的对象回填,都要靠这把锁。记住一个结论:快速路径无锁,慢速路径一把锁,批量操作才锁多次。

3. 分配路径拆解:fast path到slow path的完整链路

分配路径的入口通常是kmalloc,它先通过kmalloc_type和kmalloc_slab选出合适的kmem_cache,然后调用slab_alloc_node。这里贴一下简化后的调用关系:

kmalloc(size, flags) -> kmalloc_slab(size) // 决定用哪个kmem_cache -> slab_alloc_node(cache) -> slab_pre_alloc_hook // 处理GFP、内存cgrooup等 -> ___slab_alloc // 真正干活的函数

3.1 快速路径:几个比较指令就完成一次分配

严格来说,快速路径分布在slab_alloc_node和___slab_alloc的前半段。核心逻辑如下:

  1. 取出当前CPU的kmem_cache_cpu指针c;
  2. 如果c->freelist不为空,直接取出头对象作为结果;
  3. 把c->freelist更新为对象内记录的下一个空闲对象;
  4. 更新c->slab的inuse计数;
  5. 返回对象。

整个过程没有任何锁、没有原子操作计数、没有队列操作,唯一的并发保护就是前面提到的tid校验。读源码时你会看到大量this_cpu_cmpxchg_double的调用,它一次性地把freelist和tid两个字段原子更新,防止在分配过程中被中断处理程序插入操作。

这也解释了为什么SLUB性能好的一个重要真相:它把最常走的路做成了“傻瓜式”的头插头取,连inuse计数上锁都不需要。

3.2 慢速路径:partial列表与伙伴系统的衔接

当c->freelist为空、但c->slab还有对象时,代码会先尝试get_freelist,把当前slab的空闲链表重新拿过来。这通常发生在slab对象被其他CPU归还、解冻之后。

如果当前slab也彻底满了,就进入get_partial阶段:

  1. 先从当前CPU的partial链表里取一个非空slab;
  2. 如果CPU partial也空了,再加锁访问node partial列表;
  3. 如果node partial也拿不到,只能调new_slab向伙伴系统要新页。

new_slab内部的流程是:allocate_slab通过alloc_slab_page拿到2的order次幂页,然后把页切成objects个对象,串联成空闲链表。注意这里其实是从buddy(伙伴系统)拿内存,而伙伴系统本身也是另一个大话题。对SLUB来说,它只关心“给我一页,我把页切成块”。

整个慢速路径最贵的就是和伙伴系统的交互。因此SLUB用min_partial、cpu_partial这些参数故意“囤”一些半空slab,目的就是尽量延后触发伙伴系统调用的时机。这也是为什么很多性能问题看着是SLUB慢,根子其实是伙伴系统碎片化的原因。

3.3 关于order选择和对象对齐的细节

calculate_slab_order的算法值得单独说。它从0开始尝试order,直到KMALLOC_MAX_ORDER,每次算出当前order能放几个对象,再看剩余空间的比例。内核定义一个slab_ratio参数,默认情况下允许最多浪费不超过对象总数的1/16,如果达不到就尝试更高order。

举个例子,72字节的对象在4K页(order 0)上能放56个,浪费了72字节中的最后一小块,剩余空间无法再放一个对象,浪费比例约2.8%,符合条件,就选order 0。而如果你创建的是2MB的大对象缓存,那多半只能选大order的页,否则一个对象都放不下。

对象对齐则直接和硬件Cache line相关。默认对齐到ARCH_KMALLOC_MINALIGN,比如ARM64上通常是128字节。对齐不只是为了速度,更是为了保证对象不会跨Cache line,避免伪共享和原子操作性能下降。这块选型逻辑很少被仔细读,但调优时非常有用。

4. 释放路径与partial管理:最值得细读的对称逻辑

释放路径表面是分配的“逆操作”,但SLUB的情况没那么简单。因为分配路径只需要考虑“从哪拿对象”,而释放路径得决定“对象归还后,这页该放哪”。kfree最终会走到do_slab_free,再进__slab_free。

4.1 __slab_free的主路径逻辑

__slab_free接收几个参数:缓存s、目标slab、旧的freelist指针prior、待释放对象链表的头和尾,以及对象数量cnt。为什么一次释放可能带一串对象?因为SLUB支持批量释放,比如kfree_bulk一次释放多个对象。

函数的核心分支用一个释放前后的“预期状态”来比较:

  • slab之前不是全空(slab->inuse > 0),释放后变成全空,但prior表示它不在某个CPU冻结状态,那就把slab从node partial移除,直接还给伙伴系统;
  • slab之前非空,释放后仍非空,就把释放的对象头插到freelist,更新inuse,然后视情况决定放入CPU partial还是node partial;
  • slab已经全部空闲且prior非空,则可能意味着该slab之前被某个CPU冻结,现在所有对象归还,直接让CPU解冻并转入partial。

这段逻辑有很多“巧合式”的判断,初读容易懵。建议对照一张状态表:prior的取值代表slab在操作前位于何处(CPU当前slab、CPU partial、node partial),new.inuse代表操作后的剩余对象数,两者组合决定去向。

4.2 CPU partial批量释放设计

SLUB没有在每次释放对象后立刻判断slab该归何处,而是把大量判断延迟到批量操作时。具体说,当前CPU的partial链表能挂若干slab,cpu_partial字段就是这个上限。

当一个slab在CPU partial上挂到一定数量,或者一个slab释放到全空,SLUB会触发unfreeze_partials,把CPU partial里的所有slab一次性“解冻”,批量推送到node partial。这么做的好处是:把多次小操作合并成一次加锁大操作,大幅降低锁次数。

这个设计对真实负载影响很大。比如网络收包路径,一个连接可能频繁释放小对象,如果每次都去碰node锁,多核收包性能立刻崩塌。有了CPU partial,大部分释放都停留在CPU本地,只有凑够一批才做一次全局操作。

4.3 frozen与解冻流程

“冻结”的概念值得再强调一遍。一个slab被CPU拿走后,frozen置1,表示其他人不能再从node partial拿它。但注意:冻结slab上的对象可以被其他CPU释放——释放动作会把对象塞回slab的freelist,但不改变frozen状态。

真正解冻发生在两种场景:一是该CPU不再需要这个slab(比如freelist里没有对象了),二是批量转移CPU partial时。解冻会重新检查slab的空闲状态,决定是继续留在partial还是直接还给伙伴系统。

我读这段源码时的最大感受是,SLUB用“冻结”代替了繁重的引用计数和锁,让对象所有权状态变得极其干净。这个思路在写无锁队列时也完全可以借用。

5. SLUB调试机制与实战踩坑

分配器的问题很难排查,因为出错的地方往往不是产生bug的地方。SLUB为此内置了一套调试框架。很多老内核开发者喜欢说“先开slub_debug压个测”,这件事比想象中有用得多。

5.1 常用调试手段

内核启动参数slub_debug支持组合字母:F全状态检查、Z红区检测、P对象毒化、U记录调用栈、T追踪。常见组合:

slub_debug=FZPU
  • Z会在对象周围加一圈red zone,越界写会在释放时被检测出来;
  • P把空闲对象填充为0x6b,访问已释放内存时,异常现场和普通越界完全不同,一眼就能分辨;
  • U记录每次分配和释放的调用栈,配合崩溃地址能反查是哪段代码搞的鬼。

另外内核还支持slub_debug=<cache名>只开启某类缓存,比如slub_debug=kmalloc-128,避免全量调试带来的性能损失。如果你怀疑某类对象被踩,先只开那一个缓存,是最理性的做法。

我不建议随机开全部调试跑生产,因为CONFIG_SLUB_DEBUG开启后,分配延迟可能增加30%以上。正确姿势是:先复现问题,再冒烟测试,确认能复现后,用最小调试范围定位。

5.2 线上问题排查案例

一次典型越界踩踏的现场是这样的:系统在长时间运行后随机崩溃,dmesg没有任何异常,只有kasan未开启时的神秘panic。打开slub_debug=FZPU后,崩溃点变成:

BUG kmalloc-64: Object at 0xffff88810a1e2100 has been redzoned

这句话直译就是:kmalloc-64缓存的对象越界了。red zone位于对象末尾,这个bug说明有人往对象尾部之后写了数据。结合U选项记录的调用栈,可以看到最近一次分配这个对象的是网络驱动,那基本就能锁定是驱动中skb->cb区域越界写。

如果线上不允许重启加参数,还有一个办法:通过sysfs动态开启。/sys/kernel/slab/kmalloc-64/目录下可以查看red_zone、poison等状态,部分内核允许运行期开关部分调试选项。我的习惯是,任何涉及内存布局的模块提交前,至少用slub_debug=FZPU跑一遍功能测试,虽然慢,但值得。

另一个隐蔽的坑是CONFIG_SLAB_FREELIST_HARDENED开启后,freelist指针不再是明文地址,报错信息会变成Freepointer corrupt。这个时候不要以为看到的是崩溃现场,而是你的空闲链表指针被写坏了,通常意味着double free或者堆溢出把对象头部内容覆盖了。

6. 性能观测:快速路径命中率与分配延迟

聊完原理,还是得上点实测感受。性能数据会因为架构和负载差异很大,但以下几个观察具有普适性,可以作为理解SLUB的参照系。

6.1 快速路径命中率

我在一个8核ARM64嵌入式平台(网络转发负载)上用perf做了观测:slab_alloc_node的调用样本中,真正进入___slab_alloc慢速路径的比例不到5%。也就是说,每100次分配有95次以上直接走per-CPU freelist,不碰任何锁。

这个数字不算夸张,很多论文和内核社区报告里,SLUB的快速路径命中率在单线程或低竞争场景下超过99%,高竞争场景下也能维持90%上下。一旦命中率跌破90%,就要开始怀疑是不是有CPU迁移、中断风暴或者NUMA失衡的问题了。

6.2 一组典型的延迟对比

我用一个简单的内核模块,多次调用kmalloc/kfree并用ktime统计平均延迟,大致得到这样一组量级(具体数值依赖主频和内存速度):

路径平均延迟量级说明
快速路径(per-CPU freelist命中)数十纳秒几个分支+一次cmpxchg
慢速路径(CPU partial命中)数百纳秒需要操作链表、批量逻辑
慢速路径(node partial命中)数微秒级别引入spinlock和NUMA访问
触底路径(向伙伴系统拿新slab)微秒到几十微秒伙伴系统的成本决定

从这个表也能看出,为什么SiLUB设计者拼命想把“触底”往延迟更低的方向推:一次伙伴系统分配可能比快速路径慢100倍以上。所以很多优化手段,比如kmem_cache的min_partial、cpu_partial,本质都是在“用内存换延迟”。

另外,kfree_bulk这类批量接口不要忽视。实测中一次释放16个对象相比循环释放16次,本地partial操作的锁开销能下降一个数量级。在上游网络栈、文件系统里,批量接口已经大量使用。阅读源码时你会发现,SLUB内部为了支持批量操作,专门扩展了释放路径的状态机。

关于SLUB源码可以聊的话题还有很多,比如kmem_cache_create的完整初始化流程、slabinfo输出每个字段的含义、NUMA回退策略等等。我个人的建议是,第一次读不要陷进cmpxchg的并发细节,先把分配和释放的两条主路径走通,再把结构体的字段一个个对号入座。数据结构建起来之后,后面所有细节都是顺着逻辑找答案的事。等你把这份源码啃完,再去看memblock、伙伴系统甚至用户态的jemalloc,会发现自己对“内存到底怎么被切碎再拼起来”这件事,已经有了一个非常结实的坐标系。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询