4. Slab分配与释放路径:kmem_cache_alloc()、kmem_cache_free()的完整路径

Slab分配器的核心价值在于其多级缓存架构。当内核频繁分配和释放相同大小的对象时,完全依赖全局链表会导致严重的锁竞争和性能瓶颈。本章将深入剖析 kmem_cache_alloc() 和 kmem_cache_free() 的完整执行路径,揭示从本地缓存(CPU缓存)、共享缓存(Node缓存)到Slab链(全局链表)的逐级分配与释放策略。

4.1 分配路径:kmem_cache_alloc() 的逐级查找

分配操作遵循“从近到远、从快到慢”的原则,优先使用无锁或低锁开销的缓存层级。完整的分配路径如下图所示:

kmem_cache_alloc() 分配路径流程图 kmem_cache_alloc() 步骤1:检查 per-CPU 本地缓存 (array_cache) 无锁操作,快速命中 本地缓存有可用对象? ✅ 直接返回对象 ❌ 本地缓存为空 步骤2:检查共享缓存 (shared cache) 需要获取 Node 锁 共享缓存有可用对象? 批量转移到本地缓存 步骤3:遍历 Slab 链 (partial/full) 需要获取 Cache 锁 最终:从 Slab 分配或创建新 Slab

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链“回退”。

kmem_cache_free() 释放路径流程图 kmem_cache_free() 步骤1:尝试放入 per-CPU 本地缓存 检查 avail < limit 本地缓存未满? ✅ 放入本地缓存 ❌ 本地缓存已满 步骤2:回退到共享缓存 批量转移 batchcount 个对象到共享缓存 步骤3:回退到 Slab 链 将对象归还给其所属的 Slab

4.2.1 步骤1:归还到本地缓存

释放操作首先检查当前CPU的 array_cache 是否还有空闲位置(即 avail < limit)。

  • 成功:直接将对象指针放入 entry[avail],然后 avail++。这是无锁操作,速度极快。
  • 失败:如果本地缓存已满(avail == limit),则不能继续放入,需要触发“回退”机制。

4.2.2 步骤2:回退到共享缓存

当本地缓存已满时,分配器不会简单地丢弃对象,而是执行批量回退:

  1. 从本地缓存中取出 batchcount 个对象。
  2. 将这些对象转移到共享缓存中。
  3. 然后将当前要释放的对象放入本地缓存腾出的空位中。

这种“先腾挪、再放入”的策略,保证了本地缓存始终有空间接收新释放的对象,同时将批量对象推向更全局的缓存层级。

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性能调优和内存调试的基础。