[toc]
mm/workingset.c 工作集检测(Working Set Detection) 提升页面回收效率的热点内存识别机制
历史与背景
这项技术是为了解决什么特定问题而诞生的?
mm/workingset.c 中的代码是为了解决传统页面回收算法(如vmscan.c中的两阶段LRU)的一个核心痛点:无法精确区分真正的“工作集”和短暂的、非核心的内存使用。
工作集(Working Set)指的是一个进程在当前阶段为了高效运行而需要频繁访问的内存页面集合。传统LRU算法通过active/inactive链表来近似这个集合,但存在以下问题:
- 抖动(Thrashing):当内存压力增大时,一个暂时不活动的进程(例如,用户切换到了另一个窗口),其工作集页面可能会被从
active链表老化到inactive链表,并最终被回收。当用户切回该进程时,进程会遭遇大量的缺页中断(page faults),需要从磁盘或交换空间重新读取其工作集,导致系统响应缓慢,磁盘I/O飙升。workingset.c的核心目标就是识别并保护这些被错误回收的工作集页面,从而抑制抖动。 - 无法区分不同原因的缺页中断:内核无法轻易区分一个缺页中断是因为访问一个全新的页面(如首次读取文件),还是因为访问一个刚刚被回收的、本应属于工作集的页面。
workingset.c提供了一种机制来做出这种区分。
它的发展经历了哪些重要的里程碑或版本迭代?
workingset.c 的实现是由内核开发者 Johannes Weiner 引入的,是对传统LRU机制的一次重要增强。其核心创新是引入了**“影子条目”(Shadow Entries)**机制。
- 概念提出与实现:该机制被设计为一种低开销的方式,用于在页面被从LRU链表中换出后,继续“追踪”它一小段时间。
- 与
vmscan.c的集成:workingset.c的代码被深度集成到vmscan.c的页面回收路径和mm/memory.c的缺页中断处理路径中。它不是一个独立的算法,而是对现有算法的一个“插件式”增强。
目前该技术的社区活跃度和主流应用情况如何?
工作集检测是当前Linux内核内存管理中一项标准的、默认开启的基础功能。它不是一个可选模块,而是内存管理子系统不可或-的一部分。它的效果体现在所有现代Linux系统中,尤其是在以下场景中:
- 桌面和移动环境(如Android):用户频繁地在多个应用之间切换,工作集保护机制对于保持系统流畅响应至关重要。
- 内存过载(Over-committed)的服务器:在虚拟化和容器环境中,物理内存经常被超额分配。精确的工作集检测可以帮助内核在回收内存时做出更明智的决策,避免杀死错误的进程或导致关键服务性能下降。
核心原理与设计
它的核心工作原理是什么?
workingset.c 的核心是巧妙的**“影子条目”**机制,它像是一个页面的“墓碑”,记录了一个页面刚刚“死亡”(被回收)的事实。
- 创建影子条目:当页面回收器(
vmscan.c)决定从inactiveLRU链表中回收一个页面时,它不会立即完全忘记这个页面。相反,它会在一个特殊的数据结构——基于Radix Tree的page_owner中,为这个页面创建一个影子条目。这个影子条目非常小,只包含足够识别该页面的信息(不包含数据),因此内存开销很低。 - 处理缺页中断:稍后,如果一个进程试图访问这个刚刚被回收的页面,就会触发一个缺页中断。
- 检查影子条目:在处理缺页中断的路径上,内核会调用
workingset_refault()函数。这个函数会检查刚才发生中断的页面地址是否存在对应的影子条目。 - 做出决策:
- 情况A:存在影子条目(Refault):这是一个强烈的信号,表明这个页面是工作集的一部分!因为它刚被回收就立即被再次需要。内核会认为这次缺页中断是一次“再激活”(refault)。作为奖励,当这个页面被重新读入内存时,它会被直接放入
activeLRU链表的头部,绕过了inactive链表。这可以有效防止该页面在下一次回收扫描中被立即再次回收,从而打破了抖动的恶性循环。之后,这个影子条目被移除。 - 情况B:不存在影子条目(Cold Fault):这表明这是一次真正的“冷”缺页中断,例如进程在访问一个全新的、之前从未访问过的页面。这个页面会按照标准流程被放入
inactiveLRU链表的头部。
- 情况A:存在影子条目(Refault):这是一个强烈的信号,表明这个页面是工作集的一部分!因为它刚被回收就立即被再次需要。内核会认为这次缺页中断是一次“再激活”(refault)。作为奖励,当这个页面被重新读入内存时,它会被直接放入
通过统计“再激活”的次数和频率,内核还能对一个进程或整个系统的工作集大小和活跃度进行更精确的评估。
它的主要优势体现在哪些方面?
- 抑制抖动:核心优势。通过快速识别和保护被错误回收的工作集页面,显著提高了系统在内存压力下的响应速度。
- 提高回收效率:它为页面回收器提供了更准确的“热度”信号,帮助
vmscan.c做出更好的回收决策,避免浪费I/O在频繁换入换出同一批页面上。 - 更好的可观察性:通过
/proc/meminfo中的PgRefault等指标,它为系统管理员提供了观察和诊断内存抖动问题的数据。
它存在哪些已知的劣势、局限性或在特定场景下的不适用性?
- CPU和内存开销:虽然影子条目的开销很低,但创建、查找和销毁它们仍然需要消耗一定的CPU时间和内存。在极端的、高频率的回收和缺页场景下,这部分开销可能会变得显著。
- 启发式而非完美:它仍然是一种启发式算法。某些特殊的内存访问模式可能无法从中受益,或者在极少数情况下可能做出错误的判断。
- 复杂性:它为本已极其复杂的内存管理子系统增加了又一层逻辑,使得理解和调试变得更加困难。
使用场景
在哪些具体的业务或技术场景下,它是首选解决方案?
它不是一个“可选方案”,而是内核的内建机制。其正面效果在以下场景中表现得最为明显:
- 交互式应用:如上所述,桌面、IDE、浏览器等,用户在多个任务间切换,工作集保护能确保切换回来的应用能快速恢复响应。
- 内存数据库:当数据库的工作集(如频繁查询的索引和数据)略大于可用内存时,该机制能保护核心数据不被后台的批量操作冲刷出内存。
- Java虚拟机(JVM):JVM的垃圾回收(GC)过程可能会导致应用的内存访问模式剧烈变化。工作集检测有助于在GC之后快速恢复应用的性能。
是否有不推荐使用该技术的场景?为什么?
不能“不推荐使用”,因为它默认集成且对绝大多数场景有益。但可以理解在哪些场景下其作用有限:
- 内存充裕的系统:如果系统几乎没有内存压力,页面回收器很少运行,那么工作集检测机制也大部分时间处于空闲状态。
- 内存锁定的应用(硬实时/HPC):如果一个应用通过
mlock()系统调用将其关键数据段锁定在物理内存中,这些内存从一开始就不会参与页面回收,因此工作集检测对这部分内存无效。
对比分析
请将其 与 其他相似技术 进行详细对比。
workingset.c 与 vmscan.c 的关系是协作而非竞争。它不是vmscan.c的替代品,而是它的一个“智能顾问”。
| 特性 | workingset.c (工作集检测) |
vmscan.c (页面回收器) |
|---|---|---|
| 角色定位 | 信息提供者(Advisor) | 决策者和执行者(Executor) |
| 核心功能 | 通过“影子条目”机制,识别被回收后又立即被访问的页面(refault),从而判断页面的“热度”。 | 扫描active/inactive LRU链表,根据页面的访问状态和类型,决定并执行页面的回收动作(丢弃、写回、换出)。 |
| 工作时机 | 在页面被回收时创建影子条目,在缺页中断时检查影子条目。 | 在内存压力下(由kswapd或直接回收触发)被激活,开始扫描和回收。 |
| 交互关系 | vmscan.c在回收页面时调用workingset.c来创建影子条目。缺页中断处理代码调用workingset.c,其结果(是否是refault)会影响新页面被放入哪个LRU链表,从而影响vmscan.c未来的决策。 |
vmscan.c是页面回收的主导者。它利用workingset.c提供的信息,但最终的回收决策由它自己做出。 |
| 最终目标 | 提高回收决策的质量,减少不必要的I/O,抑制抖动。 | 释放物理内存,满足新的内存分配请求,维持系统运行。 |
与多代LRU(MGLRU)的对比:
MGLRU是比workingset.c所增强的传统两阶段LRU更进一步的演进。
workingset.c是对两阶段LRU的一个补丁式增强,它在不改变核心LRU扫描逻辑的情况下,通过外部信息(影子条目)来修正其行为。- MGLRU 则是一种全新的页面分类和老化算法,它用多个“代”(generations)来取代简单的
active/inactive二分法,试图从根本上更精确地模拟页面的生命周期和热度。可以说,MGLRU在设计上就内化了workingset.c想要解决的问题。
workingset_init: 工作集检测与影子LRU初始化
本代码片段负责初始化内核中一个高级的内存管理特性:工作集(Working Set)检测。其核心功能是创建一个“影子LRU” (shadow_nodes) 数据结构和一个相应的 shrinker。这个机制在后台追踪近期被从内存中换出(Evicted)的页面,并监测它们是否被迅速地重新载入(Refault)。通过分析这种“换出-重载”模式,内核可以更智能地判断哪些页面是进程真正活跃使用的工作集,从而在未来做出更优的页面回收决策,避免内存颠簸(Thrashing)。
实现原理分析
该机制的实现是内核LRU页面置换算法的一个重要优化,它通过一个轻量级的元数据缓存(影子条目)来获取关于页面访问历史的额外信息。
“换出代价”的量化 (
bucket_order计算):- 内核需要一种方法来衡量一个页面被换出后,过了“多久”才被重新载入。这个“多久”不是以时间衡量,而是以期间发生的其他内存分配活动的数量来衡量,这被称为重载距离(Refault Distance)。
- 为了在一个有限的位宽(
BITS_PER_LONG)内记录这个可能非常大的距离,代码采用了一种**分桶(Bucketing)**策略。 timestamp_bits计算出页标志位中可用于存储时间戳(一个随内存活动递增的计数器)的位数。max_order计算出表示系统中总页数所需的位数(即log2(totalram_pages))。- 如果总页数太大,以至于重载距离无法直接用
timestamp_bits来表示(会导致计数器过快回绕),bucket_order就会被设置为一个正值。它作为一个移位因子,将庞大的重载距离压缩到一个较小的范围内,相当于将连续的距离值分到同一个“桶”里。这是一种以牺牲精度来换取更大测量范围的典型技巧。
Shrinker 框架集成:
- Linux 内核使用
shrinker机制来统一管理各种可收缩的内存缓存。当系统内存不足时,内核会调用所有已注册的shrinker的回调函数来释放内存。 workingset_init创建了一个专门用于管理“影子条目”元数据的shrinker。标志SHRINKER_NUMA_AWARE和SHRINKER_MEMCG_AWARE表明该机制能够感知 NUMA 架构和内存控制组(cgroups),在相应的粒度上进行工作。
- Linux 内核使用
影子LRU (
shadow_nodes):list_lru_init_memcg_key初始化了核心数据结构shadow_nodes。这是一个list_lru,专门用于存储被换出页面的元数据(影子条目),而不是页面本身。- 当一个页面被从页缓存中换出时,内核不会立即丢弃它的所有信息,而是会创建一个小的“影子条目”放入
shadow_nodes链表中,并记录下当时的“时间戳”。 count_shadow_nodes和scan_shadow_nodes是注册给shrinker的回调函数。它们的作用是,当系统内存极度紧张,连存储这些元数据的内存都需要回收时,shrinker机制会调用它们来清理(释放)最旧的影子条目。
特定场景分析:单核、无MMU的STM32H750平台
功能相关性:优化 uClinux 的页缓存
- 在无 MMU 的 uClinux 系统上,虽然没有匿名页的换出,但**页缓存(Page Cache)**的管理至关重要,尤其是对于从外部 Flash(如 QSPI Flash)执行代码(XIP)或读取数据的场景。
- 工作集检测机制在这里的核心作用是优化页缓存的替换策略。当 SRAM 不足时,内核需要决定丢弃哪些缓存的页面。如果它错误地丢弃了一个程序马上就要用到的代码页,就会立即引发一次昂贵的从 Flash 重新加载的操作(即“重载”)。
- 通过
workingset机制,内核能够识别出那些被频繁重载的页面,并将它们标记为活跃工作集的一部分,从而在未来的内存回收中倾向于保留它们,而优先回收那些被换出后很久都未被再次访问的“冷”页面。这对于提升应用性能、减少 I/O 抖动至关重要。
平台特性影响
- NUMA 与 Memcg: STM32H750 是一个典型的 UMA(统一内存访问)平台,并且通常不使用内存控制组。因此,
SHRINKER_NUMA_AWARE和SHRINKER_MEMCG_AWARE标志虽然存在,但该机制的实际行为会退化为管理一个单一的、全局的影子 LRU 链表,这完全符合该平台的硬件架构。 bucket_order计算: STM32H750 的总内存totalram_pages()相对较小。因此,在计算中max_order的值会很小,很可能小于timestamp_bits。这将导致bucket_order被设置为0。这意味着在该平台上,内核可以直接使用高精度的时间戳来计算重载距离,无需进行分桶压缩,这对于内存较小的系统是完全合理的。- 单核并发:
list_lru内部的锁(list_lru->lock)是 IRQ 安全的。在单核抢占式内核上,这个锁可以有效防止在更新shadow_nodes链表时被中断服务程序打断,从而保证了数据结构的一致性。
代码分析
1 | /* |
工作集影子节点 LRU 状态同步:workingset_update_node
本代码片段定义了工作集(Working Set)检测机制的核心更新函数 workingset_update_node。其主要功能是:在页缓存的 XArray 树节点 (xa_node) 内容发生变化时被调用,动态地判断该节点是否应该被纳入“影子节点 LRU 链表” (shadow_nodes) 的管理之下,并同步更新其在链表中的存在状态以及相关的内核统计计数。
实现原理分析
该函数是工作集检测机制与 list_lru 回收框架之间的关键“胶水”逻辑。它通过一个精确的条件判断,决定一个 xa_node 的生命周期状态。
状态判断条件: 函数的核心是
if (node->count && node->count == node->nr_values)这个条件。node->count记录了xa_node中条目的总数(包括实际的页面指针和影子条目)。node->nr_values专门记录了影子条目的数量。- 因此,当且仅当一个节点非空 (
node->count != 0) 并且其所有条目都是影子条目时,这个条件为真。这种节点被称为“纯影子节点”,它不再持有任何有用的页面缓存数据,但其包含的影子条目仍然对工作集检测有价值。然而,这些节点本身占用了内存,因此它们是内存回收的候选对象。
动态链表管理:
- 当一个节点变成纯影子节点时,
workingset_update_node会检查它是否已在shadow_nodes链表上 (list_empty)。如果不在,就通过list_lru_add_obj将其加入链表尾部,并增加WORKINGSET_NODES计数。 - 反之,当一个节点不再是纯影子节点时(因为它变为空,或者一个页面被插入其中),函数会检查它是否仍在链表上。如果在,就通过
list_lru_del_obj将其移除,并减少WORKINGSET_NODES计数。
- 当一个节点变成纯影子节点时,
锁与同步:
lockdep_assert_held(&node->array->xa_lock)断言明确指出,调用此函数时必须持有 XArray 的锁(即页缓存的i_pages锁)。这个锁保证了在函数执行期间,node->count和node->nr_values的值不会被并发修改,从而确保了状态判断的原子性和正确性。list_empty检查是在持有i_pages锁的情况下进行的,这是安全的。而list_lru_add_obj和list_lru_del_obj内部会获取shadow_nodes自己的自旋锁,实现了对 LRU 链表自身的保护。这种两级锁的策略确保了数据的一致性。
优化: 代码通过
if (list_empty(...))的预检查,避免了在节点的 LRU 状态已经是期望状态时,去调用list_lru_add/del_obj,从而减少了不必要的 LRU 锁获取和释放开销。
代码分析
1 | /** |
工作集影子节点收缩器:count_shadow_nodes, scan_shadow_nodes 及 shadow_lru_isolate
本代码片段展示了 Linux 内核内存管理中,用于回收“工作集”检测机制所使用的“影子条目”的 shrinker 实现。其核心功能是:在系统面临内存压力时,精确地计算应回收的、用于存储影子条目的 xa_node 数量,并安全地从 LRU 链表上将其隔离和释放,从而回收这部分元数据所占用的内存。这套机制由三个函数协同完成:count_shadow_nodes 负责决策,scan_shadow_nodes 负责发起扫描,shadow_lru_isolate 负责具体执行。
实现原理分析
此机制是内核 shrinker 框架的一个实例,专门用于管理工作集检测(working set detection)的内存开销。工作集检测通过在页缓存(Page Cache)的基数树(Radix Tree,现为 XArray)中留下“影子条目”来判断一个页面被回收后是否很快被重新访问(即“refault”)。这些影子条目本身存储在 xa_node 结构中,也消耗内存,因此需要被管理和回收。
回收数量决策 (
count_shadow_nodes):- 此函数是 shrinker 的
count回调,用于判断有多少个影子节点可以被回收。 - 它首先通过
list_lru_shrink_count获取当前 LRU 链表上影子节点的总数nodes。 - 核心原理是计算一个允许存在的最大影子节点数
max_nodes。这个最大值与系统或内存控制组(cgroup)中的总页数pages成正比。其计算公式为max_nodes = pages >> (XA_CHUNK_SHIFT - 3)。 - 公式解析:
XA_CHUNK_SHIFT是 XArray 一个节点能容纳的条目数量的对数(通常为6,代表64个条目)。- 3等价于* 8。因此,公式可以理解为max_nodes = pages / (ENTRIES_PER_NODE / 8)。这是一种启发式算法,其意图是:我们允许用于存储影子节点的内存开销,与被监控的总内存大小成一个固定的比例。这个比例被设定为,在最坏情况下(每个节点只有一个影子条目),我们最多愿意为每ENTRIES_PER_NODE / 8个页面就分配一个xa_node来跟踪其工作集。这在内存开销和工作集检测精度之间取得了平衡。 - 最终,函数返回
nodes - max_nodes,即超出阈值的、需要被回收的节点数量。
- 此函数是 shrinker 的
节点扫描与隔离 (
scan_shadow_nodes和shadow_lru_isolate):scan_shadow_nodes是 shrinker 的scan回调。它非常简单,直接调用list_lru_shrink_walk_irq函数,该函数会遍历shadow_nodes这个 LRU 链表,并对链表上的每个节点调用shadow_lru_isolate回调函数。shadow_lru_isolate是实际执行节点回收的核心逻辑。其过程体现了内核中复杂的锁同步技巧:
a. 锁顺序反转: 正常情况下,修改页缓存树需要先持有address_space的i_pages锁,再持有 LRU 链表锁。但此函数从 LRU 链表开始,已经持有了 LRU 锁。为了安全地操作节点的address_space,它必须再去获取i_pages锁。
b. 非阻塞尝试: 由于在持有自旋锁(LRU 锁)时不能睡眠,它必须使用xa_trylock来尝试获取i_pages锁。如果失败,说明该address_space正被其他任务锁定,此时必须放弃,释放 LRU 锁并返回LRU_RETRY,以便 shrinker 稍后重试。对于与 inode 关联的页缓存,还需额外尝试获取inode->i_lock。
c. 隔离与删除: 成功获取所有需要的锁之后,它将节点从 LRU 链表中移除 (list_lru_isolate),更新相关的统计计数,然后调用xa_delete_node将节点从 XArray 中彻底删除,并释放其占用的内存。
d. 状态验证: 通过WARN_ON_ONCE宏进行健全性检查,确保被回收的节点确实只包含影子条目 (node->nr_values),而不包含任何实际的页面 (node->count)。
特定场景分析:单核、无MMU的 STM32H750 平台
功能相关性
在标准的、用于嵌入式场景的 STM32H750 应用中,通常不会启用完整的 Linux 内存管理子系统,尤其是像工作集检测这样的高级功能。工作集检测(CONFIG_WORKING_SET_DETECTION)的核心前提是存在基于页(Page)的按需换页(demand paging)和页面回收机制。
- 无 MMU 的影响: 在没有内存管理单元(MMU)的 ARMv7-M 架构上,无法实现虚拟内存,也就没有传统意义上的“页错误”(Page Fault)和页面换出(swap out)。内存管理较为直接,不存在页面被“回收”到后备存储后又被“refault”回内存的场景。
- 结论: 因此,工作集检测机制及其影子条目对于无 MMU 的平台是没有意义的。在为 STM32H750 这类平台配置内核时,
CONFIG_WORKING_SET_DETECTION选项几乎总会被禁用。所以,这段代码(count_shadow_nodes,scan_shadow_nodes,shadow_lru_isolate)在功能上与该特定平台无关,它们对应的代码路径不会被编译进最终的内核镜像,也不会被执行。
单核环境影响
尽管此代码在该平台上不适用,但我们可以从纯粹的软件角度分析其在单核环境下的行为。
代码中使用了 spin_unlock_irq 等自旋锁原语。在单核处理器上,获取一个 spin_lock_irqsave 锁的操作等价于“保存当前中断状态并禁用本地中断”。释放锁则恢复中断状态。这种机制足以防止在临界区代码执行期间被中断服务程序打断,从而保证了对共享数据(如 LRU 链表)访问的原子性。trylock 逻辑在这种环境下依然是正确的,用于处理需要获取多个“锁”(实为中断屏蔽状态)时的复杂同步,避免死锁。因此,代码中的同步机制在单核环境下是正确且有效的。
代码分析
1 | /** |









