前几天又有同事问我源码里的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 slabstruct 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“认领”。如果frozen1说明该slab被某个CPU独占使用其他CPU不能动它等于0时slab在node的partial列表里“待命”。2.3 per-CPU缓存与node节点免锁的底气SLUB快速路径之所以能免锁靠的是struct kmem_cache_cpustruct 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的前半段。核心逻辑如下取出当前CPU的kmem_cache_cpu指针c如果c-freelist不为空直接取出头对象作为结果把c-freelist更新为对象内记录的下一个空闲对象更新c-slab的inuse计数返回对象。整个过程没有任何锁、没有原子操作计数、没有队列操作唯一的并发保护就是前面提到的tid校验。读源码时你会看到大量this_cpu_cmpxchg_double的调用它一次性地把freelist和tid两个字段原子更新防止在分配过程中被中断处理程序插入操作。这也解释了为什么SLUB性能好的一个重要真相它把最常走的路做成了“傻瓜式”的头插头取连inuse计数上锁都不需要。3.2 慢速路径partial列表与伙伴系统的衔接当c-freelist为空、但c-slab还有对象时代码会先尝试get_freelist把当前slab的空闲链表重新拿过来。这通常发生在slab对象被其他CPU归还、解冻之后。如果当前slab也彻底满了就进入get_partial阶段先从当前CPU的partial链表里取一个非空slab如果CPU partial也空了再加锁访问node partial列表如果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 partialslab已经全部空闲且prior非空则可能意味着该slab之前被某个CPU冻结现在所有对象归还直接让CPU解冻并转入partial。这段逻辑有很多“巧合式”的判断初读容易懵。建议对照一张状态表prior的取值代表slab在操作前位于何处CPU当前slab、CPU partial、node partialnew.inuse代表操作后的剩余对象数两者组合决定去向。4.2 CPU partial批量释放设计SLUB没有在每次释放对象后立刻判断slab该归何处而是把大量判断延迟到批量操作时。具体说当前CPU的partial链表能挂若干slabcpu_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_debugFZPUZ会在对象周围加一圈red zone越界写会在释放时被检测出来P把空闲对象填充为0x6b访问已释放内存时异常现场和普通越界完全不同一眼就能分辨U记录每次分配和释放的调用栈配合崩溃地址能反查是哪段代码搞的鬼。另外内核还支持slub_debugcache名只开启某类缓存比如slub_debugkmalloc-128避免全量调试带来的性能损失。如果你怀疑某类对象被踩先只开那一个缓存是最理性的做法。我不建议随机开全部调试跑生产因为CONFIG_SLUB_DEBUG开启后分配延迟可能增加30%以上。正确姿势是先复现问题再冒烟测试确认能复现后用最小调试范围定位。5.2 线上问题排查案例一次典型越界踩踏的现场是这样的系统在长时间运行后随机崩溃dmesg没有任何异常只有kasan未开启时的神秘panic。打开slub_debugFZPU后崩溃点变成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_debugFZPU跑一遍功能测试虽然慢但值得。另一个隐蔽的坑是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会发现自己对“内存到底怎么被切碎再拼起来”这件事已经有了一个非常结实的坐标系。