上一篇分析到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_cache | CPU 本地 | 当前 CPU 可直接取用的对象缓存,相当于柜台 |
slabs_partial | kmem_list3 | 有部分空闲对象的 slab,相当于半满货架 |
slabs_free | kmem_list3 | 全部空闲的 slab,相当于还没拆封的整箱货 |
slabs_full | kmem_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 号对象的下一个空闲序号是 1,1 号的下一个是 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 序号 | 着色偏移 | 对象起始地址相对页首 |
|---|---|---|
| 0 | 0 | 管理头之后 + 0 |
| 1 | 64 | 管理头之后 + 64 |
| 2 | 128 | 管理头之后 + 128 |
| 3 | 192 | 管理头之后 + 192 |
每次新 slab 建立时,分配器会递增使用一个颜色值,四个 slab 分布在四种偏移上,同一编号对象在物理地址上不再严格对齐,cache set 的冲突概率被显著降低。
我第一次读这段代码时觉得这是在“尽量利用残余空间”,后来才意识到着色真正的价值是性能调优。如果只是为了利用剩余空间,完全可以把所有对象整体后移让开头对齐,但那样所有 slab 仍然重叠。着色把剩余空间拆成多个偏移档位,每一档让一部分 slab 错开,这才是关键。
5. 释放路径像倒放:从 __cache_free 到 free_block 再到 slab_destroy
5.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是 limit,60是 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 分配器,你会轻松很多。