SLUB分配器核心:kmem_cache_alloc对象分配全路径解析 我在排查内存“只涨不降”类问题的时候大多数时间其实都耗在/proc/slabinfo的某一行上。active_objs、num_objs、objs_per_slab这几个数字看起来平平无奇但背后每一次跳动都是 SLUB 分配器的Object 分配逻辑在运转。这一篇是 SLUB 分配器系列的第三篇前两篇把struct kmem_cache的创建过程和 slab 的物理组织讲清楚了这篇就直接拆最核心的问题一次kmem_cache_alloc()到底怎样把一个 object 交到你手上。默认你已经知道 slab cache 是什么也大概知道kmem_cache和slab的关系。这篇的目标是让你能顺着源码看懂分配路径能排查性能瓶颈也能处理“对象泄露、对象被写坏”这类线上问题。代码以 5.15 到 6.x 主线内核的行为为准个别字段名在不同版本里有调整但语义基本没变。1. 从 kmem_cache_alloc() 开始Object 分配的整体地图1.1 先把“object”这个词翻译成内存布局SLUB 里的 object 就是kmem_cache管理的固定大小内存单元。一个 slab 由2^order个连续物理页组成在这片区域里被等分成若干个对象。对struct task_struct、struct inode、dentry这类内核对象来说一个 object 就是整个结构体对kmalloc-64这类通用缓存来说一个 object 就是一次kmalloc(64)返回的那块内存。分配器的核心职责不是“找到一块内存”这么简单而是在合适的时间、从合适的缓存里、以尽可能低的成本把一个空闲对象从对象的空闲链上摘下来。空闲对象之间是靠指针串起来的这个链就是 SLUB 分发的命根子。1.2 三条路径一张表看明白整个 object 分配可以分成四层从快到慢路径触发条件同步方式典型成本量级快路径当前 CPUc-freelist非空无全局锁局部原子操作几十纳秒CPU partial当前 slab 耗尽但本 CPU partial 有货本 CPU 私有链表不碰 node 锁微秒级Node partialCPU partial 也没货需要n-list_lock微秒到几十微秒新建 slab所有 partial 都没有向 buddy 要页 初始化几十微秒往上调用关系大概是kmem_cache_alloc(s, gfp) - slab_alloc(s, gfp, caller_ip) - slab_alloc_node(s, gfp, NUMA_NO_NODE, caller_ip) - 快路径直接摘 object - __slab_alloc() / ___slab_alloc() // 慢路径不同内核版本对慢路径封装的层数不一样叫什么名字不重要重要的是路径分级这件事始终存在。1.3 为什么快慢路径要分这么细直觉上每次分配都去 node 链表上翻最省内存但那要抢一把 per-node 锁还要处理 CPU 间的 false sharing。SLUB 的选择是让每个 CPU 先私有一批 free object 和几个半空 slab绝大多数分配都在本 CPU 上自给自足。只有私有库存耗尽才去碰全局结构。理解了这一点后面所有细节都是在回答同一个问题“本 CPU 的库存是怎么维护、怎么补充的”。2. 快路径原理freelist 如何在 object 之间串成链2.1 freepointer 与 s-offset每个空闲 object 的头部具体偏移由s-offset决定多数情况下就是对象起始处会存一个指针指向下一个空闲 object。这就是 freepointer。分配时读取c-freelist拿到当前对象然后通过get_freepointer(s, object)取出下一个空闲对象更新到c-freelistobject c-freelist; if (likely(object)) { c-freelist get_freepointer(s, object); return object; }逻辑简单到不像话但工程难点全在get_freepointer上。对象在内存里是会被人为破坏的freepointer 也是内核漏洞利用的高价值目标所以现代内核不会把指针裸存。2.2 FREELIST_HARDENED 之后的解码公式当内核开了CONFIG_SLAB_FREELIST_HARDENEDfreelist 指针写入前会做一次混淆读取时再还原static inline void *freelist_ptr_decode(const struct kmem_cache *s, void *ptr, unsigned long ptr_addr) { return (void *)((unsigned long)ptr ^ s-random ^ swab(ptr_addr)); }三个因子缺一不可ptr是对象里读出来的原始字面值s-random是创建kmem_cache时用随机数生成器产生的、每个缓存独立的随机数swab(ptr_addr)是对指针存储位置本身地址做字节反转注意不是对象地址是那个字段的地址。这样做的效果是攻击者就算能改掉对象内容也没法轻易伪造一条合法的 freelist 链因为同一个字面值放在不同偏移位置解码出来的结果都不同。这也是为什么你经常能看到slub_debug报Freepointer corrupt—— 解码之后得到的地址指向了对象区域之外分配器马上知道有人越界乱写了。2.3 为什么“无锁”仍然需要原子操作快路径不拿任何全局锁但它并不是完全裸奔。同一颗 CPU 上进程上下文可能正分配到一半突然来了中断中断处理函数里也可能调用kmem_cache_alloc。这时候两个上下文共享同一个c-freelist必须有一种机制保证“谁先把对象拿走了另一个得重新读”。早期实现用this_cpu_cmpxchg_double同时更新 freelist 和 tid 来化解后来的实现则用struct kmem_cache_cpu里的local_lock_t lock配合关抢占/关中断来保护。不管具体用哪种语义都是快路径只是没有跨 CPU 锁不是没有原子性。排障时如果看到某条路径上频繁出现 cmpxchg 或 local_lock 等待多半是中断上下文和进程上下文在同一颗 CPU 上抢分配而不是真的跨核竞争。3. 当前 slab 用尽之后deactivate_slab 与 per-CPU partial 接力3.1 frozen 状态slab 的所有权标记要理解慢路径必须先理解struct slab里的frozen字段。它表示 slab 当前是不是被某个 CPU“冻住”了frozen 1slab 有明确的 CPU 主人。要么是当前正在往外发的c-slab要么挂在某个 CPU 的 partial 链表上。其他 CPU 往这个 slab 里 free 对象时不能直接改slab-inuse计数只能走延迟路径等主人重新结算。frozen 0slab 挂在 node 的 partial 链表上面向所有 CPU谁都可以取走配合 node 锁和 cmpxchg 更新 freelist 和 inuse。这个标记是 SLUB 能在“无锁快路径”和“node 协同”之间切换的关键。一个 slab 被 CPU 拿走当分配源的那一刻就要先冻结保证整条 freelist 只有这颗 CPU 在消费。3.2 当前 slab 耗尽时deactivate_slab 的两个出口当c-freelist被取空或者调用方指定的 NUMA 节点和当前 slab 所在节点不一致分配器就会把当前c-slab交给deactivate_slab()处理。它有两个出口如果当前 slab 还有空闲对象并且本 CPU partial 链表没到上限就把它保持 frozen 状态链入c-partial下次直接复用如果已经没有价值比如对象全部分配出去就把它解冻并交还 node 的 partial 链表让释放路径能在对象回来时重新把它挂上。这里有个很容易搞混的点c-slab的 freelist 被取空不代表 slab 真的全满了有可能少数对象正被其他 CPU 释放回来只是还没结算。所以 deactivate 要做的是把“归一化”后的状态重新登记而不是简单丢给 buddy。3.3 cpu_partial 阈值与 unfreeze_partialsstruct kmem_cache里有个cpu_partial值表示每个 CPU 最多在c-partial里保留多少个半空 slab。struct kmem_cache_cpu里对应的partial.nr就是当前实际数量。一旦c-nr达到s-cpu_partial分配器会调用__unfreeze_partials()把整条 CPU partial 链表批量解冻一次性塞回 node partial。这样设计是为了平衡两个目标保留多一些 CPU partial能减少 node 锁的争用但每个 CPU 都囤积 slab意味着这些内存对其他 CPU 不可见内存压力大时很浪费。所以cpu_partial不是越大越好。默认值通常是slub_cpu_partial启动参数指定的 30但实际生效值还会受对象大小影响具体看/sys/kernel/slab/cache/cpu_partial。3.4 从 c-partial 中拎出 slab 的细节当某次分配发现c-slab不可用而c-partial有货分配器会从链表头部摘一个 slab重新置frozen 1并用get_freelist()把 slab 上的整条 freelist 一次性搬进c-freelistload_freelist: freelist get_freelist(s, slab); c-slab slab; c-freelist freelist;这里用了一次 cmpxchg 把 freelist 置空并冻结防止另一颗 CPU 在同一瞬间往 slab 里插入新增的空闲对象。搬完之后本 CPU 又可以走完全无锁的快路径了。4. node partial 兜底与新 slab 的诞生4.1 什么时候会去翻 node partial两种情况会走到 node partialc-partial也空了本 CPU 私有库存彻底耗尽调用方显式指定了 NUMA 节点而当前 CPU 手头的 slab 不属于目标节点。struct kmem_cache_node里维护着该节点所有“非冻结、且有空闲对象”的 slabstruct kmem_cache_node { spinlock_t list_lock; unsigned long nr_partial; struct list_head partial; ... };分配器会在n-list_lock保护下遍历 partial 链表找到一个freelist非空的 slab。这个搜索不是完全随机的get_partial_node()会在链表里走一节优先挑有几个空闲对象的 slab避免频繁搬运。找到之后同样要冻结然后用get_freelist()把对象链搬到 CPU 上。注意这里的 slab 可能是其他 CPU 释放回来的对象的 inuse 计数处于“已知合法”状态冻结后inuse不再变只有 dissociate 时统一结算。4.2 新 slab 的诞生calculate_order 与 allocate_slabnode partial 也没有的话只能向 buddy 分配器要全新的物理页。入口是new_slab()-allocate_slab()。第一步是决定“要多少页、每个 slab 装多少对象”这就是calculate_order()的活。它会参考三个启动参数slub_min_objects期望每个 slab 至少装多少个对象未手动设置时会按 CPU 数算一个默认值slub_max_order允许的最大 order默认一般到 3也就是最多一次拿 8 个物理页32KBslub_min_order允许的最小 order。对每个候选 order算出能装多少对象再结合内存浪费比例选一个折衷值。结果以ooorder objects 打包的形式存在struct kmem_cache里。所以你在 sysfs 里看到的objs_per_slab和order都是在创建缓存时就算好的。选择 order 的本质是拿“分配粒度更粗、内存更整齐”换“对象更多、管理 overhead 更低”。对象越小通常越倾向于用高 order对象大到单个 slab 放不下期望数量的对象时就退化成 order 0。4.3 initialize_slab把对象摊到物理页上拿到folio之后folio_slab()把页变成struct slab视图slab folio_slab(folio); slab-slab_cache s; slab-objects oo_objects(s-oo);然后进入initialize_slab()。这里要做的核心事情是从头构建 freelist从 slab 的第一块对象区域开始按s-size步长找到所有对象的起始地址再把每个对象的 freepointer 指到下一个对象最后一个指向 NULL。如果开了CONFIG_SLAB_FREELIST_RANDOM内核会用一个伪随机序列打乱 object 的顺序这就是shuffle_freelist()目的是让 freelist 布局不可预测降低堆喷射类攻击的成功率。4.4 新对象的内容与安全初始化新 slab 构建完成、对象进入 freelist 时还会按配置做内容初始化开了SLAB_POISON对象会被填上0x6bPOISON_FREE对象尾部再放一个0xa5POISON_END。以后释放时检测这些字节是否还在就能很快发现 use-after-free开了init_on_alloc1每个对象在返回前会被memset清零防止敏感信息跨对象泄露开了init_on_free1释放时清零防止被读取还热乎的数据。这些初始化会直接作用在快路径的返回语句附近。这也是为什么很多发行版安全加固内核里kmem_cache_alloc的火焰图上总能看到 memset 热点——不是分配器本身慢是安全策略要求每次分配都清对象。5. 分配失败与非预期耗尽定位和解析5.1 什么时候kmem_cache_alloc()真的会返回 NULL只有慢路径全部走完还没有内存时分配器才放弃。new_slab()向 buddy 申请页失败后会尝试唤醒回收如果gfp明确不允许睡眠等待比如GFP_ATOMIC那就只能返回 NULL。这种情况下内核通常会打印slab_out_of_memory一类的诊断信息把所有 node 的 partial 数量、对象数、order 数都列出来。线上看到这个输出基本意味着内存压力已经传导到 slab 层了。这时候先别盯kmem_cache_alloc应该去看 buddy 层和更上层的直接回收路径因为 slab 只是最下游的受害者。5.2 三类典型的对象损坏现象用slub_debug会得到三类非常典型的内核报错Freepointer corrupt对象的 freepointer 解码后不在 slab 区域内通常是相邻对象越界写造成的Object already free / double free对象不在 freelist 上却再次被释放说明有两条释放路径指向了同一个对象Redzone overwritten开了Z后对象前后的红区字节被改写能精确定位溢出方向是向前还是向后。一个常见误区是“出错消息里写的是 kmalloc-64但问题不一定出在 kmalloc-64 的调用者”。freelist 链是跨对象共享的任何一个对象被踩都可能破坏下一个对象的 freepointer所以要结合alloc_calls/free_calls和 tracepoint 堆栈综合判断。5.3 用 tracepoint 抓分配现场排查分配路径问题时我一般先开 tracepoint 而不是直接扒代码echo 1 /sys/kernel/debug/tracing/events/kmem/kmem_cache_alloc/enable echo 1 /sys/kernel/debug/tracing/events/kmem/kmem_cache_free/enable perf record -g -e kmem:kmem_cache_alloc -e kmem:kmem_cache_free -a -- sleep 30对比分配和释放的调用栈能很快找到“只分配不释放”的热点模块。如果要精确到对象地址可以在 trace 输出里匹配 object 字段再回 slab 布局推算是哪个偏移被踩。崩溃转储场景下也可以直接在crash里看每个 cache 的cpu_slab和 freelist 链确认是不是某颗 CPU 的 freelist 被破坏成了环。6. 调参经验哪些参数值得动哪些不要乱动6.1 五个最常用的启动参数参数作用我的常用做法slub_min_objects控制每个 slab 最小对象数间接决定 order小对象缓存想“合并大 slab”时调大slub_max_order限制 slab 最大 order内存碎片敏感场景调低到 0 或 1slub_cpu_partial限制每 CPU partial slab 数量内存紧张时从默认 30 降到 5 左右slub_nomerge禁止 kmalloc 缓存合并排查跨缓存踩踏时用平时不开slub_debugFZPU开启一致性检查、红区、毒化、用户追踪只在上线前的测试环境开其中slub_debugU会把调用栈信息写进每个对象让对象大小和缓存数量都上涨性能下降也很明显绝对不适合生产长期开。6.2 什么时候调大、什么时候调小高并发分配且对象生命周期短的服务把slub_cpu_partial调大能显著减少 node 锁争用代价是每颗 CPU 都“藏”着一批空闲 slab总体内存水位会高一点。反过来容器多、内存水位敏感的环境调小slub_cpu_partial能让空闲 slab 更快回流到 node内存释放更及时。slub_max_order也类似。把它调高小对象缓存能一次拿更多页、准备更多对象但瞬间分配压力变大也可能因为连续页不足而频繁触发直接回收。遇到分配延迟抖动先看order是不是偏高再决定要不要压回 order 0。6.3 我实际排障时的一套流程先说结论读源码是“知道它能干什么”排障靠的是“观察它实际干了什么”。我现在的习惯是先记录/sys/kernel/slab/cache/slabs和/proc/slabinfo区分是 slab 数量膨胀还是单个 slab 内对象膨胀用 tracepoint 抓 10 秒分配/释放样本统计调用栈确认目标 cache 后在测试机重启加slub_debugPZ复现用 dmesg 里的对象地址和偏移定位越界源最后才考虑调cpu_partial、slub_max_order这类参数去缓解而不是根治。最后再分享一个小经验/sys/kernel/slab/cache/alloc_calls和free_calls这两个文件只有在缓存创建时就带SLAB_STORE_USER或者开了slub_debugU才有内容。如果你发现这两个文件是空的不要浪费时间直接回去加启动参数重新复现否则接下来查 stack 全是白做。