我在排查内存“只涨不降”类问题的时候,大多数时间其实都耗在/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 partial | CPU 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->freelist:
object = c->freelist; if (likely(object)) { c->freelist = get_freepointer(s, object); return object; }逻辑简单到不像话,但工程难点全在get_freepointer上。对象在内存里是会被人为破坏的,freepointer 也是内核漏洞利用的高价值目标,所以现代内核不会把指针裸存。
2.2 FREELIST_HARDENED 之后的解码公式
当内核开了CONFIG_SLAB_FREELIST_HARDENED,freelist 指针写入前会做一次混淆,读取时再还原:
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 = 1:slab 有明确的 CPU 主人。要么是当前正在往外发的c->slab,要么挂在某个 CPU 的 partial 链表上。其他 CPU 往这个 slab 里 free 对象时,不能直接改slab->inuse计数,只能走延迟路径,等主人重新结算。frozen = 0:slab 挂在 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_partials
struct 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->freelist:
load_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 partial:
c->partial也空了,本 CPU 私有库存彻底耗尽;- 调用方显式指定了 NUMA 节点,而当前 CPU 手头的 slab 不属于目标节点。
struct kmem_cache_node里维护着该节点所有“非冻结、且有空闲对象”的 slab:
struct 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_slab
node partial 也没有的话,只能向 buddy 分配器要全新的物理页。入口是new_slab()->allocate_slab()。第一步是决定“要多少页、每个 slab 装多少对象”,这就是calculate_order()的活。
它会参考三个启动参数:
slub_min_objects:期望每个 slab 至少装多少个对象,未手动设置时会按 CPU 数算一个默认值;slub_max_order:允许的最大 order,默认一般到 3,也就是最多一次拿 8 个物理页(32KB);slub_min_order:允许的最小 order。
对每个候选 order,算出能装多少对象,再结合内存浪费比例选一个折衷值。结果以oo(order + 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,对象会被填上0x6b(POISON_FREE),对象尾部再放一个0xa5(POISON_END)。以后释放时检测这些字节是否还在,就能很快发现 use-after-free; - 开了
init_on_alloc=1,每个对象在返回前会被memset清零,防止敏感信息跨对象泄露; - 开了
init_on_free=1,释放时清零,防止被读取还热乎的数据。
这些初始化会直接作用在快路径的返回语句附近。这也是为什么很多发行版安全加固内核里,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 或 1 |
slub_cpu_partial | 限制每 CPU partial slab 数量 | 内存紧张时从默认 30 降到 5 左右 |
slub_nomerge | 禁止 kmalloc 缓存合并 | 排查跨缓存踩踏时用,平时不开 |
slub_debug=FZPU | 开启一致性检查、红区、毒化、用户追踪 | 只在上线前的测试环境开 |
其中slub_debug=U会把调用栈信息写进每个对象,让对象大小和缓存数量都上涨,性能下降也很明显,绝对不适合生产长期开。
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_debug=PZ复现,用 dmesg 里的对象地址和偏移定位越界源; - 最后才考虑调
cpu_partial、slub_max_order这类参数去缓解,而不是根治。
最后再分享一个小经验:/sys/kernel/slab/<cache>/alloc_calls和free_calls这两个文件,只有在缓存创建时就带SLAB_STORE_USER或者开了slub_debug=U才有内容。如果你发现这两个文件是空的,不要浪费时间,直接回去加启动参数重新复现,否则接下来查 stack 全是白做。