☰
SLUB分配器核心:kmem_cache_alloc对象分配全路径解析
2026/10/22 9:07:33 网站建设 项目流程

我在排查内存“只涨不降”类问题的时候,大多数时间其实都耗在/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->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:

  1. c->partial也空了,本 CPU 私有库存彻底耗尽;
  2. 调用方显式指定了 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会得到三类非常典型的内核报错:

  1. Freepointer corrupt:对象的 freepointer 解码后不在 slab 区域内,通常是相邻对象越界写造成的;
  2. Object already free / double free:对象不在 freelist 上却再次被释放,说明有两条释放路径指向了同一个对象;
  3. 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 我实际排障时的一套流程

先说结论:读源码是“知道它能干什么”,排障靠的是“观察它实际干了什么”。我现在的习惯是:

  1. 先记录/sys/kernel/slab/<cache>/slabs和/proc/slabinfo,区分是 slab 数量膨胀还是单个 slab 内对象膨胀;
  2. 用 tracepoint 抓 10 秒分配/释放样本,统计调用栈;
  3. 确认目标 cache 后,在测试机重启加slub_debug=PZ复现,用 dmesg 里的对象地址和偏移定位越界源;
  4. 最后才考虑调cpu_partial、slub_max_order这类参数去缓解,而不是根治。

最后再分享一个小经验:/sys/kernel/slab/<cache>/alloc_calls和free_calls这两个文件,只有在缓存创建时就带SLAB_STORE_USER或者开了slub_debug=U才有内容。如果你发现这两个文件是空的,不要浪费时间,直接回去加启动参数重新复现,否则接下来查 stack 全是白做。

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

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

立即咨询