[Linux]学习笔记系列 -- mm/swap.c 交换机制(Swap Mechanism) 物理内存的虚拟扩展
文章目录
- mm/swap.c 交换机制(Swap Mechanism) 物理内存的虚拟扩展
- 历史与背景
- 这项技术是为了解决什么特定问题而诞生的?
- 它的发展经历了哪些重要的里程碑或版本迭代?
- 目前该技术的社区活跃度和主流应用情况如何?
- 核心原理与设计
- 它的核心工作原理是什么?
- 它的主要优势体现在哪些方面?
- 它存在哪些已知的劣势、局限性或在特定场景下的不适用性?
- 使用场景
- 在哪些具体的业务或技术场景下,它是首选解决方案?
- 是否有不推荐使用该技术的场景?为什么?
- 对比分析
- 请将其 与 其他相似技术 进行详细对比。
- mm/swap.c
- Folio Final Destructor: 记忆页面的最后仪式
- 实现原理分析
- 代码分析
- Per-CPU LRU Batching: Amortizing Lock Overhead for High-Performance Memory Management
- 实现原理分析
- 特定场景分析:单核、无MMU的STM32H750平台
- 硬件交互与MMU
- 单核环境下的行为
- 实际意义
- 代码分析
- Per-CPU LRU Drain: 批量内存管理的主力
- 实现原理分析
- 特定场景分析:单核、无MMU的STM32H750平台
- 硬件交互与MMU
- 单核环境下的行为
- 实际意义
- 代码分析
- 页缓存辅助函数:降级与清理
- 函数一:`deactivate_file_folio`
- 实现原理分析
- 函数二:`folio_batch_remove_exceptionals`
- 实现原理分析
- 代码分析

https://github.com/wdfk-prog/linux-study
mm/swap.c 交换机制(Swap Mechanism) 物理内存的虚拟扩展
历史与背景
这项技术是为了解决什么特定问题而诞生的?
这项技术以及其实现的交换(Swap)机制,是为了解决一个自计算机诞生以来就存在的根本性限制:物理内存(RAM)的大小是有限的。
- 运行大于物理内存的程序:在早期,如果一个程序需要比机器拥有的RAM更多的内存,它根本无法运行。交换机制通过将内存中不常用的部分临时存放到磁盘上,从而“欺骗”程序,让它以为自己拥有一个远大于实际物理内存的地址空间。这使得运行大型应用程序成为可能。
- 同时运行更多的程序:即使每个程序都能装入内存,但同时运行多个程序可能会迅速耗尽RAM。交换机制允许内核将处于空闲或等待状态的进程的内存换出到磁盘,从而为当前活动的进程腾出宝贵的物理内存,提高系统的并发能力和整体吞吐量。
- 系统休眠(Hibernation):为了实现休眠到磁盘(Suspend-to-Disk)功能,内核需要一个地方来存储整个系统内存的快照。交换空间(Swap Space)被自然地用作这个存储区域。
它的发展经历了哪些重要的里程碑或版本迭代?
Linux的交换机制从早期UNIX继承了基本思想,并随着内核的演进不断完善。
- 从交换进程到交换页面:最早期的系统采用“交换”(Swapping),即换出整个进程的内存映像。现代Linux采用的是“分页”(Paging),它以固定大小的内存页(通常是4KB)为单位进行换出换入。分页比交换更精细、更高效,因为只需移动那些当前不活跃的页面,而不是整个进程。
- 支持交换文件(Swap Files):最初,交换空间必须是一个独立的磁盘分区(Swap Partition)。后来内核增加了对使用普通文件作为交换空间的支持。这极大地提高了灵活性,因为管理员可以在不重新分区磁盘的情况下,动态地添加、删除或调整交换空间的大小。
- 支持多交换区与优先级:内核允许多个交换分区和交换文件同时存在,并且可以为它们指定不同的优先级。这允许管理员将交换操作优先导向更快的磁盘。
- 与内存控制组(cgroups)集成:在容器化时代,
memcg
(Memory Cgroup)的出现使得交换机制可以被精细地控制在每个容器的级别,实现了容器的交换空间隔离和限制。 - 交换机制的优化(zswap/zram):虽然不是
mm/swap.c
本身的一部分,但像zswap这样的技术是交换机制的重要演进。zswap在将页面写入磁盘之前,先在内存中对其进行压缩。这相当于一个压缩了的、基于RAM的写回缓存,极大地降低了实际发生慢速磁盘I/O的频率,显著提升了交换性能。
目前该技术的社区活跃度和主流应用情况如何?
交换机制是Linux内存管理中一个极其稳定和基础的部分。
- 社区活跃度:其核心代码非常成熟。社区的开发活动主要集中在性能优化、与cgroup和NUMA等现代内核特性的集成,以及改进像zswap这样的外围支持技术。
- 主流应用:
- 桌面和服务器:尽管现代系统拥有大量RAM,但交换空间仍然被普遍启用,作为防止系统因内存耗尽而崩溃的安全网,并支持休眠功能。
- 低内存设备:在嵌入式系统或内存较小的云服务器上,交换机制对于系统的正常运行至关重要。
- 内存超售(Overcommit)环境:在虚拟化和容器环境中,为了提高物理服务器的利用率,常常会进行内存超售(即分配给所有虚拟机的内存总和大于物理内存)。在这种场景下,高效的交换机制是必不可少的。
核心原理与设计
它的核心工作原理是什么?
交换机制的核心是在**物理内存(RAM)和交换空间(磁盘)之间移动内存页,并通过页表(Page Table)**来对这个过程进行虚拟化。
换出(Swap-out)过程:
- 触发:当系统内存不足时,内核的页面回收逻辑(由
kswapd
后台线程或直接回收触发,主要实现在mm/vmscan.c
)被激活。 - 选择牺牲页(Victim Selection):页面回收算法会选择一个合适的页面进行换出。它优先选择匿名页(Anonymous Page),即那些不与任何文件关联的内存,如进程的堆(heap)和栈(stack)。
- 分配交换槽(Swap Slot):内核在已配置的交换空间中找到一个空闲的槽位。
- 写入磁盘:内核将牺牲页的内容异步地写入到分配好的磁盘交换槽中。
- 修改页表项(PTE):一旦写入完成,内核会修改该内存页对应的进程页表项(PTE)。它会清除PTE中的物理页帧号,并将“存在位”(Present Bit)清零,然后将描述交换区位置的**交换条目(
swp_entry_t
)**存入PTE中。 - 释放物理页:最后,这个物理页面被释放,可以被系统用于其他目的。
换入(Swap-in)过程:
- 触发缺页异常(Page Fault):当进程试图访问一个已经被换出的内存地址时,CPU会发现其PTE的“存在位”为0,从而触发一个缺页异常。
- 异常处理:内核的缺页异常处理函数接管控制。它检查PTE的内容,发现里面存储的不是0,而是一个有效的交换条目。
- 从磁盘读取:内核根据交换条目中的信息,在交换空间中找到对应的页面数据。
- 分配物理页:内核分配一个新的空闲物理内存页。
- 读入数据:内核将数据从磁盘读入到这个新分配的物理页中。
- 更新页表:内核更新PTE,使其指向新的物理页,并设置“存在位”。
- 恢复执行:缺页异常处理完毕,进程从被中断的指令处恢复执行,此时它对内存的访问可以正常进行了。
它的主要优势体现在哪些方面?
- 虚拟内存扩展:以较低的成本(磁盘空间远比RAM便宜)极大地扩展了系统的可用内存。
- 提高系统稳定性:在内存压力下,通过换出不活跃页面,可以避免因内存不足而触发OOM Killer(Out-of-Memory Killer),从而保护重要进程不被杀死。
- 支持休眠:为系统休眠到磁盘功能提供了基础。
它存在哪些已知的劣势、局限性或在特定场景下的不适用性?
- 性能悬崖(Performance Cliff):最大的劣势是性能。磁盘的访问速度比RAM慢几个数量级。一旦系统开始频繁地进行交换(称为“颠簸”或“Thrashing”),系统会把绝大部分时间花在等待I/O上,导致系统响应变得极度缓慢,几乎无法使用。
- SSD寿命:在固态硬盘(SSD)上进行大量、持续的交换写入,理论上会消耗其写入寿命(Write Endurance)。不过,现代SSD的寿命已经大大提高,这在大多数场景下已不是主要问题。
使用场景
在哪些具体的业务或技术场景下,它是首选解决方案?
交换机制本身不是一个追求性能的“首选方案”,而是一个保障系统稳定运行的、必要的底层机制。
- 内存需求波动的桌面/服务器:一个Web服务器在白天负载很高,晚上负载很低。在低负载时,许多服务进程的内存可能会被换出,为文件缓存(Page Cache)腾出更多空间,从而在下一次负载高峰到来时,能更快地服务文件请求。
- 内存受限的环境:在只有1GB内存的虚拟机或嵌入式设备上运行一个需要1.2GB内存峰值的应用,如果没有交换机制,这个应用根本无法启动。
- 需要休眠功能的笔记本电脑:休眠功能依赖于将内存内容写入交换区。
是否有不推荐使用该技术的场景?为什么?
- 硬实时系统:交换过程引入的延迟是不可预测且巨大的,会彻底破坏实时系统的时间确定性要求。
- 高性能计算(HPC)/内存数据库:对于这类应用,所有数据都应常驻内存。任何交换操作都会导致灾难性的性能下降。这些应用通常会使用
mlock()
系统调用将自己的内存锁定在RAM中,防止被换出。
对比分析
请将其 与 其他相似技术 进行详细对比。
对比一:交换(Swapping Anonymous Pages) vs. 回写(Writing Back Dirty File Pages)
这两者都是在内存压力下将内存页写入磁盘,但目的和目标完全不同。
特性 | 交换 (Swap) | 页面缓存回写 (Page Cache Write-back) |
---|---|---|
处理对象 | 匿名内存页(Anonymous Pages),如进程的堆、栈。 | 与文件关联的内存页(File-backed Pages),即Page Cache。 |
写入目标 | 交换空间(Swap Space),一个专用的分区或文件。 | 文件本身,在原始的文件系统中。 |
目的 | 为了临时腾出物理内存,这些数据没有其他“家”。 | 为了持久化数据,确保对文件的修改被保存。 |
回收逻辑 | 当一个匿名页被换出后,其物理页可以被立即释放。 | 当一个“脏”的文件页被写回磁盘后,它就变成了“干净”的页。如果内存紧张,这个干净的页可以被直接丢弃(因为可以从文件重新读回),而不需要写入任何地方。 |
对比二:传统交换 vs. zswap
特性 | 传统交换 (Traditional Swap) | zswap |
---|---|---|
工作模式 | 直接将内存页写入到磁盘上的交换设备。 | 充当一个写回缓存(Write-back Cache)。 |
工作流程 | 内存 -> 磁盘 | 内存 -> 压缩 -> zswap内存池 -> (如果池满或页面变冷)-> 解压 -> 磁盘 |
性能 | 慢。受限于磁盘I/O速度。 | 快得多。大部分交换操作在RAM中完成(压缩/解压),避免了磁盘I/O。 |
资源消耗 | 消耗磁盘I/O和磁盘空间。 | 消耗CPU周期(用于压缩/解压)和部分RAM(用于存储压缩后的页面)。 |
关系 | zswap不是交换的替代品,而是其增强。它仍然需要一个底层的传统交换设备作为最终的存储。 |
mm/swap.c
Folio Final Destructor: 记忆页面的最后仪式
本代码片段定义了 __folio_put
函数,它是Linux内核内存管理中一个 folio(代表一个或多个连续物理页)生命周期的终点。这个函数不是简单地递减引用计数,而是在 folio 的引用计数已经降为零时,由 folio_put
宏调用的最终清理和释放函数。其核心功能是撤销 folio 分配和使用过程中附加的所有状态(如页缓存、cgroup记账等),并最终将 folio所占用的物理内存返还给伙伴系统(buddy system)分配器,使其能够被重新使用。
实现原理分析
__folio_put
的执行流程是一个精确的、逆向的资源回收过程。它必须清理掉 folio 可能关联的所有子系统,然后才能释放物理内存。
-
处理特殊内存类型 (Early Exits):
if (unlikely(folio_is_zone_device(folio)))
: 首先检查 folio 是否属于特殊的 “device” 内存区域,例如持久化内存(PMEM)或GPU显存。这类内存有自己独立的分配器和释放逻辑,因此会调用free_zone_device_folio
并提前返回。if (folio_test_hugetlb(folio))
: 接着检查 folio 是否属于 HugeTLB 页。HugeTLB 页由一个独立的、预分配的池进行管理,有自己的释放函数free_huge_folio
。- 对于普通内存,这两个条件都为假。
-
从页缓存解绑 (
page_cache_release
):- 这是一个关键步骤。当一个 folio 被用作页缓存时,它会被关联到一个
inode
的address_space
上。page_cache_release
会执行必要的清理,例如,递减address_space
的引用计数。这是确保当一个文件的所有页缓存都被释放后,其address_space
也能被正确回收的关键。
- 这是一个关键步骤。当一个 folio 被用作页缓存时,它会被关联到一个
-
清理延迟拆分 (
folio_unqueue_deferred_split
):- 为了性能,当内核需要将一个大的folio(例如16KB)拆分成小的基页(4KB)时,这个拆分操作可能会被延迟执行。这个 folio 会被放进一个“待拆分”队列。
__folio_put
在这里检查该 folio 是否还在这个队列中,如果是,就将其移除,因为既然整个 folio 都要被释放了,再执行拆分就毫无意义了。
- 为了性能,当内核需要将一个大的folio(例如16KB)拆分成小的基页(4KB)时,这个拆分操作可能会被延迟执行。这个 folio 会被放进一个“待拆分”队列。
-
Cgroup 内存记账返还 (
mem_cgroup_uncharge
):- 如果内核启用了内存控制组(cgroups),那么当一个 folio 被分配时,它的内存使用量会被“记账”(charge)到分配它的那个cgroup上。
mem_cgroup_uncharge
执行相反的操作:它将这个 folio 的大小从其所属 cgroup 的内存使用量中减去。这是实现容器内存限制和隔离的基础。
- 如果内核启用了内存控制组(cgroups),那么当一个 folio 被分配时,它的内存使用量会被“记账”(charge)到分配它的那个cgroup上。
-
返还物理内存 (
free_frozen_pages
):- 这是整个流程的最后一步,也是最根本的一步。
free_frozen_pages
是伙伴系统分配器的核心API之一。 - 它接收 folio 对应的
struct page
和 folio 的阶数(folio_order
,即log2(页面数量)
)。 - 它会将这个(可能由多个连续页面组成的)物理内存块,返还到伙伴系统中对应大小的空闲链表上,使其可供系统下一次内存分配使用。
- 这是整个流程的最后一步,也是最根本的一步。
代码分析
// __folio_put: 当folio引用计数降为0时,执行的最终清理函数。
void __folio_put(struct folio *folio)
{// 检查是否为特殊的设备内存。在STM32H750上,此条件为false。if (unlikely(folio_is_zone_device(folio))) {free_zone_device_folio(folio);return;}// 检查是否为HugeTLB页。在STM32H750上,此条件为false。if (folio_test_hugetlb(folio)) {free_huge_folio(folio);return;}// 步骤1: 从页缓存子系统解绑,处理address_space等资源的引用计数。page_cache_release(folio);// 步骤2: 如果folio在延迟拆分队列中,将其移除。folio_unqueue_deferred_split(folio);// 步骤3: 从内存控制组返还内存记账。// (如果CONFIG_MEMCG未定义,此函数为空操作)。mem_cgroup_uncharge(folio);// 步骤4: 将folio代表的物理内存返还给伙伴系统分配器。// folio_order() 告诉分配器要释放的内存块的大小。free_frozen_pages(&folio->page, folio_order(folio));
}
// 导出符号,供内核其他部分(如slab分配器)调用。
EXPORT_SYMBOL(__folio_put);
Per-CPU LRU Batching: Amortizing Lock Overhead for High-Performance Memory Management
本代码片段揭示了Linux内存管理子系统中一个核心的性能优化机制:per-CPU页/folio批处理(batching)。其主要功能是避免在每次需要将一个页面(folio)添加到LRU(Least Recently Used)链表时都去竞争一个全局的、高争用的锁。取而代之的是,内核将这些LRU操作请求暂存在一个当前CPU私有的、无锁的批处理队列中。lru_add_drain_all
函数的作用就是**强制处理(drain)**这些per-CPU队列,将所有暂存的页面一次性地提交到全局LRU链表中。
实现原理分析
这个机制是典型的用空间换时间、分摊锁开销的优化策略。
-
Per-CPU数据结构 (
cpu_fbatches
):- 内核为每个CPU核心都定义了一个
cpu_fbatches
结构体。这意味着每个CPU都有自己独立的批处理队列(lru_add
,lru_deactivate
等)。 - 优点: 当一个CPU上的代码需要将一个页面加入LRU时,它只需访问自己私有的
cpu_fbatches
结构。因为没有其他CPU会访问这个结构,所以几乎没有缓存争用,性能极高。 folio_batch
: 这是一个简单的数据结构,本质上是一个固定大小的folio指针数组,用于暂存待处理的folio。
- 内核为每个CPU核心都定义了一个
-
分级锁 (
local_lock
vslocal_lock_irq
):local_lock_t
: 这是一个轻量级锁。在多核系统上,它通过禁止抢占来保护数据。这足以防止在同一个CPU上的不同任务之间产生竞争。local_lock_irq
: 这是一个更强的锁,它会同时禁止抢占和本地中断。用于保护那些可能在中断上下文中也被访问的数据(如lru_move_tail
)。
-
批处理与清空 (Batch and Drain):
- 批处理: 当内核代码(例如,在缺页异常处理中)需要将一个新页面加入LRU时,它实际上是调用一个内部函数(如
folio_add_lru
,未在此处显示),该函数将folio指针添加到当前CPU的cpu_fbatches.lru_add
批处理队列中,然后就快速返回了。它不直接操作全局LRU链表。 - 清空 (Drain):
lru_add_drain()
函数就是“清空”操作。它会锁定当前CPU的批处理队列,然后调用一个工作函数(lru_add_drain_cpu
)来处理队列中的所有folio。这个工作函数会获取一次全局的LRU链表锁,然后在一个循环中将批处理队列里的所有folio都移动到全局LRU链表中,最后释放一次全局锁。
- 批处理: 当内核代码(例如,在缺页异常处理中)需要将一个新页面加入LRU时,它实际上是调用一个内部函数(如
-
lru_add_drain_all
的角色:- 此函数在
vm/drop_caches.c
中被调用。它的作用是在执行drop_caches
操作之前,确保所有CPU上所有暂存的、待加入LRU的页面都已经被处理完毕。 - 这保证了全局LRU链表处于一个完全一致和最新的状态,
drop_caches
才能看到并清理掉所有符合条件的缓存页,而不会遗漏那些还“卡在”per-CPU批处理队列里的页面。
- 此函数在
特定场景分析:单核、无MMU的STM32H750平台
硬件交互与MMU
此机制是纯粹的内核内存管理算法,与硬件或MMU无关。
单核环境下的行为
虽然per-CPU
是为多核设计的,但它在单核系统上会优雅地退化:
DEFINE_PER_CPU
: 在单核系统上,这只会定义一个普通的、非数组的cpu_fbatches
变量。所有per_cpu_ptr()
之类的操作都会被编译器优化为对这个单一变量的直接访问。local_lock_t
: 在单核可抢占内核中,local_lock()
被编译为preempt_disable()
,local_unlock()
被编译为preempt_enable()
。这仍然是绝对必要的。它可以防止一个任务在修改批处理队列的过程中被另一个任务抢占,从而避免了数据损坏。smp_processor_id()
: 在单核系统上,此宏总是返回0。
实际意义
你可能会问,既然只有一个CPU,没有CPU间的竞争,为什么还需要这么复杂的批处理机制?
答案是,这个机制仍然能带来显著的性能优势。
- 减少锁的持有时间: 全局LRU链表通常由一个自旋锁保护。在单核系统上,获取这个自旋锁意味着禁止本地中断。如果每次添加一个页面都要“禁止中断 -> 操作链表 -> 开启中断”,当中断频繁或链表操作耗时时,会增加系统的中断延迟,影响实时性。
- 分摊开销: 通过批处理,内核可以“攒一堆”页面,然后一次性地“禁止中断 -> 循环处理15个页面 -> 开启中断”。这极大地**分摊(amortize)**了开关中断和获取/释放锁的开销,使得平均到每个页面的同步成本大大降低。
结论: 在STM32H750上,lru_add_drain_all
和它背后的per-CPU批处理机制,是一个通过分摊同步开销来提高内存管理效率、降低中断延迟的关键优化。它使得即使在单核系统上,高频率的内存页面操作也不会对系统响应性造成过大的冲击。
代码分析
// cpu_fbatches: 定义一个per-CPU结构体,用于暂存待处理的folio批次。
struct cpu_fbatches {/** 以下folio批次被组织在一起,因为它们通过禁用抢占来保护。*/local_lock_t lock;struct folio_batch lru_add; // 待加入LRU的foliostruct folio_batch lru_deactivate_file; // 待取消激活的文件foliostruct folio_batch lru_deactivate; // 待取消激活的匿名foliostruct folio_batch lru_lazyfree; // 待惰性释放的folio
#ifdef CONFIG_SMPstruct folio_batch lru_activate; // 待激活的folio(仅多核)
#endif/* 保护以下批次,它们需要禁用中断。 */local_lock_t lock_irq;struct folio_batch lru_move_tail; // 待移动到LRU尾部的folio
};// 使用DEFINE_PER_CPU为每个CPU定义并初始化一个cpu_fbatches实例。
// 在单核STM32H750上,这只会创建一个实例。
static DEFINE_PER_CPU(struct cpu_fbatches, cpu_fbatches) = {.lock = INIT_LOCAL_LOCK(lock),.lock_irq = INIT_LOCAL_LOCK(lock_irq),
};// lru_add_drain: 清空当前CPU的LRU批处理队列。
void lru_add_drain(void)
{// 获取本地锁(在单核上,这会禁用抢占)。local_lock(&cpu_fbatches.lock);// 调用工作函数,处理当前CPU(ID由smp_processor_id()获取)的所有批处理队列。lru_add_drain_cpu(smp_processor_id());// 释放本地锁(在单核上,这会恢复抢占)。local_unlock(&cpu_fbatches.lock);// 清空本地的mlock批处理队列。mlock_drain_local();
}// lru_add_drain_all: 清空所有CPU的LRU批处理队列。
// 在本代码片段中,它被简化为只清空当前CPU的,
// 因为drop_caches通常是通过向所有CPU发送IPI(处理器间中断)来确保所有CPU都执行这个操作。
// 但对于此文件的上下文,可以理解为“清空所有需要清空的”。
void lru_add_drain_all(void)
{lru_add_drain();
}
Per-CPU LRU Drain: 批量内存管理的主力
本代码片段定义了 lru_add_drain_cpu
函数,它是 per-CPU 页/folio 批处理机制的核心执行者。在 lru_add_drain
获取了本地锁之后,它就调用这个函数来完成实际的工作。lru_add_drain_cpu
的功能是遍历指定CPU的 cpu_fbatches
结构中所有类型的批处理队列,并将其中所有暂存的folio提交到全局的、共享的LRU链表中。这是将为性能而“延迟”的操作最终“同步”回系统全局状态的关键一步。
实现原理分析
lru_add_drain_cpu
是一个高度特化的工作函数,其设计前提是调用者已经确保了必要的同步(通常是禁用了抢占)。
-
目标明确: 函数接收一个
cpu
编号,并通过per_cpu
宏直接定位到该CPU的cpu_fbatches
实例。这使得它可以被用于清空当前CPU的队列,也可以在CPU热拔插等场景下,由其他CPU来清空一个即将离线的CPU的队列。 -
分批处理: 函数依次处理
cpu_fbatches
结构中的每一个folio_batch
:lru_add
,lru_move_tail
,lru_deactivate_file
等。 -
通用提交逻辑 (
folio_batch_move_lru
): 对于每个非空的批处理队列(通过folio_batch_count
检查),它都会调用一个外部函数folio_batch_move_lru
。这个函数(未在此代码中显示)是真正实现“提交”的地方。我们可以推断其内部逻辑为:
a. 获取保护相应全局LRU链表的重量级锁(通常是一个需要禁用中断的自旋锁)。
b. 在一个循环中,将批处理队列中的所有folio指针逐一添加到全局LRU链表中。
c. 释放全局LRU链表的锁。
d. 清空本地的folio_batch
队列。 -
特殊的中断安全处理 (
lru_move_tail
):lru_move_tail
批处理队列的处理方式与其他不同。它使用了local_lock_irqsave
。- 原因: 从
cpu_fbatches
结构体的注释可知,lru_move_tail
是一个可以从中断上下文中被修改的队列。 - 问题: 仅仅禁用抢占不足以保护它。一个任务在执行
lru_add_drain_cpu
的过程中,可能会被一个中断打断,而这个中断服务程序(ISR)可能会尝试向lru_move_tail
队列中添加一个新的folio,导致数据竞争。 - 解决方案: 因此,在处理
lru_move_tail
队列之前,必须使用local_lock_irqsave
,它不仅会获取一个锁,还会禁用当前CPU的本地中断。这确保了在清空该队列期间,不会有任何中断来干扰它,从而保证了操作的原子性和安全性。data_race()
宏可能用于在编译时或运行时检查这种潜在的竞争。
特定场景分析:单核、无MMU的STM32H750平台
硬件交互与MMU
此函数是纯粹的内核内存管理算法,与硬件或MMU无关。
单核环境下的行为
在单核的STM32H750上,此函数的行为逻辑完全正确,并且其设计中的保护措施仍然是必要的。
- 调用上下文:
lru_add_drain
在调用此函数前会禁用抢占 (local_lock
)。 per_cpu(..., cpu)
:cpu
参数将始终为0,函数将操作全局唯一的cpu_fbatches
实例。local_lock_irqsave
: 在单核CPU上,这个函数的核心作用就是local_irq_save
,即禁用本地中断。这仍然是与中断服务程序进行同步的唯一正确方式。因此,对lru_move_tail
的特殊保护逻辑在单核系统上依然是必需和有效的。folio_activate_drain(cpu)
: 同样,这个函数会处理lru_activate
批处理队列,确保所有待激活的页面都被处理。
实际意义
lru_add_drain_cpu
是 per-CPU 批处理策略兑现其性能承诺的地方。在STM32H750上,它的意义在于:
- 实现开销分摊: 正是这个函数,通过
folio_batch_move_lru
,实现了“一次锁定,多次操作”的模式。这显著降低了平均到每个页面的同步开销。 - 降低中断延迟: 对于
lru_move_tail
这种可能在中断中使用的队列,批处理机制允许中断服务程序(ISR)只做一个极快的、无锁的“添加到本地队列”操作就立即返回,而将耗时的、需要加全局锁的链表操作延迟到lru_add_drain_cpu
在非中断上下文中执行。这极大地缩短了ISR的执行时间,降低了系统的最大中断延迟,对于需要实时响应的嵌入式系统来说,这是一个非常重要的特性。
结论: lru_add_drain_cpu
是 per-CPU 批处理优化的执行引擎。在STM32H750上,它不仅通过分摊锁开销提高了整体效率,更重要的是,它通过延迟中断上下文中的重度工作,帮助降低了系统中断延迟,提升了系统的实时性和响应性。
代码分析
/** 清空指定cpu的folio_batch队列中的页面。* 前提条件:要么“cpu”是当前CPU,并且抢占已被禁用;* 要么“cpu”正在被热拔插,并且已经死亡。*/
void lru_add_drain_cpu(int cpu)
{// 获取指定CPU的fbatches结构体指针。struct cpu_fbatches *fbatches = &per_cpu(cpu_fbatches, cpu);struct folio_batch *fbatch;// --- 处理 lru_add 批处理 ---fbatch = &fbatches->lru_add;// 如果lru_add队列不为空...if (folio_batch_count(fbatch))// ...则调用工作函数将其中的所有folio移动到全局lru_add链表。folio_batch_move_lru(fbatch, lru_add);// --- 处理 lru_move_tail 批处理 (中断安全) ---fbatch = &fbatches->lru_move_tail;// 检查lru_move_tail队列是否可能存在数据竞争(来自中断)。if (data_race(folio_batch_count(fbatch))) {unsigned long flags;// 获取一个同时禁用本地中断的锁,以安全地与中断服务程序同步。local_lock_irqsave(&cpu_fbatches.lock_irq, flags);// 移动所有folio到全局lru_move_tail链表。folio_batch_move_lru(fbatch, lru_move_tail);// 释放锁并恢复中断状态。local_unlock_irqrestore(&cpu_fbatches.lock_irq, flags);}// --- 处理 lru_deactivate_file 批处理 ---fbatch = &fbatches->lru_deactivate_file;if (folio_batch_count(fbatch))folio_batch_move_lru(fbatch, lru_deactivate_file);// --- 处理 lru_deactivate 批处理 ---fbatch = &fbatches->lru_deactivate;if (folio_batch_count(fbatch))folio_batch_move_lru(fbatch, lru_deactivate);// --- 处理 lru_lazyfree 批处理 ---fbatch = &fbatches->lru_lazyfree;if (folio_batch_count(fbatch))folio_batch_move_lru(fbatch, lru_lazyfree);// 清空与页面激活相关的批处理队列。folio_activate_drain(cpu);
}
页缓存辅助函数:降级与清理
本代码片段定义了两个重要的页缓存管理辅助函数:
deactivate_file_folio
: 当一个folio无法被立即驱逐(例如,因为它是脏的)时,此函数提供了一种“降级”机制。它将folio标记为内存回收的优先候选者,增加了它在未来被回收的可能性。folio_batch_remove_exceptionals
: 这是一个内务(housekeeping)函数,用于清理一个folio_batch
,从中移除所有非folio的“特殊条目”(如影子条目),以确保后续操作只处理真实的内存页。
函数一:deactivate_file_folio
实现原理分析
deactivate_file_folio
的核心思想是,当不能直接删除一个缓存页时,就将其移动到LRU(Least Recently Used)链表的“冷”端,从而“暗示”内存管理系统这个页面已不再活跃。
-
安全检查 (
folio_test_unevictable
):- 函数首先检查folio是否被标记为“不可驱逐”(unevictable)。这种情况发生在页面被
mlock()
系统调用锁定在内存中,或者用于某些特殊的内核数据结构。 - 对于不可驱逐的folio,尝试将其降级是毫无意义的,因为无论如何它都不会被回收。因此函数直接返回。
- 函数首先检查folio是否被标记为“不可驱逐”(unevictable)。这种情况发生在页面被
-
新一代LRU处理 (
lru_gen_enabled
):- 现代Linux内核引入了更先进的多代LRU(Multi-Generational LRU)算法,由
lru_gen_enabled()
检查是否启用。 - 如果启用了新算法,它会调用
lru_gen_clear_refs
。这个函数会尝试清除folio的“被访问”标志位。在新算法中,一个没有被访问过的folio自然就成了回收的候选者。如果这个操作成功了,就达到了“降级”的目的,函数直接返回。
- 现代Linux内核引入了更先进的多代LRU(Multi-Generational LRU)算法,由
-
传统LRU处理 (
folio_batch_add_and_move
):- 如果新一代LRU未启用或未处理该folio,则回退到传统的双链表(Active/Inactive)LRU机制。
folio_batch_add_and_move
是一个高效的批处理接口。它会将folio
添加到当前CPU的一个私有批处理队列(lru_deactivate_file
)中。- 这个操作不会立即获取全局LRU锁并移动folio。相反,它只是快速地将folio暂存起来。真正的移动操作会被延迟,直到批处理队列满了或者被
lru_add_drain
等函数强制清空时,才会一次性地处理整批folio。这极大地分摊了锁竞争的开销。
函数二:folio_batch_remove_exceptionals
实现原理分析
folio_batch_remove_exceptionals
的功能非常专一:过滤一个folio_batch
,只留下真正的folio。
- 问题背景:
find_lock_entries
为了效率,会把XArray中的所有条目——无论是真正的folio指针还是代表文件空洞的“影子条目”(value entries)——都抓取到folio_batch
中。 - 过滤算法:
- 函数使用了一个经典的双指针、原地(in-place)过滤算法。
i
是“读指针”,它遍历批处理中的每一个原始条目。j
是“写指针”,它指向下一个有效folio应该被放置的位置。- 在循环中,
if (!xa_is_value(folio))
检查当前条目是否不是一个影子条目。 - 如果是真正的folio,就执行
fbatch->folios[j++] = folio;
,将其“拷贝”到写指针的位置,然后写指针前进。 - 如果不是真正的folio(是影子条目),写指针
j
不会前进,这个条目在下一次有效的拷贝中就会被覆盖掉。
- 更新计数: 循环结束后,
j
的值就是批处理中所有真实folio的数量。fbatch->nr = j;
更新批处理的计数值,逻辑上“截断”了数组,丢弃了末尾的所有无效条目。
代码分析
/*** deactivate_file_folio() - 降级一个文件folio。* @folio: 要降级的folio。** 此函数向VM暗示@folio是一个很好的回收候选者,例如,* 如果因为folio是脏的或正在回写而导致其失效操作失败。** 上下文: 调用者持有对folio的引用。*/
void deactivate_file_folio(struct folio *folio)
{// 检查1: 如果folio是不可驱逐的(例如被mlock),则降级无意义。if (folio_test_unevictable(folio))return;// 检查2: 如果启用了新一代LRU,则使用其机制来“老化”这个folio。if (lru_gen_enabled() && lru_gen_clear_refs(folio))return;// 检查3: 回退到传统LRU,将folio添加到per-CPU的“待降级”批处理队列中。folio_batch_add_and_move(folio, lru_deactivate_file, true);
}/*** folio_batch_remove_exceptionals() - 从批处理中修剪掉非folio的条目。* @fbatch: 要修剪的批处理。** find_get_entries()会用folio和影子/交换/DAX条目填充批处理。* 此函数从@fbatch中修剪掉所有非folio的条目,且不留下空洞,* 以便将其传递给只处理folio的批处理操作。*/
void folio_batch_remove_exceptionals(struct folio_batch *fbatch)
{unsigned int i, j; // i是读指针,j是写指针// 使用双指针算法进行原地过滤。for (i = 0, j = 0; i < folio_batch_count(fbatch); i++) {struct folio *folio = fbatch->folios[i];// 检查当前条目是否不是一个“值条目”(即,它是一个真正的folio)。if (!xa_is_value(folio))// 如果是,则将其移动到写指针的位置,并移动写指针。fbatch->folios[j++] = folio;}// 更新批处理的有效数量为真实folio的数量。fbatch->nr = j;
}