☰
Linux内核内存管理:Slab与Slub分配器原理及排查实战
2026/10/9 3:30:11 网站建设 项目流程

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,这个决定背后有几个硬理由:

  1. 代码量小、可维护性强:Slub核心逻辑比Slab少一大截,bug排查路径短。
  2. 元数据开销低:尤其对小对象密集场景友好。
  3. 调试能力更强:Slub内置了对象毒化、红区检测、栈回溯记录,而Slab的调试功能耦合深,开启后性能损失明显且代码实现复杂。
  4. 并发性能好: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_SLUB=y # 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接口,但格式稍有差异)。另外可以查dmesg:

dmesg | grep -i "SLUB|SLAB"

初始启动日志里会打印类似SLUB: HWalign=64, Order=0-3, MinObjects=0, CPUs=16, Nodes=2,这行就来自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_objs:cache里目前存在的对象总数(含空闲)
  • 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/debug:Slub的调试开关

Slub的调试能力藏在sysfs里:

find /sys/kernel/slab -maxdepth 1 -type f -name "*debug*"

以dentry缓存为例,查看当前调试开关:

cat /sys/kernel/slab/dentry/debug

输出类似---,表示未启用。启用对象毒化(poison)、红区(red zone)和分配栈记录,可以在启动参数里加:

slub_debug=PZ

参数含义:

  • P:对象毒化,写入固定模式(如0x6b),释放后检查是否被改写。
  • Z:红区覆盖,检查是否越界读写。
  • F:记录alloc和free的调用栈。
  • U:记录分配者,并复用旧对象数据做双重校验。

这套开关对线上临时机器比较有用,但开销大,重启后一般就关掉。如果想精确追踪某一个cache,可以只对单个cache开:

slub_debug=PZ,dentry

重点是让分配器在free时做对齐和越界校验,越界行为会在释放时报出第一个写坏的位置,配合dmesg能直接定位。

7. 踩坑实录:slab对象泄漏与Corruption的排查路径

7.1 一次“Slab内存只涨不降”的排查过程

有一年排查一个网络网关设备,内存持续攀升直到触发OOM。free看的是available内存持续下降,但用户态进程内存占用并不高。用/proc/meminfo一看,Slab字段从80MB一路涨到300MB。再看具体cache,kmalloc-4096和skbuff_head_cache增长最多。

排查思路如下:

  1. 先用slabtop确认主线:确定是skbuff_head_cache(SKB头部缓存)在涨。
  2. 借助追踪点抓分配者:开启kmem:kmem_cache_alloc和kmem:kmem_cache_free的tracepoint,统计哪个函数分配的SKB没有对应释放。用perf或者bpftrace挂上,很快看到大量分配来自网桥转发路径的一个driver函数。
  3. 查看driver代码:发现某版本驱动在开启vlan过滤后,skb_clone的引用计数多保留了一次,导致free时只减引用不断言释放。缓存对象只有进没有出,Slab自然只涨不降。

这个案例里最有用的其实是tracepoint统计,比盲目看代码快得多。命令大概长这样:

bpftrace -e 'kprobe:kmem_cache_alloc { @[kstack] = count(); }'

或者用更细粒度的tracefs过滤器,只追踪skbuff_head_cache:

echo '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_debug=FZP

重启后,kmem_cache_alloc返回的对象已经用特定pattern填充,kmem_cache_free时再检查这些pattern。一旦发现某个字节被改动,内核会在日志中打印类似:

slub_debug: Object 0xffff8881234abcde (cache kmalloc-32): poison overwritten

并给出当前调用栈和最近一次free/alloc的栈。此时就能直接确定是谁在释放后又写入了这块区域。配合红区(Z)还能抓到“越界读写但还在同一对象附近”的案例。

提示:生产环境不建议全内核开毒化,开销高到影响吞吐。最稳妥的办法是先怀疑某个子系统,再对特定cache开启,比如slub_debug=FPZ,skbuff_head_cache。

7.3 低内存设备和虚拟化环境的特殊关注点

在容器或虚拟化环境下,内核Slab对象受宿主影响又叠加了一层不确定性。因为宿主机内存超卖、气球驱动调整的影响,容器内看到的/proc/slabinfo增长可能只是内核为回应各Namespace的dentry/cred对象所做的正常缓存扩容。

这里有两个经验:

  1. 容器内排查内存增长,先看cgroup的memory.stat里的slab字段是否与全局同步增长,如果不是,优先怀疑自身进程持有fd/thread导致的filp、task_struct缓存膨胀。
  2. 考虑是否调小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少了很多分支,个人认为是最适合作为内核内存管理入门读物的素材。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询