[Linux]学习笔记系列 -- mm/swap.c 交换机制(Swap Mechanism) 物理内存的虚拟扩展

发布于:2025-09-03 ⋅ 阅读:(18) ⋅ 点赞:(0)


在这里插入图片描述

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)过程:

  1. 触发:当系统内存不足时,内核的页面回收逻辑(由kswapd后台线程或直接回收触发,主要实现在mm/vmscan.c)被激活。
  2. 选择牺牲页(Victim Selection):页面回收算法会选择一个合适的页面进行换出。它优先选择匿名页(Anonymous Page),即那些不与任何文件关联的内存,如进程的堆(heap)和栈(stack)。
  3. 分配交换槽(Swap Slot):内核在已配置的交换空间中找到一个空闲的槽位。
  4. 写入磁盘:内核将牺牲页的内容异步地写入到分配好的磁盘交换槽中。
  5. 修改页表项(PTE):一旦写入完成,内核会修改该内存页对应的进程页表项(PTE)。它会清除PTE中的物理页帧号,并将“存在位”(Present Bit)清零,然后将描述交换区位置的**交换条目(swp_entry_t)**存入PTE中。
  6. 释放物理页:最后,这个物理页面被释放,可以被系统用于其他目的。

换入(Swap-in)过程:

  1. 触发缺页异常(Page Fault):当进程试图访问一个已经被换出的内存地址时,CPU会发现其PTE的“存在位”为0,从而触发一个缺页异常。
  2. 异常处理:内核的缺页异常处理函数接管控制。它检查PTE的内容,发现里面存储的不是0,而是一个有效的交换条目。
  3. 从磁盘读取:内核根据交换条目中的信息,在交换空间中找到对应的页面数据。
  4. 分配物理页:内核分配一个新的空闲物理内存页。
  5. 读入数据:内核将数据从磁盘读入到这个新分配的物理页中。
  6. 更新页表:内核更新PTE,使其指向新的物理页,并设置“存在位”。
  7. 恢复执行:缺页异常处理完毕,进程从被中断的指令处恢复执行,此时它对内存的访问可以正常进行了。
它的主要优势体现在哪些方面?
  • 虚拟内存扩展:以较低的成本(磁盘空间远比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 可能关联的所有子系统,然后才能释放物理内存。

  1. 处理特殊内存类型 (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
    • 对于普通内存,这两个条件都为假。
  2. 从页缓存解绑 (page_cache_release):

    • 这是一个关键步骤。当一个 folio 被用作页缓存时,它会被关联到一个 inodeaddress_space 上。page_cache_release 会执行必要的清理,例如,递减 address_space 的引用计数。这是确保当一个文件的所有页缓存都被释放后,其 address_space 也能被正确回收的关键。
  3. 清理延迟拆分 (folio_unqueue_deferred_split):

    • 为了性能,当内核需要将一个大的folio(例如16KB)拆分成小的基页(4KB)时,这个拆分操作可能会被延迟执行。这个 folio 会被放进一个“待拆分”队列。__folio_put 在这里检查该 folio 是否还在这个队列中,如果是,就将其移除,因为既然整个 folio 都要被释放了,再执行拆分就毫无意义了。
  4. Cgroup 内存记账返还 (mem_cgroup_uncharge):

    • 如果内核启用了内存控制组(cgroups),那么当一个 folio 被分配时,它的内存使用量会被“记账”(charge)到分配它的那个cgroup上。mem_cgroup_uncharge 执行相反的操作:它将这个 folio 的大小从其所属 cgroup 的内存使用量中减去。这是实现容器内存限制和隔离的基础。
  5. 返还物理内存 (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链表中。

实现原理分析

这个机制是典型的用空间换时间、分摊锁开销的优化策略。

  1. Per-CPU数据结构 (cpu_fbatches):

    • 内核为每个CPU核心都定义了一个cpu_fbatches结构体。这意味着每个CPU都有自己独立的批处理队列(lru_add, lru_deactivate等)。
    • 优点: 当一个CPU上的代码需要将一个页面加入LRU时,它只需访问自己私有的cpu_fbatches结构。因为没有其他CPU会访问这个结构,所以几乎没有缓存争用,性能极高。
    • folio_batch: 这是一个简单的数据结构,本质上是一个固定大小的folio指针数组,用于暂存待处理的folio。
  2. 分级锁 (local_lock vs local_lock_irq):

    • local_lock_t: 这是一个轻量级锁。在多核系统上,它通过禁止抢占来保护数据。这足以防止在同一个CPU上的不同任务之间产生竞争。
    • local_lock_irq: 这是一个更强的锁,它会同时禁止抢占和本地中断。用于保护那些可能在中断上下文中也被访问的数据(如lru_move_tail)。
  3. 批处理与清空 (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链表中,最后释放一次全局锁。
  4. 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的folio
	struct folio_batch lru_deactivate_file; // 待取消激活的文件folio
	struct folio_batch lru_deactivate;      // 待取消激活的匿名folio
	struct folio_batch lru_lazyfree;        // 待惰性释放的folio
#ifdef CONFIG_SMP
	struct 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 是一个高度特化的工作函数,其设计前提是调用者已经确保了必要的同步(通常是禁用了抢占)。

  1. 目标明确: 函数接收一个 cpu 编号,并通过 per_cpu 宏直接定位到该CPU的 cpu_fbatches 实例。这使得它可以被用于清空当前CPU的队列,也可以在CPU热拔插等场景下,由其他CPU来清空一个即将离线的CPU的队列。

  2. 分批处理: 函数依次处理 cpu_fbatches 结构中的每一个 folio_batchlru_add, lru_move_tail, lru_deactivate_file 等。

  3. 通用提交逻辑 (folio_batch_move_lru): 对于每个非空的批处理队列(通过 folio_batch_count 检查),它都会调用一个外部函数 folio_batch_move_lru。这个函数(未在此代码中显示)是真正实现“提交”的地方。我们可以推断其内部逻辑为:
    a. 获取保护相应全局LRU链表的重量级锁(通常是一个需要禁用中断的自旋锁)。
    b. 在一个循环中,将批处理队列中的所有folio指针逐一添加到全局LRU链表中。
    c. 释放全局LRU链表的锁。
    d. 清空本地的 folio_batch 队列。

  4. 特殊的中断安全处理 (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)链表的“冷”端,从而“暗示”内存管理系统这个页面已不再活跃。

  1. 安全检查 (folio_test_unevictable):

    • 函数首先检查folio是否被标记为“不可驱逐”(unevictable)。这种情况发生在页面被mlock()系统调用锁定在内存中,或者用于某些特殊的内核数据结构。
    • 对于不可驱逐的folio,尝试将其降级是毫无意义的,因为无论如何它都不会被回收。因此函数直接返回。
  2. 新一代LRU处理 (lru_gen_enabled):

    • 现代Linux内核引入了更先进的多代LRU(Multi-Generational LRU)算法,由lru_gen_enabled()检查是否启用。
    • 如果启用了新算法,它会调用lru_gen_clear_refs。这个函数会尝试清除folio的“被访问”标志位。在新算法中,一个没有被访问过的folio自然就成了回收的候选者。如果这个操作成功了,就达到了“降级”的目的,函数直接返回。
  3. 传统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。

  1. 问题背景: find_lock_entries 为了效率,会把XArray中的所有条目——无论是真正的folio指针还是代表文件空洞的“影子条目”(value entries)——都抓取到folio_batch中。
  2. 过滤算法:
    • 函数使用了一个经典的双指针、原地(in-place)过滤算法
    • i 是“读指针”,它遍历批处理中的每一个原始条目。
    • j 是“写指针”,它指向下一个有效folio应该被放置的位置。
    • 在循环中,if (!xa_is_value(folio)) 检查当前条目是否不是一个影子条目。
    • 如果是真正的folio,就执行 fbatch->folios[j++] = folio;,将其“拷贝”到写指针的位置,然后写指针前进。
    • 如果不是真正的folio(是影子条目),写指针j不会前进,这个条目在下一次有效的拷贝中就会被覆盖掉。
  3. 更新计数: 循环结束后,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;
}

网站公告

今日签到

点亮在社区的每一天
去签到