1. 为什么Buddy System之外内核还要再养一个分配器在Linux内核里内存管理虽然从Buddy System伙伴系统开始但实际工作中你几乎不会直接向它申请小内存。我见过不少刚接触内核的人去看alloc_pages然后疑惑既然粒度为页的分配器已经存在为什么fork()一个进程或者打开一个文件时还要绕一层别的机制关键在于两个数字页4KB和内核对象。以task_struct为例一个进程的任务描述符大约是2KB左右dentry结构约200字节inode约600字节file结构约256字节。如果每次都按页向Buddy申请一个页只能放一个task_struct剩下的空间基本废弃内核在大量创建、销毁进程时就会持续向伙伴系统发起高频操作。同时内存中会累积大量无法被其他用途利用的碎片化残余空间。更麻烦的是对象从申请到释放的整个生命周期里初始化代价往往远高于分配代价——比如dentry要解析路径、inode要关联文件系统如果每次分配都从零初始化、销毁时再全部释放性能和延迟都扛不住。于是就有了“对象缓存”这个概念。思路并不复杂把同类型对象批量备好像货架上的标准件一样随取随用用完归还到货架而不是直接回炉销毁。Slab分配器是Linux最早大规模落实这套思路的实现后来演化出Slub和Slob两种替代品合称内核的slab系列分配器。它们解决的是同一类问题——为那些频繁创建和销毁的小对象提供高效缓存但实现的取舍各有侧重。这篇文章我会从原理讲到实战三种分配器各自怎么设计、为什么Slub最终成为主线内核的默认选择以及当你在/proc/slabinfo里看到某个缓存对象数量飙升时该怎么顺着线索把问题定位到具体模块。适合正在看内核内存管理源码的人也适合在排查“内核内存只涨不降”这类问题的一线工程师。2. Slab分配器的核心设计每个“板”上住着同类对象2.1 从木板到缓存slab的物理组织最早期的Slab分配器设计非常直观。它把页看作一块“木板”plate每个struct slab对应一块或几块连续物理页然后在这块板上切分出等长的对象槽位。属于同一个kmem_cache的所有对象大小一致因此每条slab都持有固定数量的空闲对象以及一个简单的空闲链表来管理哪些槽可用。struct slab { struct list_head slab_list; /* 挂入 cache 的空闲/部分/满链表 */ void *freelist; /* 空闲对象链表头 */ unsigned int inuse; /* 已分配对象数 */ unsigned int objects; /* 总对象数 */ // ... };每个kmem_cache下挂三组slab完全空闲的、部分分配的、全部分配完的。分配对象时优先从部分空闲的slab里取取不到再找完全空闲的slab再没有就从伙伴系统那里要新页。释放对象时把对象归还给所属slab的空闲链表如果整条slab空出来了可能被回收到cache的空闲列表里也可能直接还给Buddy。这种“批量切分复用”的方式带来一个优势对象的初始化不再每次重复。内核为每种对象注册了构造函数第一次从Buddy拿到页并切分对象时调用构造之后对象归还只是回到缓存下次分配时数据结构里那些“陈旧但可用”的内容还在很多字段可以直接复用省掉了大段初始化开销。2.2 slab着色coloring到底在解决什么问题老版本Slab分配器源码里你会频繁看到一个词colour即着色偏移。很多资料只是略提一句“通过着色优化CPU cache利用率”但没讲清楚原理。CPU的L1/L2 Cache是分组的内存地址在映射到Cache组时低位索引会碰撞。如果同一kmem_cache里所有slab的对象都严格从页对齐的同一偏移开始放置那么不同slab上偏移相同的对象就会争抢相同的Cache组导致大量cache miss。Slab的着色做法让不同的slab在起始地址上错开若干个字节或整数倍的行使得对象分布在不同的缓存组里本质上是把冲突均匀化。在实际代码里这个偏移量由slab-colouroff决定计算时会根据cache行大小、对齐要求、slab页数来生成一个有限范围内的随机偏移序列。Slub实现则更简单它默认不额外做复杂着色而是依赖freelist重排来自然地打散对象分布。2.3 Slab的问题对象管理元数据太重Slab设计存在一个明显痛点每条slab都必须有独立的元数据描述符。当对象很小比如16字节时一个4KB页能放250个对象但一条slab的头、freelist指针、状态字段等往往要占用几十字节而且这些元数据也占内存。大量小对象积累后元数据占比相当可观。更麻烦的是Slab在SMP/NUMA环境下的复杂性。为了区分不同Node上的内存亲和性每个cache要为每个Node维护本地slab链表同时还要处理CPU本地缓存列表。代码里随处可见for_each_online_node之类的遍历锁竞争在高端口密度机器上尤其扎眼。老手之间流传一个说法Slab在单核时代很优雅在多核时代维护成本暴涨。这些痛点直接催生了Slub分配器。它的设计哲学和Slab完全不同能省则省能拆就拆。3. Slub分配器的精简学把元数据藏进struct page里3.1 去掉独立的slab描述符Slub最有魄力的改动是不再为每条slab单独分配一个描述符结构。slab的元数据信息freelist、inuse、objects等直接借用struct page里那些在Buddy管理时闲置的字段。struct page { // 常规管理字段... union { struct { /* Slub 复用 */ struct list_head slab_list; void *freelist; // 指向下一个可用对象 unsigned int inuse; unsigned int objects; }; // 其他子系统字段... }; };struct page内核里有统一的页结构Buddy回收或分配页时部分字段不生效。Slub就是利用这个空隙把每页或每组页的slab信息塞进同组第一个struct page的联合体里。这样既省掉了额外结构体分配也让层次关系更扁平。这套设计意味着Slub在内存密度上有天然优势。用slabinfo观察同一套系统你会发现struct task_struct这类cache的内存占用Slub通常比Slab少几个百分点在大量小对象场景差异更明显。3.2 三级缓存层次CPU本地优先Slub把缓存拆成三个层级层层缓冲CPU本地freelist每个CPU的回填链表分配时优先从这里取无锁或极短锁。Node partial列表按NUMA节点组织线程只抢占对应Node的partial链表避免跨Node访问。伙伴系统本地列表和partial都没有可用对象时重新获取页并切分。分配路径上有一个重要优化freelist指针不只是指向空闲对象它还负责缓存“下一个要用的对象”。Slub在构建空闲链时会按CPU cache友好的顺序重排使得连续分配的对象在物理内存里尽量相邻。这块策略叫freelist randomization默认开启代价是构建链表时多一段重排逻辑但对多核体系下的TLB和cache命中率提升明显。static inline void *slab_alloc_node(struct kmem_cache *s, gfp_t gfpflags, int node, unsigned long addr) { struct slab *slab; unsigned long flags; void *object; // CPU 本地 freelist 快速路径 slab cpu_slab-slab; if (likely(slab slab-freelist)) { // 直接取出对象并更新 freelist object slab-freelist; slab-freelist get_freepointer(s, object); // ... return object; } // 慢路径从 partial/list、Buddy 逐级找 // ... }刚接触源码的人容易陷进快速路径和慢路径的细节但其实抓住一条主线就行优先从当前CPU拿拿不到再看本节点最后才跨节点找伙伴系统。理解这条主线后面看性能调优和内存水位控制都顺了。3.3 为什么Slub成为主线默认主线内核从2.6.23之后默认使用Slub这个决定背后有几个硬理由代码量小、可维护性强Slub核心逻辑比Slab少一大截bug排查路径短。元数据开销低尤其对小对象密集场景友好。调试能力更强Slub内置了对象毒化、红区检测、栈回溯记录而Slab的调试功能耦合深开启后性能损失明显且代码实现复杂。并发性能好CPU本地freelist让绝大多数分配释放路径绕开了全局锁。如果你的内核是这些年主流发行版自带的CONFIG_SLUB基本是默认开启的。少数做实时或嵌入式裁剪的团队会考虑其他选项。4. Slob分配器嵌入式场景下的极简路线4.1 没有slab概念的分配器Slob的全称是“Simple List Of Blocks”它跟Slab/Slub走的完全不是一个路子。它不维护kmem_cache和slab对象池而是直接在页内维护一个块链表有点像内核版的简化malloc把整块内存按不同大小切成块块头保存尺寸信息空闲块用链表串起来。struct slob_block { int size; /* 含头部在内的块大小 */ struct slob_block *next; /* 空闲块链 */ };分配时按first-fit策略扫描空闲链表找到足够大的块就切分剩余部分重新挂回链表。释放时把块归还给空闲链表并尝试与相邻空闲块合并。4.2 它在什么场景有意义Slob的优势在于极低的元数据开销和极其简单的代码路径。它不需要page字段的复杂借用不需要per-cpu缓存也不需要维护复杂的NUMA状态。在只有几MB内存的单核嵌入式设备上Slob可以把方案做到小而精节省出的代码和数据结构内存相当可观。不过它的短板也很明显分配释放性能不稳定容易产生外碎片长时间运行后可能出现“明明有内存但连续大对象分配不出来”的情况。因此在Docker容器、虚拟化、桌面、服务器场景下内核不会选它。4.3 现实情况新内核已基本放弃SLOB需要提醒的是SLOB在长时间“有人用但少有人测”的状态后主线内核在较新的版本6.4左右已经移除了SLOB选项。虽然仍能在老内核或部分厂商树里看到它但新代码中已不再维护。如果你在嵌入式平台做定制内核除非有充分的兼容性要求否则建议直接使用SLUB并开启CONFIG_SLUB_TINY来适配小内存环境效果比继续啃SLOB更实际。5. Slab、Slub、Slob三者的关键差异与运行期选型依据5.1 从设计权衡维度做横向对比维度SlabSlubSlob元数据开销高独立描述符低复用struct page极低页内块头分配释放性能中等多核锁竞争明显高CPU本地freelist低first-fit扫描NUMA支持有但复杂设计简单且有效基本无专门优化调试能力弱且侵入性强强毒化、RZ、trace几乎没有适用场景历史兼容、早期内核现代通用/服务器/嵌入式小内存单核/已废弃5.2 编译期如何选择和确认在你自己的内核源码根目录下执行grep -E CONFIG_SLAB|CONFIG_SLUB|CONFIG_SLOB .config正常情况下你会看到类似CONFIG_SLUBy # CONFIG_SLAB is not set # CONFIG_SLOB is not set如果在Menuconfig里改选其他分配器重新编译后需要重点回归测试多线程sysbench或内核编译任务因为分配器变动会影响整体吞吐。我实际测试过一个4核8GB的云主机从Slub切到Slab后hackbench这类进程频繁创建类负载吞吐下降了10%左右原因正是进程创建销毁时task_struct、mm_struct等缓存对象的分配路径竞争更大。如果你在做内核裁剪但又想要Slub的低开销特性留意CONFIG_SLUB_TINY它牺牲一部分调试与热路径优化换取更小的代码段和静态数据结构非常适合小内存的嵌入式场景比SLOB更值得投入。5.3 运行期观察当前用的是哪个分配器运行时想知道目前内核使用哪种实现最简单的办法cat /proc/slabinfo | head -20如果能看到输出基本就是Slab或Slub两者都实现了slabinfo接口但格式稍有差异。另外可以查dmesgdmesg | grep -i SLUB|SLAB初始启动日志里会打印类似SLUB: HWalign64, Order0-3, MinObjects0, CPUs16, Nodes2这行就来自Slub初始化阶段。6. 用/proc/slabinfo和/sys/kernel/slab实测对象缓存行为6.1 slabinfo字段解读不只是看一眼数字/proc/slabinfo每个cache一行我贴一个实际输出片段来逐字段拆dentry 512 820 256 16 4 : tunables 0 0 0 : slabdata 32 32 0这些字段依次是cache名称active_objs当前已分配对象数num_objscache里目前存在的对象总数含空闲objsize单个对象大小字节objperslab每个slab可容纳对象数pagesperslab每个slab占用页数tunables可调参数一般不用动slabdata当前slab数、最多slab数、共享slab数看两个数字组合最有用active_objs / num_objs。如果这个比值长期接近1说明cache基本没有空余对象新对象申请会频繁触发向伙伴系统申请新页如果比值在0.2以下说明存在大量空闲缓存对象代码里可能有对象分配后长期占用但不释放的嫌疑注意区分“分配但还没用”和“泄漏”。6.2 快速定位哪个cache在疯长排查内核内存增长我常用的命令组合watch -n 1 cat /proc/slabinfo | awk {print \$1, \$2, \$3, \$4}或者直接用slabtop -s c按对象数排序观察变化最快的几个cache。常见的嫌疑包括dentry或filp文件打开/关闭过多或者路径缓存回收不及时。kmalloc-*通用小块对象分配可能来自驱动、网络协议栈或某些内核线程。cred_jar凭据对象进程安全上下文切换频繁的场景会增长。task_struct线程频繁创建销毁如果创建速率很高cache里对象多很正常但如果持续不回收就要看是否有线程泄漏。有个更省事的方法是看/proc/meminfo里的Slab字段但它只给总量不给分布。所以定位增量还是要落到具体cache。6.3 /sys/kernel/slab/debugSlub的调试开关Slub的调试能力藏在sysfs里find /sys/kernel/slab -maxdepth 1 -type f -name *debug*以dentry缓存为例查看当前调试开关cat /sys/kernel/slab/dentry/debug输出类似---表示未启用。启用对象毒化poison、红区red zone和分配栈记录可以在启动参数里加slub_debugPZ参数含义P对象毒化写入固定模式如0x6b释放后检查是否被改写。Z红区覆盖检查是否越界读写。F记录alloc和free的调用栈。U记录分配者并复用旧对象数据做双重校验。这套开关对线上临时机器比较有用但开销大重启后一般就关掉。如果想精确追踪某一个cache可以只对单个cache开slub_debugPZ,dentry重点是让分配器在free时做对齐和越界校验越界行为会在释放时报出第一个写坏的位置配合dmesg能直接定位。7. 踩坑实录slab对象泄漏与Corruption的排查路径7.1 一次“Slab内存只涨不降”的排查过程有一年排查一个网络网关设备内存持续攀升直到触发OOM。free看的是available内存持续下降但用户态进程内存占用并不高。用/proc/meminfo一看Slab字段从80MB一路涨到300MB。再看具体cachekmalloc-4096和skbuff_head_cache增长最多。排查思路如下先用slabtop确认主线确定是skbuff_head_cacheSKB头部缓存在涨。借助追踪点抓分配者开启kmem:kmem_cache_alloc和kmem:kmem_cache_free的tracepoint统计哪个函数分配的SKB没有对应释放。用perf或者bpftrace挂上很快看到大量分配来自网桥转发路径的一个driver函数。查看driver代码发现某版本驱动在开启vlan过滤后skb_clone的引用计数多保留了一次导致free时只减引用不断言释放。缓存对象只有进没有出Slab自然只涨不降。这个案例里最有用的其实是tracepoint统计比盲目看代码快得多。命令大概长这样bpftrace -e kprobe:kmem_cache_alloc { [kstack] count(); }或者用更细粒度的tracefs过滤器只追踪skbuff_head_cacheecho kmem_cache_alloc ! 0 /sys/kernel/tracing/events/kmem/kmem_cache_alloc/filter echo 1 /sys/kernel/tracing/events/kmem/kmem_cache_alloc/enable跑一段时间后读trace看频率最高的分配调用栈。7.2 对象毒化如何帮你抓“写越界”另一个常见问题是对象越界写分配了32字节的对象但某驱动往里面写40字节。没有保护时越界部分会悄悄破坏相邻对象过很久才在无关模块里暴露出一个野指针或校验错误。遇到这种问题我会用内核的slub_debug毒化机制。启动参数改为slub_debugFZP重启后kmem_cache_alloc返回的对象已经用特定pattern填充kmem_cache_free时再检查这些pattern。一旦发现某个字节被改动内核会在日志中打印类似slub_debug: Object 0xffff8881234abcde (cache kmalloc-32): poison overwritten并给出当前调用栈和最近一次free/alloc的栈。此时就能直接确定是谁在释放后又写入了这块区域。配合红区Z还能抓到“越界读写但还在同一对象附近”的案例。提示生产环境不建议全内核开毒化开销高到影响吞吐。最稳妥的办法是先怀疑某个子系统再对特定cache开启比如slub_debugFPZ,skbuff_head_cache。7.3 低内存设备和虚拟化环境的特殊关注点在容器或虚拟化环境下内核Slab对象受宿主影响又叠加了一层不确定性。因为宿主机内存超卖、气球驱动调整的影响容器内看到的/proc/slabinfo增长可能只是内核为回应各Namespace的dentry/cred对象所做的正常缓存扩容。这里有两个经验容器内排查内存增长先看cgroup的memory.stat里的slab字段是否与全局同步增长如果不是优先怀疑自身进程持有fd/thread导致的filp、task_struct缓存膨胀。考虑是否调小slab_min_extra和slab_max_size等参数。不过这些是老Slab时代的可调项Slub下很多场景用不上。Slub里更有用的是/sys/kernel/slab/cache/cpu_partial这些可调项在NUMA跨节点分配频发时可以尝试把partial值调大减少伙伴系统调用。8. 踩过几次坑之后我对slab系列分配器的实操体会先补充一个非常实用的小命令。如果只想快速对比某两个cache当前的对象数量和平均大小可以一次性读出来for c in dentry filp cred_jar kmalloc-512; do awk -v name$c $1 name {print name, active $2, total $3, size $4} /proc/slabinfo done这个脚本我反复用了很多次比反复grep一个cache实用得多。再就是推荐两个常见的误判规避方向不要只看到某个cache对象数大就认定泄漏。像dentry这种系统里天然庞大的缓存对象数在万级是正常的因为路径缓存本来就是内核主动保留的。要对比free/alloc的速率或者看它是否一直在增长而不回落。不要在高负载生产环境一上来就全量开slub_debug。全量毒化会显著影响性能而且dmesg日志会被大量越界警告刷屏。先定位到可疑cache再单独开。最后分享一个关于源码阅读的体会直接读mm/slub.c如果觉得入口太杂可以先从kmem_cache_create和kmem_cache_alloc两条函数顺着看。kmem_cache_create会调__kmem_cache_create里面有对象大小对齐、页面阶数选择、freelist随机的初始化过程kmem_cache_alloc则走slab_alloc_node观察它如何依次访问cpu_slab、node partial、Buddy整条主线就通了。Slub的代码相比Slab少了很多分支个人认为是最适合作为内核内存管理入门读物的素材。