上一节我们把slab分配器放在整个内存管理的坐标里审视了一遍:伙伴系统按页承办大块内存,slab则在一段连续的“页块”里切出许多同规格对象,专门用来伺候那些频繁创建和销毁的内核对象。文章写完总有朋友说,明白了slab为什么存在,但一翻源码还是找不到北,各种struct看得头晕。这一篇我不打算再堆概念,直接换一个角度:从源码文件开始,沿着一条真正的分配请求走一遍,把关键的数据结构和路径掰开了讲。适合谁看?如果你已经知道伙伴系统做了什么,知道页面级分配,但还没认真读过mm/slab.c,这篇能帮你建立一条主线。源码版本我不会锁死某一个具体版本,经典slab的代码形态大同小异,另外你八成会在比较新的内核里遇到slub,那我也会顺手指出两者对应的概念,免得你对着宏开关发愣。
1. slab要解决的根本问题:不是“按需分配”这么简单
1.1 伙伴系统在“小对象”场景下的无力感
先回到一个基础场景。内核里很多结构体很小,几十字节到几百字节不等。如果用伙伴系统的__get_free_pages来分配一个128字节的对象,它至少要给你一整页4KB。这个空间利用率连零头都算不上,更致命的是每次分配都会让页分配器频繁处理位图、合并、分裂,次数一多,内存碎片和锁竞争就会同时找上门。为了照顾这种高频小对象,slab引入的核心假设是“同一类型对象反复使用,规格不变”。基于这个假设,它可以一次从伙伴系统拿几页,连续切分,重复利用,把分配开销压到极低。
1.2 三个设计支柱:缓存、复用、局部性
slab的解法可以用三句话概括:
- 第一个是对象缓存:每个
kmem_cache对应一种对象类型,里面有规格相同的一组对象。 - 第二个是快速复用:对象用完后不还给页分配器,而是留在本cache的空闲列表里,下一位索取者直接拿走。
- 第三个是CPU局部性:每个CPU维护自己的缓存区,减少跨核锁竞争,也增加cache命中率。
打个比方,食堂打菜如果每次都要从买菜开始做,那队伍得排到马路上;slab就是后厨提前备好的那几盆菜,窗口师傅只负责舀。对象创建销毁的成本被挪到“餐桌”外,热路径上只剩几个指针操作。
1.3 同样叫slab,代码实现却分了好几支
这里必须先提醒一个坑:你在源码里看到的mm/slab.c是经典slab实现,而现代内核多数默认开启的是CONFIG_SLUB,走mm/slub.c;早期还有mm/slob.c,那是给嵌入式用的极简变体。三者的对外接口统一,但内部完全两码事。更常见的情况是,较新的内核把CONFIG_SLAB直接移除了,老教程里的mm/slab.c在新版本里可能不存在。所以我们这篇说的“slab源码”,指的是广义上的“基于slab思想的实现”,以经典slab的主线结构为主,遇到slub的地方我会指出来,方便你在不同版本内核间对照。阅读时先用grep确认你的内核开了哪个分配器,这是第一步,省得白看一堆对不上的宏。
2. 源码地图:先知道该翻哪个文件
2.1 主要文件与职责划分
打开内核源码,搜slab相关文件,大约会看到下面这几个核心点:
include/linux/slab.h:对外接口,kmem_cache_create、kmem_cache_alloc、kmem_cache_free的声明都在这里。mm/slab_common.c:公共部分,负责cache注册、统计、通用kmalloc cache的组织。mm/slab.c:经典slab的实现本体,大部分分配逻辑在这里。mm/slub.c:slub实现本体,你如果开启的是slub,阅读入口在这。include/linux/slab_def.h:slab相关结构定义,经典实现里struct kmem_cache的长相在这。include/linux/slub_def.h:slub的结构定义。
第一次看别在文件列表里迷失,我的建议是:先把slab.h里的接口扫一遍,再跳到slab_def.h看结构,最后回到slab.c看函数。如果把文件当字母表顺序啃,会很痛苦,因为实现由上百个静态函数交织,不经主链路串联基本记不住。
2.2 从kmem_cache_create找入口
内核里每个cache由kmem_cache_create创建。这个函数的实现在mm/slab_common.c里并不复杂:先做参数合法性检查,再计算对象大小、对齐方式,然后根据config走到__kmem_cache_create,最终把cache挂到全局链表中。你可能会问:为什么日常写驱动不用它?因为内核已经预建了一组通用kmalloc cache,只是在需要隔离管理、统计或具备专用构造/析构函数的场景,才自己创建专用cache。这也是理解slab的关键:它有两类玩家,一类是kmalloc背后的通用cache,另一类是kmem_cache_create创建的专用cache。后面分析分配路径时,你会发现两条路的终点殊途同归。
2.3 kmalloc缓存:另一种视角的“套餐窗口”
kmalloc背后通常是一组按2的幂次划分的cache,比如kmalloc-8、kmalloc-16一直到kmalloc-8192等。你申请一个172字节的对象,它可能落到kmalloc-256里。这个“向上取整”的机制会导致内部碎片,所以很多追求极致内存利用率的子系统宁可用专用cache,比如文件系统的inode_cache,对象大小正好贴合核心结构体。看/proc/slabinfo时你会看到一排kmalloc-*项,它们就是通用cache。排查内存占用时,看到这些名字别以为是什么奇怪内核模块,它们服务的是所有调用kmalloc的代码路径。
3. 核心数据结构拆解:看懂这些字段,代码就懂了一半
3.1 每个struct kmem_cache里,先抓这七个字段
经典slab的struct kmem_cache字段很多,但不需全部记住,只要会挑重点:
object_size:对象逻辑大小,也即用户申请时看到的规格;size:实际占用的内存大小,可能在对象大小基础上加上填充、红区、毒药等调试区域;align:对象对齐要求;num:一个slab里能容纳多少个对象;gfporder:每个slab要申请几个页面;colour和colour_off:着色相关,后面专门说;array_cache数组:每个CPU一个快速缓存;node数组:对应NUMA节点,管理每个节点上的slab链表;ctor:构造函数(可选),在对象创建时初始化。
可以把kmem_cache理解成一个品牌的中央厨房:它知道每种菜品的规格、备菜数量、每家分店(CPU)的库存,以及总仓库(node)的调度策略。
3.2 每个slab里,重点看对象组织
在经典slab实现里,描述每个slab的元数据也来自页结构,但用了一组专用字段来组织空闲对象。一个slab内部有三个状态:full、partial、free,对应对象全用光、部分空闲、全部空闲。链表就用slab_next/slab_prev穿起来,挂在struct kmem_cache某个节点的链表中。
要特别注意的是,struct slab在不同位数的系统上布局有差异,而且不同内核版本字段名在变动。老代码里你可能会看到struct page被复用为slab头部,新代码则可能独立出struct slab。看到差异先别慌,只要能认出“空闲对象链表”“inuse计数”“slab所在节点”这几个语义,名字怎么变都不影响理解主线。
3.3 对象空闲链表:最让我觉得巧妙的地方
每个slab内的空闲对象用一个链表串起来,链表指针直接放在对象内存本身里。什么意思呢?就是不额外占用管理空间,分配时把第一个空闲对象取出来,把指针更新为下一个;释放时再把对象插回链表头。第一次读源码时我脑子转半天才接受:对象还没分配出去时,它的前几个字节存的是“下一个空闲对象是谁”;一旦分配出去,这块内存才交给你写数据。这个机制虽然牺牲了一点对象需对齐的约束,但换来了极低的管理开销。
3.4 CPU快速缓存:struct array_cache
经典slab在每个CPU上维护一个array_cache,里面存一个指针数组,指向一批已经准备好、马上可用的对象。分配时优先从这取,没有才走slab链表,这叫“热路径”。用生活里的例子,这就是收银台后面的小抽屉:客人多时先把零钱备好,抽屉空了再到保险库取纸币。这个设计大幅减少了下探到slab链表和伙伴系统的机会,也是slab性能好的原因之一。
用/proc/slabinfo观察时,你会看到active_objs和num_objs两个计数。其中num_objs指cache总共管理的对象数,active_objs指正被使用的对象数。两者差值越大,说明囤在per-cpu缓存和partial链表里的“预备役”对象越多。实际排查内存占用时,这个差值是判断“是否异常堆积”的重要指标。有些朋友一看到num_objs比active_objs大很多就喊内存泄漏,其实未必,slab本来就会预留一部分对象。
4. 分配路径源码解析:一次kmem_cache_alloc到底经历了什么
4.1 从接口到核心的调用链
以经典slab为例,你调用kmem_cache_alloc(cachep, gfp)后,源码会顺着这样一条链走:
- 取当前CPU的
array_cache; - 如果有空闲对象,直接
array_cache->avail--,返回对应指针。这一步没有任何锁竞争,是快路径; - 如果
avail已经为0,就调用cache_alloc_refill从slab层“进货”,把一批对象搬进当前CPU的数组缓存; - 如果某个slab都满了,再从伙伴系统分配新的slab页面。
这个顺序一定要背下来吗?不用,但理解它的优先级很关键。每次分配都是在“更快但容量更小”的缓存里层层找,找到就返回,找不到才往下一层要。这跟CPU的L1/L2/内存的层级思想简直一脉相承,怪不得内核设计者这么喜欢cache这个词。
4.2 快路径:array_cache命中的时候
快路径里会先关本地中断或禁用抢占,具体做法因内核配置有差异。这一步是为防止当前CPU在操作过程中被调度或者被中断打断,导致array_cache指针状态不一致。接着看avail,如果还有盈余就把entry指针数组里下标减一后对应的对象返回。注意很多源码分析文章会强调这里尽量不用自旋锁,靠per-cpu和“关中断”保住一致性,目的就是极致压缩热路径开销。
我自己调过一个小模块,当时每秒有几十万次的分配释放动作,瓶颈不在算法,反而在锁竞争上。后来换成per-cpu缓存后,吞吐立刻上来。读slab源码时你会不断看到“per-cpu优先”的痕迹,这不是偶然,而是多核时代性能设计的主旋律。
4.3 中速路径:cache_alloc_refill的“进货”逻辑
当快路径落空,就进入cache_alloc_refill。它会先上锁,然后在当前节点的slab链表中找刚刚释放过对象的partialslab,把这些对象批量搬到array_cache中。搬运数量不是随意的,通常有个limit,要兼顾搬移成本与cache命中率。代码里还要检查这个slab在搬完后会不会变成full,如果会,就把它从partial链表摘下挂到full链表。这里能观察到slab在三个链表间的转会:free -> partial -> full -> partial -> free,对象生命周期就是绕着这条转会移动。
4.4 慢路径:从伙伴系统申请新slab
如果partial链表也空了,new_slab会从伙伴系统申请gfporder个页面。这不是每次分配都发生,但一旦发生,代价就非常可观:要调用页分配器,可能要处理内存回收、水位检查。这也是为什么slab缓存设置合理大小时,对系统整体性能影响明显——太小则频繁下探到慢路径,太大则浪费内存。calculate_slab_order就是干这个的,它会根据对象大小和目标利用率,选择一个合适的“一个slab页数”,让浪费率最小。经典slab里还常出现一个辅助cache叫slabp_cache,用来存放slab描述符本身;当描述符放不进页内剩余空间时,就得单独给它申请内存。这个细节在slabinfo里有时能看到一个名为slabinfo或类似特殊项,别觉得奇怪。
4.5gfp标志在slab里如何起作用
slab分配时传入的gfp标志并不直接在本级每一步都发力,而是在需要新页面或触发回收时才传给伙伴系统。比如GFP_ATOMIC告诉它不要睡眠,__GFP_ZERO要求对象清零,__GFP_HIGHMEM在某些场景下不能用于slab对象,等等。这块经常被初学者忽略,我特意提醒一句:阅读源码时看到gfp传参层层透传,不要以为所有标志在每层都生效,只有最后触及伙伴系统或内存回收才会真正发挥作用。
5. 释放路径:也别小瞧,热路径照样讲究
5.1 释放对象回array_cache
释放路径和分配高度对称:kmem_cache_free会把对象放回当前CPU的array_cache。如果array_cache没满,直接放入、增加avail,完事。这里同样要在关中断状态下做,避免在放回的瞬间被并发路径取走或踩踏。对热场景来说,大部分释放就是这个“入抽屉”动作,没有锁、没有链表操作。很多人只盯着分配路径看,以为释放简单,其实释放路径如果写得糙,照样会造成严重的cache miss和锁抖动。
5.2 数组缓存溢出时的回仓
当array_cache满了,或者内核判定缓存过大需要收缩,对象会被批量退回所属slab,再依据slab的空闲情况决定它的链表归属。如果对象全空了,slab可能被整体释放回伙伴系统。这里有个细节:释放对象时,slab的inuse计数器递减,当它从full变成partial时,会被挂回partial链表;而一部释放导致inuse=0时,是否立刻回收又取决于cache配置和内存压力,并非见空就销毁。理解这套“延迟回收”机制,对排查slab内存不下降的假象很有帮助。你看到某个cache在/proc/slabinfo里仍占着大量对象,但实际没有谁在分配它,很可能就是这些空闲slab在等待回收。
5.3 构造/析构函数在什么时候调用?
很多书提到kmem_cache_create可以传ctor构造参数,但注意构造函数的调用时机通常是对象初始分配时,而不是每次从空闲链表复用时。释放走kmem_cache_free也不会自动调用析构函数,除非你管理的对象有明确生命周期需求,否则别指望这个机制帮你清理资源。这个坑我当年踩过:以为free会触发清理,结果对象里的自旋锁状态根本没人管,后来调试老半天才想起来析构逻辑要自己负责。如果你的对象需要主动释放内部资源,比如解锁、放引用、归还缓冲区,要老老实实把这些动作写在你的“free逻辑”里。
5.4 kmem_cache_destroy:什么时候能真正销毁
销毁一个cache的调用是kmem_cache_destroy。这个函数并不保证立刻把所有slab释放干净,它要求调用者确认此时没有正在使用的对象,否则内核会列出一条警告,甚至拒绝销毁。所以在驱动卸载路径里,你应该先确保所有从该cache分配的对象都已回收。实际模块开发中,常见的错误是在持有对象引用时卸载模块,导致cache销毁失败或留下悬空对象。我在自己的模块里会在exit函数中加断言统计,确认active对象数为0,再调销毁,这能提前暴露不少生命周期问题。
6. slab着色和对象对齐:追求缓存命中率的细节
6.1 什么是slab着色,为什么要着色
在经典slab里,对象大小通常不会正好铺满一个slab,尾部会有边角料。为了平衡每条cache line的负载,不同slab会通过colour_off和colour字段把一个起始偏移量稍微错开,让高频访问的对象在不同slab里尽可能映射到不同的cache line。风险在于:如果所有slab中的偏移完全相同,多个slab中下标相同的对象会映射到同一组cache line上,导致相互驱逐,命中率下降。着色就是给它们“错峰出行”。
这个思想放到现实中很好理解:想象一条高速路入口,如果所有车都在同一时刻挤到同一条匝道,必然堵车;有人在前面路口错开一点放行,整体流量就顺畅了。CPU cache的set和路数有限,物理解释并不能做到随机,但slab着色尽量错开不同slab的起始位置,减少无谓的cache冲突。
6.2 颜色数怎么算出来的
colour的取值主要由slab剩余空间除以colour_off得到,代码里看起来就是colour = slab_size / colour_off之类的计算。理解这个公式不需要去背具体宏,而要明白设计意图:颜色数越多,错峰空间越大,但实现也越复杂。现代CPU的cacheline大小一般是64字节,所以colour_off也常与它或对象对齐值挂钩,让起始偏移尽量是cacheline的整数倍。有的读者会问:slab着色是不是把内存白扔了一块?其实那点偏移量通常只有几十到几百字节,换来的是反复分配同一cache时更稳定的缓存命中率,整体收益往往是正的。
6.3 着色在slub里的延续
经典slab对着色的实现比较显式,到了slub里,这一思想换了种形式:slub更多依赖object_size和red_left_pad等字段调整对象布局,同样尽量避免对象在物理内存中的固定偏移。调试时你如果看到对象地址“莫名其妙”不是整数对齐,或者对象前面多了几个字节,先想想是不是着色或调试填充区域,不要急着判定数据结构定义错。很多时候是抽象层加进来的对齐和填充,而不是真的乱序。
7. 调试选项与真实调试手段
7.1 内核开关:打开slab调试需要哪些配置
经典slab和slub都有调试选项,常见的包括:
CONFIG_SLAB_DEBUG:打开slab调试总开关;CONFIG_SLAB_RED_ZONE:在对象前后加防护区域,写入越界时能检测到;CONFIG_SLAB_POISON:用填充字节对对象做“毒化”,捕获未初始化和释放后使用;CONFIG_DEBUG_OBJECTS:对特定对象类型做参考追踪。
这些开关开启会显著改变对象布局,性能也会下降,所以生产环境一般不开,调试时才开。需要特别强调的是:开启红区或毒化后,struct kmem_cache里的size字段会比object_size大,原因就是附加区域也被计算了进去。如果你调试时发现结构体和预想的不一致,请先看配置。
7.2 落地排查:怎么抓到越界写
实际工作中最常见的slab问题之一,是某个驱动越界写把相邻对象踩坏了。开启毒化和红区后,通常做法是这样:
- 复现问题,让内核崩溃或打印出“Slab corruption”日志;
- 查看日志里的对象地址、cache名称、红区字节内容;
- 对照
/proc/slabinfo找出对应的cache; - 用
slabtop观察哪个cache的object数量、size异常。
这里有个习惯我不想藏私:排查越界时,我会先看红区里的内容,如果红区前后几个字节被人为改写成一串重复的值,往往能顺着值对应的数据结构或字符串判断肇事者范围;如果红区没坏,但对象内部被写入陌生数据,那可能是释放后重写,需要打开CONFIG_SLAB_POISON,从毒药字节是否被覆盖来定位。这套手法熟练后比盲目grep代码快得多。当然,这只是辅助手段,真要定位内核模块的越界,最终还是要靠KASAN这类动态检测工具,slab调试负责给你第一现场的线索。
7.3 动态开关:slub_debug参数
如果你跑的是slub,不用重新编译内核也能通过启动参数开启部分调试,比如slub_debug=P(毒化)或slub_debug=U(未初始化检查)。经典slab版本的调试更偏向编译期开关。这里多提一嘴是因为很多人搜到“slub_debug”命令后,在经典slab内核上执行却毫无反应,然后就蒙了,其实只要确认自己内核默认用的哪个分配器,就知道该相信哪些文档。检查方式很简单,/proc/slabinfo存在只是通用接口,真正决定走哪套代码的是内核配置里CONFIG_SLUB还是CONFIG_SLAB。另外slub_debug还支持按cache名过滤,比如slub_debug=P,kmalloc-64只对指定cache生效,这在复现场景比较有用。
8. 常见问题排查速查表
8.1 现象:某个cache的objects数目只涨不跌
第一反应别急着怀疑内存泄漏。slab延迟回收是正常现象,对象被释放后会留在array_cache或partial链表中,等内存压力上来才回收。可以先看/proc/slabinfo里active_objs和num_objs的差值,如果差值很大,说明大量空闲对象囤积在cache中,用slabtop -s按缓存大小排序,找出占了大量内存的cache。如果确实异常增长且无法解释,再怀疑泄漏也不迟。很多误报“slab泄漏”的情况,其实是对方没想到还有延迟回收这个机制。
8.2 现象:开启slab debug后panic,日志指向某一地址
panic信息往往会带有类似general protection fault、BUG kmalloc-64或Redzone overwritten的字样。第一件事是保存完整dmesg,不要把注意力立刻放到那一行红字上。再找到出问题的cache名字(比如kmalloc-64),想想哪类对象最常使用这个规格;用slabtop和trace过滤,可以缩小到驱动模块。如果是自己写的模块,优先检查有没有向kmalloc申请的对象越界写入。我在一次排查中遇到的崩溃信息指向kmalloc-128,最后发现是一个驱动把struct xxx的大小算错了,多写了4个字节,正好踩到红区。
8.3 现象:系统内存不足,但slab占用看起来巨大
这个通常是slab未能及时归还或者某个cache确实占用太大。可以执行echo 2 > /proc/sys/vm/drop_caches试试,但别指望能回收全部slab,slab回收受cache活跃度制约。更靠谱的做法是从/proc/slabinfo找到大头cache,再去看对应子系统,比如dentry、inode_cache、buffer_head等。有些cache平时看着大其实是正常缓存,比如dentry缓存会随文件访问增长,这是vfs的预期行为。如果你真的怀疑某个子系统失控,再去追查具体模块的分配释放逻辑,不要一上来就调drop_caches自欺欺人。
8.4 排障速查总表
我用一个表把常见问题和方向列出来,方便收藏:
| 现象 | 可能原因 | 先去查什么 |
|---|---|---|
Slab corruption | 越界写或对象被踩 | 红区内容、对象前后邻居 |
| 某个cache对象数暴涨 | 泄漏或未及时回收 | active_objs与num_objs差值、slabtop排序 |
| 开启slub_debug无效果 | 内核走的是经典slab | Kconfig的CONFIG_SLAB/CONFIG_SLUB |
| free后崩溃 | 释放后使用或重复释放 | 毒药字节被覆盖、对象内容痕迹 |
| 系统内存不足但slab巨大 | 某子系统缓存膨胀 | /proc/slabinfo大cache项、对应子系统 |
8.5 读源码时应该保持的主线思维
内核源码分析最忌讳抱着一个函数从头啃到尾。我的习惯是先跑通一个“生理周期”,即创建、分配、释放、销毁的完整过程,再倒回来研究每根枝杈。这个方法的效率远高于线性阅读,因为slab里的函数之间互相调用十分密集,如果你没有一条主线,很容易陷进kmem_cache_alloc的层层调用里出不来。我的建议是拿一张纸,先画出array_cache、slab链表、伙伴系统三层,然后在每层旁边标注对应函数名,边看代码边往纸上填数据流,过完一遍后再理解各种调试选项和性能调优手段,会觉得顺很多。
最后再分享一个小习惯:我第一次读slab源码时总想把所有宏定义记下来,后来发现纯属浪费时间。更实际的做法是把分配器当成一个“多级缓存系统”看待,抓住快路径、中速路径、慢路径和对象链表状态转换这条主线。只要这条主线通了,以后再切换slub或其他分配器,你会发现结构都是熟悉的配方:都有per-cpu快速缓存,都有partial链表,都有对象复用。这篇是为源码分析系列做的一根“拐杖”,真正深入之后你完全可以丢掉它,直接跟着mm/slab.c里的函数跳转走。