很多做内核开发和性能优化的朋友,第一次啃SLUB分配器源码时,最头疼的不是那些巨型数据结构,而是kmem_cache_alloc()这个入口背后那套“能快则快、不行再慢”的路径选择逻辑。我之前的两篇把SLUB的整体框架和slab生命周期梳理过了,这篇专门聊Object分配——也就是从分配器手里拿一个对象出来,整个过程到底发生了什么。
我在实际项目里吃过分配路径的亏。一次是业务侧高频小对象分配导致单核CPU的cache miss飙升,一次是NUMA环境下跨节点访问引发延迟抖动。最后问题都落在对Object分配逻辑的理解上。所以这篇我不打算只贴源码,而是把从数据结构到快速路径再到慢速路径的完整链路,按我自己阅读和排查问题的顺序讲一遍。就算你暂时不想深入源码,文末的调试手法也足够你在现场定位大部分分配器问题。
1. Object分配在整个SLUB框架里的位置
1.1 SLUB相比SLAB,核心改动在哪
在SLAB时代,每个CPU需要维护大量独立的空闲队列,对象分配和释放的元数据开销很重。SLUB把“空闲对象链表”直接放进slab内部,每个CPU只需要保存一个freelist指针指向“下一个可以分配的对象”。这个设计让分配路径只剩下指针跳转和一次原子比较交换,代价是当本地指针失效时,需要更复杂的慢速路径兜底。
另一个关键改动是SLUB刻意减少了对node锁的依赖。在SLAB里,每次分配都要查node链表,锁竞争在高核数机器上非常明显。SLUB把partial slab按node组织,但大部分情况下用不到node锁——CPU本地就解决了。真正的锁操作只在“本地没有partial可用了,必须去node链表搬货”时才发生。
还有一个容易忽略的细节:SLUB用struct slab复用struct page,一个slab就是一组连续物理页,对象按固定大小切分,对象之间通过内嵌的freepointer串联。也就是说,分配一个对象,本质上就是从这条空闲链上摘掉头节点。把这个问题看透了,后面所有路径逻辑都能顺着理解下来。
1.2 三层快速路径的核心思想
我把Object分配理解成三层结构,平时读代码也是按这个层次去套。
第一层是CPU本地快速路径。kmem_cache_cpu里保存了当前正在被该CPU独占使用的一个slab,以及对空闲对象的直接指针。分配就是从c->freelist拿一个对象,然后更新指针指向下一跳。这一层几乎没有任何锁,只靠this_cpu_cmpxchg_double保证一致性。
第二层是partial慢速路径。当前slab用完、或者node不匹配、或者slab被别的CPU释放了,那就得去kmem_cache_node的partial链表找还有空闲对象的slab。这里可能涉及node锁。
第三层是新slab路径。partial也空了,就只能向页分配器要新的物理页,初始化成一个新slab,再把对象串好返回一个。
为什么要把层次设计得这么分明?核心原因是访问代价差太多。第一层操作的是本CPU缓存里热得发烫的数据,指针本身就在cache line里;第二层涉及跨CPU、跨node的链表;第三层会触发伙伴系统分配和页表操作。越往后越贵,所以设计上尽可能让大多数分配停在第一层。我做过一次简单统计,同一台机器上快速路径单次分配耗时个位数纳秒量级,而走一次新slab路径可能要到微秒级,差距是上百倍的。
2. 四个关键数据结构,搞懂它们就懂了一半
2.1 struct kmem_cache:分配器的“总装线图纸”
kmem_cache描述的是“如何制造和分配同一类对象”的完整信息。我通常先看几个字段:objects和oo决定了一个slab里能放多少个对象、需要几页物理内存;size是对象实际占用的字节数;offset是freepointer在对象里的偏移。
这里有个容易踩的坑:size通常不等于调用者请求的对象大小。内核会对齐、可能加红区(redzone)、加校验尾巴,还要保证freepointer能安全内嵌。比如你创建cache时请求96字节,内核可能实际给你一个更大的size布局。这个差异在估算内存占用时特别容易算错,所以看/proc/slabinfo时如果发现objsize和真实对象大小对不上,不用奇怪,这是正常的设计结果。
kmem_cache里还有random字段,用于freepointer混淆;node[n]数组在NUMA下每个节点一个kmem_cache_node。还有一个组合min和oo,当伙伴系统无法按最优阶数分配时,可以退而求其次用更小的阶数创建slab,保证分配不会轻易失败。
2.2 struct kmem_cache_cpu:每CPU最热的对象池
struct kmem_cache_cpu虽然只有几个字段,但它是Object分配的“第一现场”:
struct kmem_cache_cpu { void **freelist; /* 指向下一个空闲对象 */ unsigned long tid; /* 事务ID,检测并发修改 */ struct slab *slab; /* 当前CPU正在使用的slab */ struct slab *partial; /* 本地partial链表 */ unsigned int cpu; unsigned int random; };freelist这个指针本身就是“下一个要交给调用者的对象”。它和slab并不总是同时有效:比如c->slab还在,但freelist已经为NULL,说明这个slab对象用完了,快速路径必须撤退。c->partial则是CPU本地攒下来的一批“还剩空闲对象”的slab,从它上面取slab比从node链表快很多,因为不需要拿node锁。
tid字段是整个无锁机制的关键。每次成功分配,tid都会递增;如果cmpxchg失败,说明期间有别的CPU或中断改了freelist,分配就得重新读一遍现场。这个“乐观并发”思路在后面第四节会展开讲。
2.3 struct slab与kmem_cache_node:货架和仓库
struct slab代表一个物理上连续、被某个cache切分成对象的“货架”。它复用struct page的许多字段,关键内容包括:freelist(指向货架内第一个空闲对象)、inuse(已分配对象数量)、objects(总对象数)、frozen(是否被某个CPU锁定)。frozen这个标记很有意思,它表示当前slab正在被某个CPU独占使用。被冻结的slab不会出现在node的partial链表里,只有当它被释放时,才可能解除冻结并回到partial链。
kmem_cache_node则是按NUMA节点组织的“仓库”,核心是一个受list_lock保护的partial链表,以及nr_partial计数。慢速路径里,当CPU本地没有可用slab时,就要从仓库里取一块到本地。这个链表被所有CPU共享,所以并发高时可能成为瓶颈,这也是SLUB尽量让每次分配都停在本地的原因。
2.4 藏在对象里的freelist指针
SLUB去掉了SLAB的独立空闲队列,转而把“链表下一跳”直接存进对象内存本身,这就是freelist指针。每个空闲对象在固定偏移处存着一个指向下一个空闲对象的指针(freepointer)。分配时,读了头对象的freepointer,就能把它作为新的freelist。
这个设计省掉了额外的元数据内存,但也带来了安全风险:对象内容里藏指针,一旦被攻击者利用UAF写入,可能篡改freelist链表。所以内核提供了CONFIG_SLAB_FREELIST_HARDENED,对freepointer做异或混淆。开启后,对象中保存的不再是裸指针,而是经过random值和对象地址混淆过的编码值。调试时很容易在这里看走眼,直接按普通指针解引用就会崩。
理解了“分配对象就是删除空闲链表头节点”这个本质,再看后面的代码,你会发现每一步其实都在围绕“如何安全、高效地摘掉头节点并更新指针”做文章。
3. Object分配全流程拆解:从快速路径到新slab
3.1 快速路径:绝大多数分配在这里发生
分配入口即kmem_cache_alloc(),最终收敛到slab_alloc_node()。先看它最核心的逻辑:
redo: tid = this_cpu_read(s->cpu_slab->tid); c = this_cpu_ptr(s->cpu_slab); slab = READ_ONCE(c->slab); object = c->freelist; if (unlikely(!slab || !node_match(slab, node) || !object)) { object = ___slab_alloc(s, gfpflags, node, addr, c); if (!object) return NULL; slab = c->slab; } if (unlikely(!pfmemalloc_match(slab, gfpflags))) { object = ___slab_alloc(s, gfpflags, node, addr, c); /* 处理紧急内存场景 */ } if (unlikely(!this_cpu_cmpxchg_double( s->cpu_slab->freelist, s->cpu_slab->tid, object, tid, get_freepointer(s, object), next_tid(tid)))) { goto redo; } prefetch_freepointer(s, object);读代码的关键在于理解:第一次读c->freelist和c->slab是无锁的,所以拿到的值可能是“过期的”。那怎么发现过期?靠cmpxchg_double。它一次性比较freelist是否还是object、tid是否还是tid,如果是,就把freelist更新为object的下一跳、tid加一。这个操作是原子的。如果比较失败,说明有别人动过,回到redo重新读。
还有一个细节:prefetch_freepointer是软件预取。分配完这个对象后,很可能马上要分配下一个对象,提前把下一跳对象的指针所在cache line拉进缓存,能明显减少后续分配耗时。这种细节就是SLUB能快的原因,也是读内核源码比较有意思的地方。
3.2 慢速路径入口:___slab_alloc的决策树
当快速路径的条件不满足,比如c->slab为NULL、node不匹配、freelist为空,控制流进入___slab_alloc()。我把它想象成一个决策树,按代价从低到高排列:
- 如果
c->slab还在、freelist不为空,只是node不匹配,那优先在当前slab或者本地partial上想办法,因为换到别的node的slab成本很高。 - 如果
c->slab已经用光(freelist为NULL),先看c->partial,从本地partial链表弹出一个slab。 - 本地partial也没有,则访问
kmem_cache_node,在list_lock保护下从node的partial链表取一个slab。 - node partial也没有,就调用
new_slab()向伙伴系统申请新的物理页,构建新slab。
每步之间还有各种“意外处理”。比如走到某一步发现c->slab已经变了,可能是中断或者其它CPU动了本地状态,那就立刻调整,能少做就少做。这里容易误解的一点:___slab_alloc第一步其实会再次检查c->slab的freelist。为什么要再检查?因为快速路径是带着旧的c->slab进来的,这个slab可能在判断之后已经被释放并重新初始化。慢速路径开始时要重新确认现场,避免拿一个已经“换主”的slab。
3.3 从partial链表补充对象
从partial补充对象是慢速路径里“最划算”的选择,因为slab是现成的,对象也已经初始化好了。从c->partial取slab的核心是:把当前CPU的partial指针往后移一块,然后把这块slab的freelist交到c->freelist,同时把c->slab指向它。
这里面的关键点在于:c->partial是per-cpu的链表,操作它不需要全局锁,只有当前CPU在碰它,所以代价很低。相比之下,从node partial链表取slab就麻烦多了,必须持有n->list_lock,还要修正nr_partial统计。SLUB之所以保留两级partial,就是想让高频路径尽量停留在不需要锁的那一级上。
我在看代码时还注意到了一个细节:从node partial取slab时,分配器不一定会把slab上的freelist直接用光,而是可能先设置frozen状态,把它变成当前CPU独占的slab。这个状态切换决定了后续释放时,它应该回node partial还是继续留在CPU本地。
3.4 新slab的创建和对象链表初始化
partial链表全空了,就进入最重的路径——new_slab()。它首先根据oo找到合适的阶数(order),从伙伴系统分配页面。拿到页面后,把页面初始化成struct slab,然后计算对象起始地址,把对象用freelist指针串起来。
初始化阶段最核心的操作就是“建链表”:从第一个对象开始,在固定偏移处依次写入下一个对象的地址,最后一个对象的freepointer置空。这个动作只需要做一次,建好之后分配器摘对象就非常便宜了。新slab创建之后,还要决定是把它设置为当前CPU的c->slab,还是先扔进partial链。通常情况下,新slab会直接成为CPU本地使用的slab,这样它可以连续服务大量分配请求。
如果伙伴系统在最优阶数上分配失败,SLUB会退而求其次,用min阶数再试。这就是oo和min两个值的意义。这种情况下创建的slab对象数量少,但总比分配失败好。我在内存压力很大的机器上见过这种降级,同时也能看到分配延迟明显上升。
3.5 批量分配kmem_cache_alloc_bulk的妙处
频繁调kmem_cache_alloc其实是有开销的,每次都要做两次读、一次cmpxchg_double,还可能踩中慢速路径。kmem_cache_alloc_bulk一次分配多个对象,可以摊薄这些固定开销。
批量分配的策略是:先在当前c->freelist上尽量多摘几个,摘到不够了,再进入慢速路径补充。它尽量在同一个slab的freelist上连续摘取,所以对freelist的更新是连续进行的,硬件预取也更友好。比如网络协议栈的skb分配、文件系统dentry分配,用批量接口后吞吐提升非常明显。
这个接口有一个注意点:它分配的对象有可能来自不同的slab或node。如果你要求所有对象在同一node上,需要额外检查。我看过有些驱动直接用它分配一批内存,结果没检查node,跨node访问性能惨不忍睹。分配性能和NUMA局部性之间的平衡,只能靠实际场景去调。
4. 设计背后的权衡和细节
4.1 tid事务ID:无锁快速路径的定心丸
tid是我觉得整个SLUB设计里最巧妙的地方。它本质是一个单调递增的事务号,每次成功修改freelist都会变化。读端拿到的freelist和tid必须是一对一致快照。
在无锁读之后,做cmpxchg_double时比较的是“freelist值 + 当前tid”。如果另一个CPU或者中断期间动了freelist,那么那个动作者一定也更新了tid,两者不匹配,比较就会失败。这样快速路径就能安全重试,不需要任何锁。这就是典型的乐观并发技巧:用比较交换代替加锁,冲突了就重新读,反正大部分时间不会冲突。
tid不是无限长的,它最终会回绕。内核对此做了处理,保证回绕后依然可以检测出不一致。这个细节读源码时值得留意,也是面试里常问的一个点。回绕处理的核心思路是让新旧tid的差值在比较时仍有意义,不因为回绕而误判为“一致”。
4.2 为什么要有node_match检查
node_match的作用是确保当前CPU使用的slab属于调用者请求的NUMA节点。如果在快速路径里忽略node,分配器很可能递给调用者一个“本地CPU上用着顺手但内存物理上在远端节点”的对象。在NUMA机器上,远端内存访问延迟可能高出数倍,对性能敏感程序是致命伤。
所以快速路径虽然快,但node_match(slab, node)不通过时,宁可进入慢速路径去别的node找匹配slab。注意,当前CPU自己的c->slab如果不是目标node,那么即便它还有空闲对象,分配器也不直接用它;它会先把这块slab让出去,再找符合node的slab。
这背后是一个取舍:本CPU局部性 vs 内存访问的NUMA局部性。SLUB默认更倾向于NUMA局部性,因为跨node访问的代价远大于一次慢速路径。这个策略也被很多性能优化案例验证过,尤其在数据库和分布式缓存这类内存敏感型应用上,NUMA意识几乎是必修课。
4.3 指针混淆与slub_debug的安全博弈
freelist指针藏在对象内存里,这个优化是一把双刃剑。攻击者如果能把伪造的freelist写进对象,就可能诱导分配器返回任意地址的内核对象。CONFIG_SLAB_FREELIST_HARDENED就是对抗这个问题的:把freepointer用cache的random值和对象地址做混淆。攻击者即使能写对象,也很难制造出合法的编码指针。
而slub_debug是调试视角的另一套机制。它把对象布局重新排列,在对象前后加redzone,在空闲对象填充poison值,还能记录分配/释放的调用栈。这些能力能精准定位UAF、越界写、重复释放。但它和性能是冲突的,所以生产环境通常只对怀疑的特定cache开启,比如:
slub_debug=FZPU,kmalloc-128这个参数只给kmalloc-128启用完整调试,不影响其它cache的性能。关于这些debug选项的字母含义,下面实战部分再展开。
5. 实战:观察Object分配路径的手段
5.1 /proc/slabinfo与sysfs接口怎么读
拿到一台出问题的机器,第一步我会看/proc/slabinfo:
cat /proc/slabinfo | head -20重点关注几个列:active_objs(当前活跃对象)、num_objs(总对象)、objsize、objperslab、pagesperslab。如果某个cache的active_objs不断增长而num_objs也在涨,说明对象只分配不释放;如果num_objs稳定,但/sys/kernel/slab/<cache>/下的partial数值很大,说明slab碎片化严重,分配路径可能频繁走node partial。
/sys/kernel/slab/<cache>/下面有很多文件,比如slab_size、object_size、partial、cpu_slabs。开启CONFIG_SLUB_STATS后,会有更细的统计:分配次数、慢速路径次数、node hit/miss等。这些统计是定位“到底走了多少次慢速路径”最直接的依据。
5.2 用ftrace和bpftrace抓住慢速路径
慢速路径出现一次不一定是问题,但频繁出现就要数一数。我常用的手法是挂tracepoint和kprobe:
# 观察所有kmem_cache_alloc的调用,带调用栈 perf trace -e kmem:kmem_cache_alloc --max-stack=8 # 用bpftrace统计慢速路径___slab_alloc的调用次数和调用栈 bpftrace -e 'kprobe:___slab_alloc { @[kstack] = count(); }'如果___slab_alloc的调用频率和业务分配频率接近,说明快速路径基本失效。再进一步挂kprobe:new_slab,如果它也被频繁触发,说明partial储备不足,分配器在持续向伙伴系统要页。这个结论往往直接指向cache配置不合理或内存紧张。
我还习惯按进程维度统计,快速看清是谁在制造压力:
bpftrace -e 'kprobe:kmem_cache_alloc { @[comm] = count(); }'几十秒就能知道哪个进程在疯狂触发对象分配,这对线上问题初筛特别有用。
5.3 内核参数slub_debug的正确打开方式
slub_debug是在内核启动参数里配置的。字母含义要记牢:
- F:Sanity checks,做基本一致性检查
- Z:Redzoning,对象前后加红色区域,检测越界
- P:Poisoning,空闲对象填毒药值,检测UAF
- U:Track,记录分配和释放的调用栈
- T:Trace,启用tracepoint
- A:Validate,在slab操作时校验整个slab
组合使用可以精确打击问题,比如怀疑某个cache有UAF就slub_debug=U,<cache>;怀疑越界写就slub_debug=FZ,<cache>。注意,开启后对象布局会变化,/proc/slabinfo里的objsize也会变大,属于正常现象。
还可以通过/sys/kernel/slab/<cache>/下的对应接口在运行中做动态开关,但通常重启加启动参数更简单可控。生产环境建议只对可疑cache开启,全量开debug会把性能拖垮。
5.4 一次快速路径失效的定位记录
分享一次我自己的排障记录。某个系统服务延迟突然升高,CPU load不高但上下文切换暴涨。用bpftrace统计后,发现___slab_alloc每秒调用几万次,而正常情况每秒只有几百次。继续观察new_slab,发现它也在高频触发,说明slab严重不足。
再查/proc/slabinfo,对应cache的num_objs稳定,但active_objs几乎等于num_objs,就是“几乎全满”。这说明业务侧没有泄漏,是并发分配峰值太高,把cache的slab池打穿了。解决方案不是改分配器,而是看cache的tunables参数(比如/proc/slabinfo里的limit和batchcount),或者优化业务侧的对象复用,降低释放延迟。这次排障让我彻底明白,分配路径优化不能只看分配侧,释放侧和cache参数同样重要。
6. 常见问题和避坑清单
6.1 快速路径频繁失效怎么办
如果统计发现快速路径命中率低,先别急着怀疑SLUB实现。按顺序排查:
- CPU迁移频繁。线程在不同CPU间跳动,
kmem_cache_cpu的本地slab无法复用,每次分配都要重建本地状态。此时优先给线程绑核或优化调度。 - 中断/软中断抢占。中断上下文也在做分配操作,它们会抢用同一个CPU的
kmem_cache_cpu,导致普通进程分配时的tid冲突和快速路径重试。 - cache对象几乎耗尽。此时
freelist经常为空,必然走慢速路径,这是容量问题不是路径设计问题。 - NUMA node不匹配。如果调用方请求的node跟当前CPU的本地slab不同,快速路径也会失效,需要检查分配请求的node参数是否合理。
我见过有团队为了让快速路径命中,把所有小对象分配都固定到某个CPU上,结果那个CPU成为瓶颈。这种“矫枉过正”不值得学。分配器设计本来就是在局部性和均衡性之间找平衡,人为打破平衡只会从一个坑跳进另一个坑。
6.2 对象“泄漏”的系统排查思路
对象数量增长不一定是泄漏。先用/proc/slabinfo看active_objs趋势,如果持续增长且不回落,再定位是哪个调用路径分配不放。
我通常这样定位:先开启slub_debug=U(Track),然后查看对应cache的/sys/kernel/slab/<cache>/alloc_calls和free_calls,里面会记录活跃的分配/释放调用栈。有了调用栈,基本就能判断是忘释放还是真泄漏。
如果对象是分配给内核服务使用的,比如kmalloc-256被某个驱动大量持有,还要结合驱动自身的统计。不要一看到slabinfo上涨就怀疑SLUB,分配器只是按照调用方的请求提供服务,真正的问题通常都在调用方。分配器能做的只是帮你把“谁在分配、从哪个路径分配”暴露出来。
6.3 中断上下文和per-cpu变量的恩怨
在中断上下文里调用kmem_cache_alloc必须用GFP_ATOMIC,这一点很多新手容易忽略。但还有一个更隐蔽的问题:中断可能会打断正常的分配过程,也就是在slab_alloc_node的快速路径中间插入一次新的分配操作。这会让第一次分配时的cmpxchg_double失败,然后redo重读——性能受影响,但正确性没问题。
真正要小心的是:如果中断里用了同一个cache,并且恰好发生在___slab_alloc修改kmem_cache_cpu的中间,可能让外层的分配路径观察到不一致的状态。但这些场景SLUB本身都能处理。开发者真正要养成的好习惯是:中断上下文尽量少分配对象,能用栈、per-cpu变量或预先分配好的内存,就不要临时申请。
还有一个经验:在多线程场景下,如果每个线程频繁分配又释放同一个cache对象,尽量做到“谁分配谁释放”。一旦对象跨CPU释放,SLUB要把slab在各CPU之间搬运,partial链表的锁竞争和cache line bouncing都会冒出来。这不是SLUB设计缺陷,而是调用模式没有匹配分配器的局部性假设。
写到这里,Object分配的主线已经基本清晰了。我个人最大的体会是:读SLUB源码不能用“从头到尾一行行啃”的方式,最好带着性能问题去读,先看快速路径的原子操作,再理解慢速路径的几层抉择,最后用bpftrace这类工具验证自己的理解。很多看起来“多余”的判断,比如再检查一次c->slab、node_match、pfmemalloc_match,都是为了处理并发和异常状态,去掉任何一个都会让分配器在特定场景下翻车。
如果后续要把这篇延伸下去,我建议把释放侧的slab_free逻辑一并读掉,分配和释放合在一起看,才会明白为什么SLUB要在cpu、node、partial之间反复搬运slab。到时候再看kmem_cache_shrink、kmem_cache_reap这些回收路径,整个slab生命周期就彻底闭环了。