资讯动态

Linux内核slab分配器源码解析:分配与释放路径深度剖析

发布时间:2026/10/24 1:33:33 来源:尧图企业网站定制
上一篇分析到struct slab和kmem_cache这两个核心数据结构时我关掉了代码窗口。当时觉得结构理清楚了分配逻辑应该不难结果真到kmem_cache_alloc这条调用链上还是绕了好几圈才走通。这篇就把 slab 分配器两条最主要的执行路径彻底啃完分配路径从kmem_cache_alloc进释放路径从kmem_cache_free进涉及的代码都在mm/slab.c里逻辑上正好是一条正着走、一条倒着走。如果你正在读 slab 源码或者读过第一篇之后想在代码层面把“对象从哪来、回哪去”这个问题打通这篇很适合。我的目标很简单让你合上文章之后能在源码窗口里顺着____cache_alloc一路点下去不迷路。1. 动手读代码之前先把两条主路径和三个链表钉在脑子里1.1 从第一篇停下的位置继续上一篇我把kmem_cache和struct slab的基本面貌讲清楚了前者是管理某个内存对象的“分配器实例”后者是一页或几页物理内存被切成若干等大对象之后形成的“管理块”。但光认识结构体是没有用的分配器真正复杂的地方在于它把“找对象”这件事拆成了好几层每一层都对应不同的数据结构和函数。我自己的体会是读 slab 源码最忌从kmem_cache_alloc一行一行往下死磕。因为你很快会撞进____cache_alloc、cache_alloc_refill、cache_grow这一串函数里如果脑子里没有一张“路径图”你会分不清自己现在是在本地缓存里取对象还是在链表上补货或者已经跑去伙伴系统要新页面了。所以这篇我特意调整了顺序先看一张路径表再逐个函数往里钻。这样每个函数出现在什么位置、负责哪一段都清清楚楚。1.2 两条路径与三个链表的对照slab 分配器在逻辑上可以压缩成两条主路径分配kmem_cache_alloc→____cache_alloc→ 不够了进cache_alloc_refill→ 还不够进cache_grow。释放kmem_cache_free→__cache_free→ per-CPU 缓存满了就进free_block→ 整个 slab 空了就进slab_destroy。中间涉及的存储结构有三个链表加一个 per-CPU 数组数据结构位置含义array_cacheCPU 本地当前 CPU 可直接取用的对象缓存相当于柜台slabs_partialkmem_list3有部分空闲对象的 slab相当于半满货架slabs_freekmem_list3全部空闲的 slab相当于还没拆封的整箱货slabs_fullkmem_list3全满的 slab已经被拿走无法再直接分配用日常场景类比CPU 要一个对象时先从自己口袋里的零钱袋取零钱袋不够了才去书架上看哪本书有空位书架上没有空位就去仓库拆新书仓库连书都没有就直接找印刷厂印新的。这个类比不精确但足够帮你在读源码时定位自己。真正重要的区别在于array_cache是无锁的因为每个 CPU 只碰自己的数组而三个链表挂在kmem_list3上操作时需要抢一把全局的list_lock。这也解释了为什么分配器优先用 per-CPU 缓存因为一旦走到链表就是锁竞争和跨 CPU 同步的开始。2. kmem_cache_alloc 的快速路径为什么本地 CPU 缓存能快到不查链表2.1 slab_alloc 的预处理先看入口。现代内核中kmem_cache_alloc本身是个短的封装static __always_inline void *kmem_cache_alloc(kmem_cache *cachep, gfp_t flags) { return slab_alloc(cachep, flags); }真正的逻辑在slab_alloc里。这个函数做三件事过滤掉当前不允许的 GFP 标志、处理调试统计用的 hook、然后调用底层分配函数。我把多余判断去掉后核心骨架大致是这样static __always_inline void *slab_alloc(kmem_cache *cachep, gfp_t flags) { void *objp; flags gfp_allowed_mask; objp __do_cache_alloc(cachep, flags); return objp; }这里有个值得注意的点flags在进入更深的路径之前先做一次位过滤。这是内核一贯的风格调用者传入的 GFP 标志可能带有一堆上下文信息但分配器在这里只允许使用白名单里允许的位。我第一次读到这里时觉得多此一举后来才明白这是安全设计不同子系统传给kmem_cache_alloc的 flags 五花八门如果分配器内部不对标志做约束很可能在中断上下文、持有锁的场景里错误地触发睡眠。2.2 ____cache_alloc 里那三行核心逻辑__do_cache_alloc在非 NUMA 场景下会直接转到____cache_alloc。这个函数才是快速路径的主战场代码量非常小static inline void *____cache_alloc(kmem_cache *cachep, gfp_t flags) { struct array_cache *ac; void *objp; ac cpu_cache_get(cachep); if (likely(ac-avail ac-batchcount)) { ac-touched 1; objp ac-entry[--ac-avail]; } else { objp cache_alloc_refill(cachep, flags); } return objp; }注意那个判断条件ac-avail ac-batchcount。为什么不写成ac-avail 0原因是batchcount代表了每次从 slab 链表搬进 per-CPU 缓存时的批量大小。如果每次分配都只判断“还有没有”那么 per-CPU 缓存里剩一个对象时你取走它之后马上就得去抢锁补货分配器会退化成一个频繁锁操作的慢速分配器。而用batchcount做水位线等于告诉分配器“只有当本地缓存存量低于一批次时才去补货”让每次上锁都能搬回一整批对象摊薄锁开销。ac-entry[--ac-avail]这行代码也很值得品。entry是一个数组存的是指向对象的指针。分配时不是从数组头部取而是从尾部取因为尾部最近被放进来大概率还留在 CPU 的 cache line 里。这种细节在内核代码里随处可见它不是偶然的。2.3 touched 与 batchcount两个容易被忽略的字段ac-touched 1这行很多人扫一眼就过去了但它实际是 slab 收缩逻辑的重要判断依据。内核在kmem_cache_shrink或 CPU 热插拔处理时会检查这个字段如果某个 CPU 的 array_cache 从来没被 touch 过那说明这个 CPU 很少使用该缓存它的 per-CPU 数组可以被优先回收。所以touched不是统计字段而是“活动程度”的指标。这也是我读源码时比较喜欢关注的点真实分配器里每一个字段都有它存在的理由如果只盯主路径这些看似边角的部分反而容易让你对整体设计产生误解。batchcount也不只是一个简单的批量数。它是在do_tune_cpucache里根据缓存大小、对象大小和 CPU 数量动态算出来的。后面第 6 章我会专门讲这个参数怎么从前台观察到。现在你只需要记住这个值既决定了快速路径的触发水位也决定了每次cache_alloc_refill搬运多少对象。3. 缓存不够就得补cache_alloc_refill 的补货机制3.1 从 per-CPU 缓存跳到全局链表当____cache_alloc发现本地缓存存量低于batchcount时就会调用cache_alloc_refill。这个函数才是 slab 分配器真正的调度核心。它的逻辑是从当前 CPU 对应的kmem_list3里找到一批 slab把 slab 上的空闲对象搬进 per-CPU 数组。我把核心代码压缩成这样static int cache_alloc_refill(kmem_cache *cachep, gfp_t flags) { struct kmem_list3 *l3; struct array_cache *ac; int batchcount; ac cpu_cache_get(cachep); batchcount ac-batchcount; l3 cachep-nodelists[numa_node_id()]; spin_lock(l3-list_lock); while (batchcount 0) { struct slab *slabp; struct list_head *entry NULL; if (!list_empty(l3-slabs_partial)) entry l3-slabs_partial.prev; else if (!list_empty(l3-slabs_free)) entry l3-slabs_free.prev; if (!entry) { if (!grow_ready(cachep, numa_node_id())) break; spin_unlock(l3-list_lock); cache_grow(cachep, flags, numa_node_id()); spin_lock(l3-list_lock); continue; } slabp list_entry(entry, struct slab, list); while (slabp-inuse cachep-num batchcount--) { void *objp slab_get_obj(cachep, slabp, numa_node_id()); ac-entry[ac-avail] objp; } if (slabp-inuse cachep-num) list_move(slabp-list, l3-slabs_full); } spin_unlock(l3-list_lock); return ac-avail; }这里有一段逻辑很关键优先从slabs_partial取 slab而不是slabs_free。为什么不先从完全空闲的 slab 拿对象那样不是更充裕吗答案是碎片与资源利用率的权衡。slabs_partial里的 slab 已经有部分对象被使用如果长期不碰它这些已用对象和空闲对象会形成“半死”状态。优先从部分空的 slab 取对象可以让它们更快被填满变成slabs_full从而把完全空闲的 slab 留给更极端的场景。完全空闲的 slab 一旦被使用出来之后全部对象都释放时就有机会整块归还给伙伴系统。这是很典型的“先拆零散包裹不动整箱货”策略。3.2 为什么取链表尾部的 entry还有一个细节entry l3-slabs_partial.prev。为什么是prev而不是next链表节点的加入方式决定了prev指向的是最近加入的元素。slab 在分配路径中如果从slabs_free被取用会用list_add挂到目标链表的头部附近所以prev方向的 slab 更可能是“最近被使用过”的。对于刚被填满又马上有对象被释放回 partial 的 slab它上面的对象在内存上更有可能还在 cache 里热乎着。从热 slab 上取对象缓存命中率通常更好。这个选择没有在注释里大张旗鼓地说明但它和ac-entry[--ac-avail]从尾部取对象的思路是一致的内核在多个层面都在追求“后进先出”带来的 cache 局部性优势。3.3 freelist 索引链怎么把对象交出来slab_get_obj是真正把一个空闲对象从 slab 上拆下来给调用者的函数但它的实现依赖一个很容易被忽略的数据布局freelist。上一篇讲过struct slab 后面紧跟着一块缓冲区里面存的是一个整数数组叫slab_bufctl。每个空闲对象的索引在这个数组中存着“下一个空闲对象是谁”。因此 freelist 的逻辑不是通过对象体内的指针串链而是通过一块独立的管理数组串链。这么做的好处是对象内存本身始终干干净净不会在空闲状态下被写入任何额外指针用户拿到对象时可以直接使用无需清理残留的链表信息。slab_get_obj核心逻辑大致如下static inline void *slab_get_obj(kmem_cache *cachep, struct slab *slabp, int nodeid) { unsigned int next get_free_obj(slabp, slabp-free); void *objp index_to_obj(cachep, slabp, slabp-free); slabp-free next; slabp-inuse; return objp; }get_free_obj读取slab_bufctl[slabp-free]拿到当前空闲对象序号之后的下一个序号。index_to_obj则根据序号计算对象在 slab 中的起始地址slabp-s_mem cachep-size * index。初始化时freelist 数组通常会按下标依次指向上一个或下一个对象最后一个指向一个特殊哨兵值BADSLAB。这样整个 slab 的空闲对象链条就完整了。我第一次看到这里时最大的感触是这是一种极其廉价的“空闲列表”实现不依赖任何锁每个对象的管理开销只有一个整数索引代价则是要在分配/释放时小心维护索引一致性。4. 补货也失败之后cache_grow 从伙伴系统到新 slab 的搭建过程4.1 页面来源与管理头分配cache_alloc_refill里如果发现slabs_partial和slabs_free都是空的并且grow_ready判断可以增长就会调用cache_grow。从名字就能看出来这是给缓存“扩容”的地方也是分配器真正去向 buddy 系统要内存的节点。cache_grow做的事情可以拆成三步通过kmem_getpages从伙伴系统分配物理页通过alloc_slabmgmt在页面上搭建 slab 管理结构通过cache_init_objs初始化对象与 freelist。kmem_getpages的分配不是按单页来的它按cachep-gfporder分配连续的 2 的幂次页。大对象缓存可能一次申请多页因为一个页面装不下太多对象。如果分配失败cache_grow会返回 0然后cache_alloc_refill也只能带着当前已有的补货结果返回。alloc_slabmgmt有个分支需要特别注意slab 管理结构有两种存放方式。一种是ON_SLAB即 struct slab 直接放在页面开头紧接着是 freelist 数组再往后才是真正的空闲对象区另一种是OFF_SLAB当对象尺寸很小、freelist 相对较大时管理头会从另一个专用 slab 缓存中分配不占用对象区。这个决策是在创建缓存时就确定下来的原则是“代价最小化”。如果对象本身只有几十字节管理头挤在同一个页面里会挤压对象数量此时把管理头放到外面更划算如果对象是几百字节甚至更大管理头的相对占比很低放在页面开头反而省事。4.2 对象初始化与 freelist 串联cache_init_objs负责把刚拿到的原始内存整理成可用状态。这里对所有对象做一遍循环static void cache_init_objs(kmem_cache *cachep, struct slab *slabp) { int i; for (i 0; i cachep-num; i) { void *objp index_to_obj(cachep, slabp, i); if (cachep-ctor) kmem_cache_alloc_hook... set_free_obj(slabp, i, i 1); } set_free_obj(slabp, cachep-num - 1, BADSLAB); slabp-free 0; slabp-inuse 0; }如果缓存配置了构造函数这里会逐个对象调用一次。注意这里的ctor和释放时的dtor都是“未初始化/将要销毁”时的钩子和 C 的构造析构不是一回事更准确的定位是“让对象回到用户期望的初始状态”。构造完成后freelist 被串成一条链free指向 0 号对象0 号对象的下一个空闲序号是 11 号的下一个是 2依次类推最后一个对象的索引存的是BADSLAB。这样分配器在之后每次取对象时都能通过slabp-free的索引链走完整个 slab。4.3 着色偏移不是玄学colour 的计算与作用新 slab 建立时还有一个看起来奇怪的步骤给对象区加一个“着色偏移”。struct slab里有个colouroff字段对象起始地址不是页首对齐而是在页首之上加一个偏移量。为什么要这么做直接原因是 cache line 冲突。如果所有 slab 的对象都从物理页的同一偏移开始那么不同 slab 上编号相同的对象在物理内存中的位置会天然对齐。当这些对象在 L1/L2 缓存中映射到同一组 cache set 时它们会互相挤压。尤其在内核这种高并发场景里同一个缓存的不同 CPU 可能同时操作编号相同的对象极端情况下会导致 cache line 反复相互驱逐。着色方案是让不同的 slab 在对象区起始位置错开若干个colour_off的倍数。colour_off一般取 cache line 大小比如 64 字节。colour的数量则由建 slab 时剩余空间决定colour left_over / colour_off假设一个 slab 能放 15 个 256 字节对象页面剩余 192 字节colour_off 是 64那么 colour 192 / 64 3表示有 0、1、2、3 四种偏移可选。slab 序号着色偏移对象起始地址相对页首00管理头之后 0164管理头之后 642128管理头之后 1283192管理头之后 192每次新 slab 建立时分配器会递增使用一个颜色值四个 slab 分布在四种偏移上同一编号对象在物理地址上不再严格对齐cache set 的冲突概率被显著降低。我第一次读这段代码时觉得这是在“尽量利用残余空间”后来才意识到着色真正的价值是性能调优。如果只是为了利用剩余空间完全可以把所有对象整体后移让开头对齐但那样所有 slab 仍然重叠。着色把剩余空间拆成多个偏移档位每一档让一部分 slab 错开这才是关键。5. 释放路径像倒放从 __cache_free 到 free_block 再到 slab_destroy5.1 快速释放路径与 limit 门槛分配路径有快速路径释放路径同样有。kmem_cache_free最终会进入__cache_free它的核心逻辑非常对称static inline void __cache_free(kmem_cache *cachep, void *objp) { struct array_cache *ac cpu_cache_get(cachep); if (likely(ac-avail ac-limit)) { ac-entry[ac-avail] objp; return; } free_block(cachep, objp, 1, numa_node_id()); }如果 per-CPU 数组还没满直接把对象压回数组尾部。这里判断用的是avail ac-limit也就是说数组可以装到limit个对象。一旦达到上限则触发一次free_block把对象回写到 slab 链表上。很多人会把limit和batchcount搞混。简单区分batchcount是补货/回写的批量粒度limit是 per-CPU 数组的容量上限。通常limit要明显大于batchcount因为释放时希望尽量把对象留在 CPU 本地避免频繁回写链表。但也不能无限大否则每个 CPU 都攒着一大堆空闲对象内存整体利用率会下降其他 CPU 想用却又用不到。这种设计本质上是在“本地缓存热度”和“全局内存可用性”之间找平衡。对象释放后先留在当前 CPU 的数组里是因为它在未来很有可能会被同 CPU 再次分配这是局部性原则在内存分配器上的体现。5.2 free_block 里的链表迁移free_block处理对象回写时必须重建 slab 与链表之间的关系。对象从 slab 上归还后slab 的状态可能发生三类变化原本全满的 slab因为一个对象被释放变成部分空闲需要从slabs_full挪到slabs_partial。原本部分空闲的 slab对象全部归还inuse 降为 0需要从slabs_partial挪到slabs_free。完全空闲的 slab 数量已经太多或者满足某些条件直接销毁并归还内存。核心代码逻辑类似slab_put_obj(cachep, slabp, objp, node); if (slabp-inuse 0) { if (free_slab_ok(cachep, slabp, node)) { list_del(slabp-list); slab_destroy(cachep, slabp); } else { list_add(slabp-list, l3-slabs_free); } } else if (was_full) { list_move(slabp-list, l3-slabs_partial); }这里有一个反直觉的细节slab 在inuse 0时不一定会立刻销毁而是可能先挂到slabs_free链上。原因很简单如果每次一个 slab 空出来就立刻归还伙伴系统那么下一次分配又要重新走一次cache_grow成本太高。所以分配器会保留一部分完全空闲的 slab 作为“热备份”只有当游离的 slab 数量过多或者内存压力增大时才真正销毁。这个“留一手”的策略贯穿 slab 分配器始终。它不做即时反应而是用阈值和批量操作来摊薄成本。读代码时如果一味追求“最简洁的路径”很容易忽略这些设计意图。5.3 slab_destroy 的出发时机slab_destroy是把整个 slab 从生命周期中移除的地方。它会执行每个对象的析构钩子然后归还页面最后释放 slab 管理结构如果管理头是 OFF_SLAB 模式。static void slab_destroy(kmem_cache *cachep, struct slab *slabp) { struct page *page virt_to_page(slabp-s_mem); if (unlikely(cachep-dtor)) { for (i 0; i cachep-num; i) cachep-dtor(index_to_obj(cachep, slabp, i)); } kmem_freepages(cachep, page); if (OFF_SLAB(cachep)) kmem_cache_free(cachep-slabp_cache, slabp); }注意销毁对象之前必须保证所有对象都已经归还也就是slabp-inuse 0。这是释放路径里一个很自然的约束如果你看到有代码在 inuse 非零时调用slab_destroy那一定已经触发了内存破坏或者引用计数错误属于严重 bug。释放路径最让人头晕的地方其实是锁和链表迁移的交叉。free_block会持list_lock操作三条链表而 slab_put_obj 又必须保证 freelist 数组的更新在同一个临界区里完成。对于正常场景来说每次只释放一个对象时快速路径完全避开这些锁只有批量回写时才会上锁这也是 slab 分配器在高并发下不会把锁开销完全暴露出来的原因。6. 把 slab 源码读成调优线索关键参数的前世今生6.1 size、num、colour 到底怎么算读到这里你可能会好奇一个问题前面讲的那一堆参数batchcount、limit、colour到底是哪个函数算出来的答案分散在缓存的创建流程中。对象大小cachep-size来自创建缓存时传入的对象尺寸经过对齐处理cachep-num则是在cache_estimate里根据页面大小和管理头占用计算出来的一个 slab 最多能塞多少个对象算完还会检查剩余空间是否足够分摊着色。这些参数之间是有联动的。colour不是随便定的它受限于页面剩余空间left_over。batchcount和limit则受 CPU 缓存大小影响如果对象很大per-CPU 数组能容纳的数量就少如果对象很小数组数量可以相应增加。do_tune_cpucache是这组参数的计算入口它会根据对象大小和 CPU 数量做一次拟合最终写入struct array_cache和kmem_cache的字段。所以当你看到源代码里一个缓存对象的batchcount是 64 而另一个是 8那不是随手选的而是代码基于对象大小做的一次平衡。这个平衡没有绝对最优但大致保证了单次批量操作的内存流量在一个可控范围内。6.2 从 /proc/slabinfo 反推分配器参数源码读完之后建议你去实际机器上看一眼运行时的参数。/proc/slabinfo是内核导出 slab 状态的主要窗口里面的 tunables 一列就对应limit、batchcount、sharedfactor这三个值。命令是cat /proc/slabinfo输出样例如下kmalloc-128 640 800 128 31 1 : tunables 120 60 28 : slabdata 26 26 0第二列是活跃对象数第三列是总对象数第五列是每个 slab 的对象数tunables 里的120是 limit60是 batchcount。看到这一行时你就能把源码里的逻辑和现实数据对应起来per-CPU 数组最多缓存 120 个对象每次补货或回写 60 个差不多是 30 批次的节奏。如果你修改了/sys/kernel/slab/cache/limit之类的参数/proc/slabinfo的 tunables 会随之变化。试试把 batchcount 调大再跑一个压力测试你会直观看到补货次数变少、但单次补货搬运的对象变多。这种“源码 实际数据”互相印证的方式比单纯读代码理解深刻得多。6.3 读 slab 源码时我自己的三个习惯最后分享一点个人经验。传统 slab 分配器代码不算特别长但嵌套的数据结构和宏比较多第一次啃很容易被绕晕。我自己实践下来比较有效的办法有三个。第一先画调用链再填函数内容。每进一个新函数先确认它处于分配/释放路径的哪一层别急着看实现细节。链路图比函数本身重要得多。第二重点关注链表迁移和阈值判断。slab 分配器一半的复杂度都在“什么时候把 slab 从这个链表搬到另一个链表”上。把这些迁移时机弄明白整个状态机就清楚了。第三遇到结构体字段不要死记回到第一篇讲的数据结构定义里反复对照。比如slabp-free和cachep-num这两个字段在分配和释放路径里会被频繁修改它们的含义必须像乘法口诀一样熟。读内核内存分配器最好的状态不是把每个函数背下来而是形成一种“感觉”一看到某个阈值判断就能猜到写这段代码的人是为了避免哪种开销。slab 分配器里这种“避免锁”“避免 cache miss”“避免过度膨胀”的算计无处不在读懂了它们再去看后来的 SLUB 分配器你会轻松很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑