作者:Jonathan Corbet,2026 年 1 月 15 日
Al Viro 并不经常涉足核心虚拟文件系统(VFS)之外的领域;一旦他有所动作,通常都值得关注。最近,他凭借这组针对 slab 分配器及其部分用户的补丁系列进入了内存管理领域。内核开发者经常会为了微小的优化付出巨大努力,但看到为了在某些内存分配的热点路径(hot path)中避免一次指针解引用而投入如此多的精力,依然是一件非常有趣的事情。
Slab 缓存
内核的 slab 分配器(slab allocator)旨在提供固定大小对象的快速分配。例如,内核使用大量的 dentry 结构(目录项结构)来缓存文件名信息;在撰写本文的系统上,根据 /proc/slabinfo 的报告,目前有超过 800,000 个活跃的 dentry 结构。分配和释放这些结构的请求非常频繁,因此它们的性能至关重要。
slab 分配器提供了一个 kmem_cache_create() 函数,它返回指向新分配并初始化的 kmem_cache 结构的指针。反过来,通过调用 kmem_cache_alloc(),可以使用该指针分配一个符合该特定缓存配置大小的新对象。例如,虚拟文件系统层可以使用 slab 缓存来分配 dentry 结构。slab 分配器将维护一个可用结构的缓存,并根据请求发放;它还会努力将它们优化地布局在从页分配器(page allocator)获得的内存页中。即使是内核中的简单操作也可能涉及多个对象的分配和释放,因此随着时间的推移,人们在优化 slab 分配器方面投入了大量精力。
虽然 slab 缓存可以动态地创建和销毁,但其中有很多缓存是与系统生命周期共存的。例如,dentry 结构的缓存是在系统引导过程中创建的,该缓存的 struct kmem_cache 指针以 dentry_cache 的形式存储在引导完成后设为只读的内存中。需要分配或释放 dentry 结构的代码在编译后将包含 dentry_cache 的地址,该地址可用于获取必须传递给 slab 分配器的 kmem_cache 结构的指针。大多数时候,相对于分配一个新对象的开销,这种额外的解引用成本很小,但据 Viro 所说,对于频繁使用的缓存,它确实有可衡量的影响。
因此,消除这种解引用具有一定的吸引力,而且这应该是可行的。dentry_cache 指针的值是恒定的;一旦设置,它在系统的整个生命周期内都不会改变。所需要的只是在内核二进制文件中出现 dentry_cache 地址的每一个地方,都将其替换为存储在那里的 kmem_cache 结构的实际地址。
运行时常量
事实证明,上述关于如何访问 dentry 缓存 slab 的描述对于当前的内核来说并不完全准确。如果查看 6.19-rc 内核中的 fs/dcache.c,你会发现 slab 指针是这样声明的:
staticstructkmem_cache*__dentry_cache __ro_after_init;
#definedentry_cache runtime_const_ptr(__dentry_cache)指向用于 dentry 结构的 slab 缓存的指针实际上存储在一个名为 __dentry_cache 的变量中;而未加修饰的 dentry_cache 名称是由第二行的 #define 创建的。这种声明序列展示了由 Linus Torvalds 添加到 6.11 内核中的运行时常量(runtime constant)机制。他一直没能抽出时间来记录这个新功能——想必现在这一定排在他待办事项列表的最顶端——所以人们不得不对其进行逆向工程。简而言之,运行时常量完成了上文所述的工作:它们在运行时直接将地址修补(patch)到代码中,从而避免了解引用操作。
要将一个指针设置为运行时常量,第一步是使用如上所示的 runtime_const_ptr() 宏来声明它。该宏返回一个值,通过 #define 将其绑定到代码其余部分用于该指针的名称(在本例中是不带下划线的 dentry_cache)。还有其他宏用于设置运行时常量的值;对于 dentry slab,该常量在 dcache_init() 中使用 runtime_const_init() 进行设置:
__dentry_cache=KMEM_CACHE_USERCOPY(dentry,
SLAB_RECLAIM_ACCOUNT|SLAB_PANIC|SLAB_ACCOUNT,
d_shortname.string);
runtime_const_init(ptr, __dentry_cache);kmem_cache 是通过 KMEM_CACHE_USERCOPY() 分配的,这是一个封装了 kmem_cache_create() 的宏;产生的指针随后被用于设置运行时常量的值。这种初始化将导致内核代码中任何引用 dentry_cache 的指令都直接包含 kmem_cache 指针。这样就消除了额外的解引用;这也是最初添加运行时常量机制的初衷。
虽然看起来问题已经解决了,但这种解决方案在几个方面还存在不足。首先,并非所有架构都支持运行时常量,尽管最重要的架构似乎都支持。但这种机制仅在系统引导过程中有效;一旦系统完全启动,就无法再修改内核文本以反映运行时常量的实际值。这反过来意味着运行时常量不能在可加载模块中使用。
静态 kmem_cache 结构
Viro 决定专门针对 slab 问题,而不是尝试修复运行时常量来解决这些问题。如果在调用者端静态分配结构,那么让内核代码包含指向其所需 kmem_cache 结构的指针就很容易实现,而无需进行运行时代码修补。该结构的地址就变成了编译时常量。即使是可加载模块也能使用这种功能,至少对于在模块内部分配和管理的 slab 是如此。
需要克服的一个小障碍是,struct kmem_cache 的定义对 slab 分配器之外的代码是隐藏的,这有着充分的理由。这将使得在内核的其他地方声明这些结构变得困难。解决问题的关键在于意识到,这段代码实际上只需要分配一些足够容纳 struct kmem_cache 结构的内存。因此,Viro 的补丁集引入了一个新类型 struct kmem_cache_opaque,其定义方式使其与 struct kmem_cache 大小相同,但不会暴露该结构的任何细节。此外还有一个新宏 to_kmem_cache(),它将指向不透明形式结构的指针转换为 slab 子系统预期的常规类型。
通过这些修改,dentry_cache 的声明变为了:
staticstructkmem_cache_opaque__dentry_cache;
#definedentry_cache to_kmem_cache(&__dentry_cache)为了将一个子系统转换为使用静态 kmem_cache 结构,还需要进行一些其他更改。通常对 kmem_cache_create() 的调用变成了带有相同参数的 kmem_cache_setup() 调用。(在 dentry 缓存中,更专业的 KMEM_CACHE_USERCOPY() 宏变成了 KMEM_CACHE_SETUP_USERCOPY())。除此之外,分配和释放对象的代码无需更改即可工作。
为了让这一特性在模块中发挥作用,还需要进行一些额外的铺垫,以确保在删除创建这些缓存的模块之前,静态分配的 slab 缓存清理工作已经完成。在 slab 分配器内部,主要的改动是记录预分配的 kmem_slab 结构,以便 slab 代码不会尝试自行分配或释放它们。静态分配的 slab 缓存也无法与其他缓存合并。
该补丁系列将核心内核和文件系统子系统中的相当一部分缓存转换为了静态变体。目前还没有显示性能提升幅度的基准测试结果。Linus Torvalds 对这个补丁集表示满意,称其在处理这些事务时比 runtime_const 好得多。到目前为止,内存管理开发者还没有发表太多评论。假设他们没有异议,这项工作进入主线(mainline)的道路看起来相对平坦。
LWN 评论概述:
读者对此话题反应热烈,主要观点包括:
静态分配 kmem_cache 结构能有效简化关键路径的代码生成。
讨论了这种方案在不同硬件架构上的通用性以及对内核模块的特殊支持。
部分读者担心静态分配可能导致的内存对齐和缓存合并机制的局限性。
社区普遍认可这种比运行时常量更直接、更易于维护的优化思路。
总体而言,社区对 Al Viro 提出的这种通过静态分配来规避解引用的方案持肯定态度,认为它在保持代码整洁的同时实现了极致的性能优化。
关注了就能看到更多这么棒的文章哦~ 全文完LWN 文章遵循 CC BY-SA 4.0 许可协议。
欢迎分享、转载及基于现有协议再创作~
长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~


452

被折叠的 条评论
为什么被折叠?



