[TOC]
mm/shrinker.c 内核缓存收缩器(Kernel Cache Shrinker) 响应内存压力的回调机制
历史与背景
这项技术是为了解决什么特定问题而诞生的?
这项技术以及其实现的“收缩器”(Shrinker)框架,是为了解决Linux内核中一个根本性的资源管理问题:如何在一个统一的框架下,回收由各种不同内核子系统所占用的内存缓存。
- 内核缓存的多样性:Linux内核不仅仅有用于缓存文件数据的页面缓存(Page Cache)。还有许多其他重要的缓存,例如用于加速路径查找的dcache、用于缓存文件元数据的inode缓存,以及Slab/Slub分配器自身为内核对象维护的缓存等。
- 缺乏统一回收接口:当系统物理内存(RAM)不足时,内存管理(MM)子系统需要释放一些内存。对于页面缓存,它有自己复杂的LRU(最近最少使用)算法来回收。但对于dcache、inode cache等“非页面缓存”的内存,MM子系统本身并不知道它们的内部结构,也不知道哪些对象是“可回收的”(例如,一个未被使用的dentry)。
- 解耦的需求:在没有Shrinker框架的情况下,内存管理代码将不得不硬编码对dcache、inode cache等特定子系统的了解,以便调用它们的清理函数。这会造成紧密的耦合,使得代码难以维护,并且无法支持新的内核子系统添加自己的缓存回收逻辑。
Shrinker框架的诞生就是为了解决这个问题。它提供了一个通用的、基于回调的“插件”机制。任何内核子系统只要实现了自己的缓存,就可以向MM子系统注册一个shrinker,从而参与到全局的内存回收过程中。
它的发展经历了哪些重要的里程碑或版本迭代?
Shrinker机制是随着Linux内存管理系统的成熟而逐步完善的。
- 早期概念:内核很早就有了回收dentry和inode缓存的机制,这可以看作是Shrinker思想的雏形。
- 标准化接口:
mm/shrinker.c的出现,将这个过程标准化为一个正式的框架。register_shrinker()和unregister_shrinker()API的确立,使得任何子系统都可以动态地加入或退出内存回收体系。 - 引入两阶段回调 (
count/scan):早期的实现可能只有一个简单的“shrink”回调。现代的Shrinker框架演进为一个更智能的两阶段过程:count_objects:一个快速的、通常无锁的回调,用于估算可回收对象的数量。scan_objects:一个较重的回调,负责实际扫描和释放对象。
这种分离使得MM子系统可以先“探查”所有缓存的“胖瘦”,然后根据内存压力和每个缓存的可回收对象数量,按比例地、公平地分配回收任务。
- NUMA和Cgroup感知:为了适应现代多节点(NUMA)服务器和容器化(cgroups)环境,Shrinker框架被扩展为NUMA和内存cgroup感知的。这意味着内存回收可以更具针对性,只在内存压力高的NUMA节点或cgroup内部触发其关联的shrinker,提高了回收的效率和隔离性。
目前该技术的社区活跃度和主流应用情况如何?
Shrinker是Linux内存管理中一个基础、稳定且至关重要的组成部分。
- 社区活跃度:其核心框架非常稳定。相关的社区活动主要集中在:1) 新的内核子系统(如新的网络协议栈或驱动)注册自己的shrinker;2) 对现有shrinker(特别是VFS的)进行性能调优,使其在
scan_objects阶段更高效。 - 主流应用:它是所有主要内核缓存进行自我清理的标准方式。
- VFS:最重要的使用者。
fs/dcache.c和fs/inode.c都注册了shrinker,用于在内存压力下修剪未使用的dentry和inode对象。 - Slab/Slub分配器:Slab分配器自身也注册了shrinker,用于将完全空闲的slab页面释放回页分配器。
- XArray/Radix Tree:这些内核中广泛使用的数据结构,也提供了shrinker来回收其内部节点所占用的内存。
- VFS:最重要的使用者。
核心原理与设计
它的核心工作原理是什么?
mm/shrinker.c的核心是一个“发布-订阅”模型,MM子系统是发布者(发布内存压力事件),各种内核缓存是订阅者(订阅并响应事件)。
- 注册(Subscription):一个希望参与内存回收的内核子系统(如VFS)会创建一个
struct shrinker对象。这个结构体中最关键的是两个函数指针:count_objects和scan_objects。然后,该子系统调用register_shrinker()将这个对象注册到一个全局的shrinker链表中。 - 触发(Publication):当系统内存不足时,无论是后台的
kswapd内核线程被唤醒,还是某个进程因分配内存失败而触发“直接回收”(direct reclaim),它们最终都会调用到shrink_slab()函数。 - 协同回收过程:
shrink_slab()会遍历所有已注册的shrinker对象,并对每个对象执行以下两步操作:- 第一步:计数(Count):调用该shrinker的
count_objects回调函数。这个函数需要快速地(通常是原子地读取一个计数器)返回该缓存中当前可回收对象的估算数量。例如,dcache的count函数会返回LRU链表中未使用dentry的数量。MM子系统会汇总所有shrinker的计数值,从而了解当前整个内核缓存的可回收潜力。 - 第二步:扫描(Scan):根据当前的内存压力和第一步中得到的计数值,MM子系统会计算出一个需要该shrinker释放的对象数量(
nr_to_scan)。然后,它调用该shrinker的scan_objects回调函数,并传入这个数量。scan_objects函数则负责执行实际的清理工作,例如,遍历LRU链表,释放最多nr_to_scan个未使用的对象。最后,它返回实际释放的对象数量。
- 第一步:计数(Count):调用该shrinker的
它的主要优势体现在哪些方面?
- 模块化与解耦:MM子系统无需知道任何关于dcache或inode cache的内部实现。它只通过标准的回调接口与它们通信,这使得代码清晰、易于维护。
- 可扩展性:任何新的内核代码只要想实现一个可回收的缓存,都可以简单地通过实现两个回调函数并注册一个shrinker来集成到全局内存管理中。
- 公平与高效:通过
count/scan两阶段机制,MM子系统可以根据每个缓存的“可回收性”来按比例施加压力,避免了对某个缓存进行过度的、低效的扫描,从而更公平、更高效地回收内存。
它存在哪些已知的劣势、局限性或在特定场景下的不适用性?
- 实现依赖:Shrinker框架的效率高度依赖于每个shrinker实现者的代码质量。一个 poorly-written 的
scan_objects函数(例如,持有锁的时间过长,或者扫描效率低下)会拖慢整个系统的内存回收过程。 - 估算的局限性:
count_objects返回的是一个估算值,可能与实际可回收的数量有偏差。这可能导致MM子系统的回收决策不是最优的。 - 不适用于页面缓存:这个框架是为基于对象的内核缓存设计的。它不适用于页面缓存(Page Cache)的回收。页面缓存有其自己的一套更复杂的、基于LRU和页面状态的回收机制。
使用场景
在哪些具体的业务或技术场景下,它是首选解决方案?请举例说明。
在内核中,任何子系统创建了一个动态的、非必需的、基于对象的内存缓存时,提供一个shrinker是标准且首选的做法。
- VFS dentry缓存回收:这是最经典的例子。当系统运行时,会创建大量的dentry对象来加速路径查找。但其中很多dentry在一段时间后就不再被使用。当内存紧张时,MM子系统会调用VFS注册的dentry shrinker。其
scan_objects函数会扫描dentry的LRU链表,将那些引用计数为0的dentry从哈希表和父子关系中脱离,并释放其占用的slab对象。 - Slab空闲页面回收:内核通过
kmem_cache_create创建了很多slab缓存。当某个缓存(例如dentry_cache)中的大量对象被释放后,可能会出现整个slab页面都变为空闲的情况。Slab分配器注册的shrinker被调用时,就会寻找这些完全空闲的slab页面,并将它们返还给伙伴系统(Buddy System),供其他用途使用。
是否有不推荐使用该技术的场景?为什么?
- 页面缓存(Page Cache):如前所述,页面缓存的回收逻辑(Active/Inactive List等)比shrinker模型复杂得多,有其专门的处理路径。
- 必需的核心数据:Shrinker用于回收“缓存”——即那些为了性能而保留,但丢弃后系统仍能正常工作(尽管可能变慢)的数据。它绝不能用于释放那些正在被使用的、不可或缺的核心数据结构。
对比分析
请将其 与 其他相似技术 进行详细对比。
对比一:Shrinker机制 vs. 页面缓存回收(Page Reclaim)
| 特性 | Shrinker机制 | 页面缓存回收 |
|---|---|---|
| 回收对象 | 通用内核对象,通常由Slab/Slub分配器管理(如dentries, inodes)。 | 内存页(struct page),主要是文件数据页(File-backed pages)和匿名页(Anonymous pages)。 |
| 核心机制 | 基于回调的“请求-响应”模型。MM请求,子系统响应并释放。 | 基于LRU(最近最-少使用)链表的管理。MM直接操作Active/Inactive链表来决定回收哪些页面。 |
| 决策者 | 子系统本身。scan_objects函数内部决定具体释放哪个对象。 |
MM子系统。kswapd直接决定哪个page将被回收、换出或丢弃。 |
| 工作范围 | 补充页面回收,清理内核数据结构占用的非页面缓存内存。 | 内存回收的主力,负责清理系统中最大头的内存消耗者——页面缓存。 |
| 关系 | 协同工作。一次完整的内存回收过程,通常会同时进行页面回收和调用shrinkers。 |
对比二:Shrinker机制 vs. drop_caches
| 特性 | Shrinker机制 | echo N > /proc/sys/vm/drop_caches |
|---|---|---|
| 触发方式 | 自动、按需。由内核在检测到内存压力时自动、渐进地触发。 | 手动、强制。由管理员或脚本显式触发,是一次性的、全局性的、暴力的操作。 |
| 回收方式 | 智能、渐进。根据压力大小,回收适量的、最不常用的对象(LRU)。 | 暴力、全部。尽可能多地丢弃所有可丢弃的缓存(N=1丢弃页面缓存,N=2丢棄dentry/inode,N=3丢弃全部)。 |
| 对性能的影响 | 旨在平滑系统性能,避免因内存不足而突然停顿。 | 会导致剧烈的性能下降。因为所有热缓存都被清空,后续的文件访问会触发大量的磁盘I/O风暴。 |
| 使用场景 | 内核正常的、持续的后台内存管理。 | 仅限于调试和测试。用于在可控环境下测试应用的冷启动性能,或临时释放内存以进行某些特殊操作。绝不应在生产环境中作为常规操作使用。 |
mm/shrinker.c
shrinker_alloc: 动态内存收缩器分配与初始化
本代码片段是 Linux 内核内存管理子系统的一部分,其核心功能是动态地分配并初始化一个 struct shrinker 实例。Shrinker 是内核中一种重要的机制,它为各种缓存(如 dentry 缓存、inode 缓存、工作集影子条目等)提供了一个标准接口,允许它们在系统内存不足时,被虚拟机(VM)子系统回调以安全地释放其占用的内存。此函数相当于一个“构造函数”,根据传入的标志位来配置 shrinker 的特性,如 NUMA 感知和 cgroup 感知。
实现原理分析
该函数的实现基于标志位驱动的配置和对不同内核特性的优雅降级,以构建一个符合调用者需求的 shrinker 实例。
基础分配与命名:
- 函数首先使用
kzalloc分配一块零初始化的内存用于struct shrinker。零初始化确保了所有未明确赋值的成员都处于已知的默认状态。 - 它通过可变参数 (
...) 和va_list机制,接收一个格式化字符串,并调用shrinker_debugfs_name_alloc来为这个 shrinker 分配一个唯一的名称。这个名称将用于在debugfs文件系统中创建条目,为内核开发者和系统管理员提供了监控和调试特定 shrinker 行为的途径。
- 函数首先使用
Memcg 感知与优雅降级:
- 核心分支: 如果调用者在
flags中指定了SHRINKER_MEMCG_AWARE,函数会尝试调用shrinker_memcg_alloc来分配与内存控制组(Memcg)相关的资源。这使得 shrinker 能够按 cgroup 粒度进行内存回收,这是实现容器化资源隔离的关键。 - 优雅降级: 一个关键的设计是处理
shrinker_memcg_alloc返回-ENOSYS的情况。这个错误码意味着内核虽然编译了 shrinker 框架,但并未启用 Memcg 支持 (CONFIG_MEMCG未开启或被命令行禁用)。在这种情况下,函数不会失败,而是会自动降级:它清除SHRINKER_MEMCG_AWARE标志位,并跳转到非 Memcg 的处理路径。这使得内核模块代码无需使用大量的#ifdef CONFIG_MEMCG宏,就可以编写出同时兼容两种配置的内核。
- 核心分支: 如果调用者在
NUMA 感知与
nr_deferred分配:nr_deferred的作用: 这是一个计数器数组,用于实现延迟收缩。当 VM 调用一个 shrinker,但该 shrinker 发现其缓存的对象当前因被锁定等原因无法释放时,它会递增nr_deferred计数器并快速返回。VM 会根据这个计数值来判断该 shrinker 的“繁忙”程度,从而决定后续扫描的优先级。- NUMA 适配: 如果
flags中指定了SHRINKER_NUMA_AWARE,nr_deferred数组的大小将是nr_node_ids(系统中NUMA节点的数量) 的倍数。这意味着每个 NUMA 节点都有自己独立的nr_deferred计数器。这是一种重要的性能优化,因为它避免了多个节点上的 CPU 在更新同一个全局计数器时发生缓存行伪共享(False Sharing)和锁争用。
特定场景分析:单核、无MMU的STM32H750平台
功能相关性
shrinker 机制是 Linux 内存管理的基础。在 STM32H750 上,文件系统缓存(dcache, icache)和工作集检测等功能如果被启用,都将依赖 shrinker_alloc 来创建它们的内存回收器。因此,尽管平台资源受限,此函数依然是保证系统在内存压力下稳定运行的关键基础设施。
配置标志的影响
SHRINKER_MEMCG_AWARE: 在为 STM32H750 配置内核时,几乎一定会禁用CONFIG_MEMCG以节省宝贵的 Flash 和 SRAM 资源。因此,当workingset_init等函数调用shrinker_alloc并传入此标志时,shrinker_memcg_alloc会返回-ENOSYS,触发优雅降级机制。最终分配的将是一个轻量级的、不感知 cgroup 的 shrinker。这完美地展示了内核如何自动适配简化后的配置。SHRINKER_NUMA_AWARE: STM32H750 是一个典型的 UMA(统一内存访问)架构,CONFIG_NUMA也会被禁用。内核会认为系统只有一个内存节点 (nr_node_ids等于 1)。因此,当kzalloc为nr_deferred分配内存时,size *= nr_node_ids;这个乘法操作不会改变size的值。最终会分配一个仅包含单个计数器的数组,这完全符合该平台的硬件实际。
内存分配与单核影响
kzalloc使用GFP_KERNEL标志,表示分配内存时可以睡眠。这在shrinker_alloc的调用上下文中(通常是启动时的initcall)是安全的。在单核 STM32H750 上,这意味着如果内存暂时不足,调度器可能会切换到其他任务,直到有内存被释放。- 此函数本身的执行不存在并发问题。它创建的数据结构(如
nr_deferred)虽然会在运行时被并发访问(例如,被中断或被抢占的任务访问),但访问这些结构的 shrinker 核心代码会使用适当的锁来保证安全。
代码分析
1 | /** |
shrinker_register: 将内存收缩器挂载至全局回收链表
本代码片段定义了 shrinker_register 函数,其核心功能是将一个已经通过 shrinker_alloc 动态分配并配置好的 shrinker 实例正式激活,使其成为内核全局内存回收机制的一部分。通过将 shrinker 添加到一个全局链表 (shrinker_list) 中,此函数使得虚拟机(VM)子系统在面临内存压力时,能够发现并回调这个 shrinker,从而触发相应缓存(如 dentry 缓存)的内存收缩操作。
实现原理分析
该函数的实现是内核中典型的发布-订阅(或观察者)模式,并采用了精密的并发控制和生命周期管理机制来确保系统在高并发环境下的稳定性和正确性。
并发控制 (Mutex 与 RCU):
- 内核的内存回收路径(
kswapd线程或直接回收路径)可能会在任何时候遍历shrinker_list来寻找可回收的内存。同时,内核模块也可能在任何时候加载或卸载,从而调用shrinker_register或shrinker_unregister来修改这个链表。这是一个经典的读者-写者并发问题。 - 写者保护 (
mutex_lock): 函数使用shrinker_mutex互斥锁来保护对shrinker_list的所有写操作(添加和删除)。这确保了同一时间只有一个任务可以修改链表,防止了链表指针的损坏。 - 读者保护 (
list_add_tail_rcu): 仅仅锁住写者是不够的,因为内存回收路径作为读者,不能因为等待写者释放锁而长时间阻塞。因此,链表的添加操作使用了list_add_tail_rcu。这是一个 RCU (Read-Copy-Update) 安全的链表操作。它保证了即使在没有加锁的情况下,正在遍历链表的读者(内存回收路径)也能看到一个一致的、完整的链表视图,不会因为并发的添加操作而访问到不完整或已损坏的节点。
- 内核的内存回收路径(
生命周期管理 (
refcount):shrinker的生命周期管理是一个复杂的问题,尤其是在 RCU 环境下。一个 shrinker 可能正在被内存回收路径使用,而与此同时,另一个线程可能想要注销并释放它。refcount_set(&shrinker->refcount, 1)是生命周期管理的起点。在shrinker被完全设置好并对系统可见后,函数将其引用计数设置为 1。这个初始引用可以被看作是“注册”本身所持有的引用。- 后续,当内存回收路径需要使用这个
shrinker时,它会尝试通过shrinker_try_get()来增加引用计数。只有在成功增加引用计数后,它才能安全地使用该shrinker。使用完毕后,它会减少引用计数。 - 当
shrinker_unregister被调用时,它会移除这个初始的“注册”引用。只有当引用计数最终降为 0 时(意味着没有任何代码正在使用它),shrinker的内存才会被安全地释放。这彻底避免了“使用后释放”(Use-After-Free)的严重 bug。
代码分析
1 | /** |









