4. Slab分配与释放路径:kmem_cache_alloc()、kmem_cache_free()的完整路径
Slab分配器的核心价值在于其多级缓存架构。当内核频繁分配和释放相同大小的对象时,完全依赖全局链表会导致严重的锁竞争和性能瓶颈。本章将深入剖析 kmem_cache_alloc() 和 kmem_cache_free() 的完整执行路径,揭示从本地缓存(CPU缓存)、共享缓存(Node缓存)到Slab链(全局链表)的逐级分配与释放策略。
4.1 分配路径:kmem_cache_alloc() 的逐级查找
分配操作遵循“从近到远、从快到慢”的原则,优先使用无锁或低锁开销的缓存层级。完整的分配路径如下图所示:
4.1.1 步骤1:per-CPU 本地缓存 (array_cache)
每个CPU都拥有一个独立的 array_cache 结构,这是分配路径的第一站。由于是CPU私有,访问时不需要任何锁,仅通过关闭本地中断来保证原子性。
- 操作:检查
array_cache->avail是否大于0。如果大于0,直接从数组尾部取出一个对象指针,avail--,然后返回。 - 性能:这是最快的路径,通常耗时在几十纳秒级别。
- 失败条件:当
avail == 0时,表示本地缓存已耗尽,需要从下一级缓存“进货”。
关键数据结构:array_cache
struct array_cache {
unsigned int avail; /* 当前可用对象数量 */
unsigned int limit; /* 缓存容量上限 */
unsigned int batchcount; /* 批量转移的个数 */
void *entry[]; /* 对象指针数组 */
};
4.1.2 步骤2:共享缓存 (shared cache)
当本地缓存为空时,分配器会尝试从当前NUMA节点内的共享缓存中获取一批对象。共享缓存由同一NUMA节点上的所有CPU共享,因此访问时需要获取 cache->spinlock。
- 批量转移:从共享缓存中取出
batchcount个对象,填充到本地缓存中。然后从本地缓存中返回一个对象给调用者。 - 设计意图:减少对全局Slab链的访问频率,将锁竞争控制在NUMA节点级别。
- 失败条件:如果共享缓存也为空,则必须进入全局Slab链进行分配。
4.1.3 步骤3:Slab 链 (partial / full 链表)
这是分配路径的最后防线。分配器需要获取 cache->slab_mutex(或早期内核中的大锁),然后遍历Slab链表。
| Slab状态 | 链表名称 | 分配策略 |
|---|---|---|
| 部分空闲 | slabs_partial |
优先遍历,从中找到第一个有空闲对象的Slab,分配对象。 |
| 完全空闲 | slabs_free |
当 slabs_partial 为空时,从 slabs_free 中取出一个Slab,重新初始化后使用。 |
| 无可用Slab | — | 调用 kmem_getpages() 从伙伴系统分配新的物理页面,创建新的Slab。 |
性能提示: 步骤3涉及全局锁和可能的页面分配,是分配路径中最慢的一环。如果系统监控显示
slabs_partial 链表的遍历次数过高,通常意味着缓存层级配置不合理或对象生命周期管理需要优化。
4.2 释放路径:kmem_cache_free() 的逐级回退
释放路径是分配路径的逆过程,同样遵循“从近到远”的原则,但方向相反:优先将对象归还到本地缓存,只有当本地缓存已满时,才向下一级缓存或Slab链“回退”。
4.2.1 步骤1:归还到本地缓存
释放操作首先检查当前CPU的 array_cache 是否还有空闲位置(即 avail < limit)。
- 成功:直接将对象指针放入
entry[avail],然后avail++。这是无锁操作,速度极快。 - 失败:如果本地缓存已满(
avail == limit),则不能继续放入,需要触发“回退”机制。
4.2.2 步骤2:回退到共享缓存
当本地缓存已满时,分配器不会简单地丢弃对象,而是执行批量回退:
- 从本地缓存中取出
batchcount个对象。 - 将这些对象转移到共享缓存中。
- 然后将当前要释放的对象放入本地缓存腾出的空位中。
这种“先腾挪、再放入”的策略,保证了本地缓存始终有空间接收新释放的对象,同时将批量对象推向更全局的缓存层级。
4.2.3 步骤3:回退到 Slab 链
如果共享缓存也已满,或者该缓存没有配置共享缓存,则释放操作最终会走到Slab链:
- 获取
cache->slab_mutex。 - 根据对象地址,通过
virt_to_page()找到其所属的struct page,进而找到所属的Slab。 - 将对象标记为空闲,并更新Slab的内部空闲链表。
- 如果Slab变为完全空闲(所有对象都已释放),且缓存配置允许回收,则可能将Slab释放回伙伴系统。
避坑指南: 释放路径中,最容易被忽视的性能陷阱是“缓存抖动”。如果
batchcount 设置过大,频繁的批量回退会导致共享缓存和Slab链的锁竞争加剧。反之,如果 batchcount 过小,本地缓存会频繁触发回退,增加路径长度。通常建议 batchcount 设置为 limit / 2。
4.3 分配与释放策略对比总结
| 缓存层级 | 分配策略 | 释放策略 | 锁开销 | 典型延迟 |
|---|---|---|---|---|
| per-CPU 本地缓存 | 直接取出,avail-- | 直接放入,avail++ | 无锁(仅关中断) | ~10-30 ns |
| 共享缓存 (Node级) | 批量转移到本地缓存 | 批量从本地缓存接收 | 获取 Node 锁 | ~100-500 ns |
| Slab 链 (全局) | 遍历 partial/free 链表 | 归还对象到 Slab | 获取 Cache 锁 | ~1-10 μs |
| 伙伴系统 (新Slab) | 分配新页面 | 释放页面 | 获取 Zone 锁 | ~10-100 μs |
核心设计哲学: Slab分配器的分配与释放路径,本质上是一个三级缓存 + 批量操作的经典架构。它通过将热对象保持在CPU本地,冷对象推向全局,在内存利用率和访问性能之间取得了精妙的平衡。理解这条路径,是进行Slab性能调优和内存调试的基础。