[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 的核心是巧妙的**“影子条目”**机制,它像是一个页面的“墓碑”,记录了一个页面刚刚“死亡”(被回收)的事实。

  1. 创建影子条目:当页面回收器(vmscan.c)决定从inactive LRU链表中回收一个页面时,它不会立即完全忘记这个页面。相反,它会在一个特殊的数据结构——基于Radix Tree的page_owner中,为这个页面创建一个影子条目。这个影子条目非常小,只包含足够识别该页面的信息(不包含数据),因此内存开销很低。
  2. 处理缺页中断:稍后,如果一个进程试图访问这个刚刚被回收的页面,就会触发一个缺页中断。
  3. 检查影子条目:在处理缺页中断的路径上,内核会调用workingset_refault()函数。这个函数会检查刚才发生中断的页面地址是否存在对应的影子条目
  4. 做出决策
    • 情况A:存在影子条目(Refault):这是一个强烈的信号,表明这个页面是工作集的一部分!因为它刚被回收就立即被再次需要。内核会认为这次缺页中断是一次“再激活”(refault)。作为奖励,当这个页面被重新读入内存时,它会被直接放入active LRU链表的头部,绕过了inactive链表。这可以有效防止该页面在下一次回收扫描中被立即再次回收,从而打破了抖动的恶性循环。之后,这个影子条目被移除。
    • 情况B:不存在影子条目(Cold Fault):这表明这是一次真正的“冷”缺页中断,例如进程在访问一个全新的、之前从未访问过的页面。这个页面会按照标准流程被放入inactive LRU链表的头部。

通过统计“再激活”的次数和频率,内核还能对一个进程或整个系统的工作集大小和活跃度进行更精确的评估。

它的主要优势体现在哪些方面?

  • 抑制抖动:核心优势。通过快速识别和保护被错误回收的工作集页面,显著提高了系统在内存压力下的响应速度。
  • 提高回收效率:它为页面回收器提供了更准确的“热度”信号,帮助vmscan.c做出更好的回收决策,避免浪费I/O在频繁换入换出同一批页面上。
  • 更好的可观察性:通过/proc/meminfo中的PgRefault等指标,它为系统管理员提供了观察和诊断内存抖动问题的数据。

它存在哪些已知的劣势、局限性或在特定场景下的不适用性?

  • CPU和内存开销:虽然影子条目的开销很低,但创建、查找和销毁它们仍然需要消耗一定的CPU时间和内存。在极端的、高频率的回收和缺页场景下,这部分开销可能会变得显著。
  • 启发式而非完美:它仍然是一种启发式算法。某些特殊的内存访问模式可能无法从中受益,或者在极少数情况下可能做出错误的判断。
  • 复杂性:它为本已极其复杂的内存管理子系统增加了又一层逻辑,使得理解和调试变得更加困难。

使用场景

在哪些具体的业务或技术场景下,它是首选解决方案?

它不是一个“可选方案”,而是内核的内建机制。其正面效果在以下场景中表现得最为明显:

  • 交互式应用:如上所述,桌面、IDE、浏览器等,用户在多个任务间切换,工作集保护能确保切换回来的应用能快速恢复响应。
  • 内存数据库:当数据库的工作集(如频繁查询的索引和数据)略大于可用内存时,该机制能保护核心数据不被后台的批量操作冲刷出内存。
  • Java虚拟机(JVM):JVM的垃圾回收(GC)过程可能会导致应用的内存访问模式剧烈变化。工作集检测有助于在GC之后快速恢复应用的性能。

是否有不推荐使用该技术的场景?为什么?

不能“不推荐使用”,因为它默认集成且对绝大多数场景有益。但可以理解在哪些场景下其作用有限

  • 内存充裕的系统:如果系统几乎没有内存压力,页面回收器很少运行,那么工作集检测机制也大部分时间处于空闲状态。
  • 内存锁定的应用(硬实时/HPC):如果一个应用通过mlock()系统调用将其关键数据段锁定在物理内存中,这些内存从一开始就不会参与页面回收,因此工作集检测对这部分内存无效。

对比分析

请将其 与 其他相似技术 进行详细对比。

workingset.cvmscan.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页面置换算法的一个重要优化,它通过一个轻量级的元数据缓存(影子条目)来获取关于页面访问历史的额外信息。

  1. “换出代价”的量化 (bucket_order 计算):

    • 内核需要一种方法来衡量一个页面被换出后,过了“多久”才被重新载入。这个“多久”不是以时间衡量,而是以期间发生的其他内存分配活动的数量来衡量,这被称为重载距离(Refault Distance)
    • 为了在一个有限的位宽(BITS_PER_LONG)内记录这个可能非常大的距离,代码采用了一种**分桶(Bucketing)**策略。
    • timestamp_bits 计算出页标志位中可用于存储时间戳(一个随内存活动递增的计数器)的位数。
    • max_order 计算出表示系统中总页数所需的位数(即 log2(totalram_pages))。
    • 如果总页数太大,以至于重载距离无法直接用 timestamp_bits 来表示(会导致计数器过快回绕),bucket_order 就会被设置为一个正值。它作为一个移位因子,将庞大的重载距离压缩到一个较小的范围内,相当于将连续的距离值分到同一个“桶”里。这是一种以牺牲精度来换取更大测量范围的典型技巧。
  2. Shrinker 框架集成:

    • Linux 内核使用 shrinker 机制来统一管理各种可收缩的内存缓存。当系统内存不足时,内核会调用所有已注册的 shrinker 的回调函数来释放内存。
    • workingset_init 创建了一个专门用于管理“影子条目”元数据的 shrinker。标志 SHRINKER_NUMA_AWARESHRINKER_MEMCG_AWARE 表明该机制能够感知 NUMA 架构和内存控制组(cgroups),在相应的粒度上进行工作。
  3. 影子LRU (shadow_nodes):

    • list_lru_init_memcg_key 初始化了核心数据结构 shadow_nodes。这是一个 list_lru,专门用于存储被换出页面的元数据(影子条目),而不是页面本身。
    • 当一个页面被从页缓存中换出时,内核不会立即丢弃它的所有信息,而是会创建一个小的“影子条目”放入 shadow_nodes 链表中,并记录下当时的“时间戳”。
    • count_shadow_nodesscan_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_AWARESHRINKER_MEMCG_AWARE 标志虽然存在,但该机制的实际行为会退化为管理一个单一的、全局的影子 LRU 链表,这完全符合该平台的硬件架构。
  • bucket_order 计算: STM32H750 的总内存 totalram_pages() 相对较小。因此,在计算中 max_order 的值会很小,很可能小于 timestamp_bits。这将导致 bucket_order 被设置为 0。这意味着在该平台上,内核可以直接使用高精度的时间戳来计算重载距离,无需进行分桶压缩,这对于内存较小的系统是完全合理的。
  • 单核并发: list_lru 内部的锁(list_lru->lock)是 IRQ 安全的。在单核抢占式内核上,这个锁可以有效防止在更新 shadow_nodes 链表时被中断服务程序打断,从而保证了数据结构的一致性。

代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
/*
* 我们的 list_lru->lock 是 IRQ 安全的,因为它嵌套在同样是 IRQ 安全的
* i_pages 锁内部。
*/
static struct lock_class_key shadow_nodes_key;

/**
* @brief 工作集检测机制的初始化函数。
* @return 成功返回0,失败返回负数错误码。
*/
static int __init workingset_init(void)
{
struct shrinker *workingset_shadow_shrinker;
unsigned int timestamp_bits;
unsigned int max_order;
int ret = -ENOMEM;

// 编译时检查,确保用于存储时间戳和标志位的 long 类型足够大。
BUILD_BUG_ON(BITS_PER_LONG < EVICTION_SHIFT);
/*
* 计算换出桶的大小,以覆盖最长的可操作重载距离,
* 目前该距离是内存的一半 (totalram_pages/2)。然而,内存热插拔
* 可能会在运行时增加更多页面,因此我们以 totalram_pages 为基准,
* 这样可以处理高达初始内存两倍的情况。
*/
// 计算可用于时间戳的位数。
timestamp_bits = BITS_PER_LONG - EVICTION_SHIFT;
// 计算表示总页数所需的位数 (log2)。
max_order = fls_long(totalram_pages() - 1);
// 如果表示总页数所需的位数超过了时间戳的位数,则需要分桶。
if (max_order > timestamp_bits)
// bucket_order 是一个移位值,用于压缩重载距离以防止时间戳回绕。
bucket_order = max_order - timestamp_bits;
pr_info("workingset: timestamp_bits=%d max_order=%d bucket_order=%u\n",
timestamp_bits, max_order, bucket_order);

// 分配一个 shrinker 实例,用于管理影子节点的元数据。
// SHRINKER_NUMA_AWARE 和 SHRINKER_MEMCG_AWARE 使其能感知 NUMA 和 cgroup。
workingset_shadow_shrinker = shrinker_alloc(SHRINKER_NUMA_AWARE |
SHRINKER_MEMCG_AWARE,
"mm-shadow");
if (!workingset_shadow_shrinker)
goto err;

// 初始化用于存储影子节点的 list_lru 数据结构。
ret = list_lru_init_memcg_key(&shadow_nodes, workingset_shadow_shrinker,
&shadow_nodes_key);
if (ret)
goto err_list_lru;

// 注册 shrinker 的回调函数。
workingset_shadow_shrinker->count_objects = count_shadow_nodes; // 用于计算可收缩对象数量
workingset_shadow_shrinker->scan_objects = scan_shadow_nodes; // 用于执行收缩操作
// ->count 只报告完全可丢弃的节点,因此 seeks 设为0,表示扫描开销很小。
workingset_shadow_shrinker->seeks = 0;

// 向内核注册此 shrinker,使其生效。
shrinker_register(workingset_shadow_shrinker);
return 0;

// 错误处理路径
err_list_lru:
shrinker_free(workingset_shadow_shrinker);
err:
return ret;
}
// 将 workingset_init 注册为模块初始化函数。
module_init(workingset_init);

工作集影子节点 LRU 状态同步:workingset_update_node

本代码片段定义了工作集(Working Set)检测机制的核心更新函数 workingset_update_node。其主要功能是:在页缓存的 XArray 树节点 (xa_node) 内容发生变化时被调用,动态地判断该节点是否应该被纳入“影子节点 LRU 链表” (shadow_nodes) 的管理之下,并同步更新其在链表中的存在状态以及相关的内核统计计数。

实现原理分析

该函数是工作集检测机制与 list_lru 回收框架之间的关键“胶水”逻辑。它通过一个精确的条件判断,决定一个 xa_node 的生命周期状态。

  1. 状态判断条件: 函数的核心是 if (node->count && node->count == node->nr_values) 这个条件。

    • node->count 记录了 xa_node 中条目的总数(包括实际的页面指针和影子条目)。
    • node->nr_values 专门记录了影子条目的数量。
    • 因此,当且仅当一个节点非空 (node->count != 0) 并且其所有条目都是影子条目时,这个条件为真。这种节点被称为“纯影子节点”,它不再持有任何有用的页面缓存数据,但其包含的影子条目仍然对工作集检测有价值。然而,这些节点本身占用了内存,因此它们是内存回收的候选对象。
  2. 动态链表管理:

    • 当一个节点变成纯影子节点时,workingset_update_node 会检查它是否已在 shadow_nodes 链表上 (list_empty)。如果不在,就通过 list_lru_add_obj 将其加入链表尾部,并增加 WORKINGSET_NODES 计数。
    • 反之,当一个节点不再是纯影子节点时(因为它变为空,或者一个页面被插入其中),函数会检查它是否仍在链表上。如果在,就通过 list_lru_del_obj 将其移除,并减少 WORKINGSET_NODES 计数。
  3. 锁与同步:

    • lockdep_assert_held(&node->array->xa_lock) 断言明确指出,调用此函数时必须持有 XArray 的锁(即页缓存的 i_pages 锁)。这个锁保证了在函数执行期间,node->countnode->nr_values 的值不会被并发修改,从而确保了状态判断的原子性和正确性。
    • list_empty 检查是在持有 i_pages 锁的情况下进行的,这是安全的。而 list_lru_add_objlist_lru_del_obj 内部会获取 shadow_nodes 自己的自旋锁,实现了对 LRU 链表自身的保护。这种两级锁的策略确保了数据的一致性。
  4. 优化: 代码通过 if (list_empty(...)) 的预检查,避免了在节点的 LRU 状态已经是期望状态时,去调用 list_lru_add/del_obj,从而减少了不必要的 LRU 锁获取和释放开销。

代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* @struct shadow_nodes
* @brief 一个全局的 list_lru 实例。
*
* 这个 LRU 链表专门用于追踪所有“纯影子节点”(即仅包含影子条目的 xa_node)。
* 当系统内存紧张时,内核的 shrinker 机制会遍历此链表,回收这些节点以释放内存。
*/
struct list_lru shadow_nodes;

/**
* @brief workingset_update_node - 更新一个 xa_node 在影子 LRU 链表中的状态。
* @param node: 指向被修改的 xa_node 结构体的指针。
* @note 此函数必须在持有 xa_node 所属 XArray 的锁 (xa_lock/i_pages lock) 的情况下调用。
*/
void workingset_update_node(struct xa_node *node)
{
// 获取 node 结构体所在的物理页的 page 结构体指针,用于更新页状态计数。
struct page *page = virt_to_page(node);

// 这是一个调试断言,确保调用者已经持有了保护此节点的锁。
lockdep_assert_held(&node->array->xa_lock);

// 条件判断:如果节点非空,并且节点中的总条目数等于影子条目数,
// 这意味着它是一个“纯影子节点”,应该被 LRU 链表追踪。
if (node->count && node->count == node->nr_values) {
// 如果此纯影子节点尚未在 LRU 链表上。
if (list_empty(&node->private_list)) {
// 将其添加到 shadow_nodes LRU 链表中。
list_lru_add_obj(&shadow_nodes, &node->private_list);
// 增加全局的 WORKINGSET_NODES 统计计数。
__inc_node_page_state(page, WORKINGSET_NODES);
}
} else {
// 否则,此节点或为空,或包含实际的页面,不应被 LRU 追踪。
// 如果此节点当前仍在 LRU 链表上。
if (!list_empty(&node->private_list)) {
// 将其从 shadow_nodes LRU 链表中移除。
list_lru_del_obj(&shadow_nodes, &node->private_list);
// 减少全局的 WORKINGSET_NODES 统计计数。
__dec_node_page_state(page, WORKINGSET_NODES);
}
}
}

工作集影子节点收缩器: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 结构中,也消耗内存,因此需要被管理和回收。

  1. 回收数量决策 (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,即超出阈值的、需要被回收的节点数量。
  2. 节点扫描与隔离 (scan_shadow_nodesshadow_lru_isolate):

    • scan_shadow_nodes 是 shrinker 的 scan 回调。它非常简单,直接调用 list_lru_shrink_walk_irq 函数,该函数会遍历 shadow_nodes 这个 LRU 链表,并对链表上的每个节点调用 shadow_lru_isolate 回调函数。
    • shadow_lru_isolate 是实际执行节点回收的核心逻辑。其过程体现了内核中复杂的锁同步技巧:
      a. 锁顺序反转: 正常情况下,修改页缓存树需要先持有 address_spacei_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)和页面回收机制。

  1. 无 MMU 的影响: 在没有内存管理单元(MMU)的 ARMv7-M 架构上,无法实现虚拟内存,也就没有传统意义上的“页错误”(Page Fault)和页面换出(swap out)。内存管理较为直接,不存在页面被“回收”到后备存储后又被“refault”回内存的场景。
  2. 结论: 因此,工作集检测机制及其影子条目对于无 MMU 的平台是没有意义的。在为 STM32H750 这类平台配置内核时,CONFIG_WORKING_SET_DETECTION 选项几乎总会被禁用。所以,这段代码(count_shadow_nodes, scan_shadow_nodes, shadow_lru_isolate)在功能上与该特定平台无关,它们对应的代码路径不会被编译进最终的内核镜像,也不会被执行。

单核环境影响

尽管此代码在该平台上不适用,但我们可以从纯粹的软件角度分析其在单核环境下的行为。

代码中使用了 spin_unlock_irq 等自旋锁原语。在单核处理器上,获取一个 spin_lock_irqsave 锁的操作等价于“保存当前中断状态并禁用本地中断”。释放锁则恢复中断状态。这种机制足以防止在临界区代码执行期间被中断服务程序打断,从而保证了对共享数据(如 LRU 链表)访问的原子性。trylock 逻辑在这种环境下依然是正确的,用于处理需要获取多个“锁”(实为中断屏蔽状态)时的复杂同步,避免死锁。因此,代码中的同步机制在单核环境下是正确且有效的。

代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
/**
* @brief count_shadow_nodes - 计算应被回收的影子节点的数量。
* @param shrinker: 指向 shrinker 控制块的指针 (未使用)。
* @param sc: 指向 shrinker 控制参数的指针,包含了目标节点和 cgroup 信息。
* @return unsigned long 返回值: 如果当前节点数未超过阈值,返回0;否则返回超出阈值的节点数。如果链表为空,返回 SHRINK_EMPTY。
*/
static unsigned long count_shadow_nodes(struct shrinker *shrinker,
struct shrink_control *sc)
{
unsigned long max_nodes; /// < 允许存在的最大节点数。
unsigned long nodes; /// < 当前LRU链表上的节点总数。
unsigned long pages; /// < 用于计算阈值的总页面数。

// 获取 shadow_nodes 这个 LRU 链表上的对象数量。
nodes = list_lru_shrink_count(&shadow_nodes, sc);
// 如果链表为空,则无需回收。
if (!nodes)
return SHRINK_EMPTY;

#ifdef CONFIG_MEMCG
// 如果是在一个内存控制组 (cgroup) 的上下文中进行回收。
if (sc->memcg) {
struct lruvec *lruvec;
int i;

// 刷新 cgroup 的统计数据(有速率限制)。
mem_cgroup_flush_stats_ratelimited(sc->memcg);
// 获取指定 cgroup 和 NUMA 节点的 lruvec。
lruvec = mem_cgroup_lruvec(sc->memcg, NODE_DATA(sc->nid));
// 累加所有LRU链表(活跃/不活跃的匿名页和文件页)的页面数。
for (pages = 0, i = 0; i < NR_LRU_LISTS; i++)
pages += lruvec_page_state_local(lruvec,
NR_LRU_BASE + i);
// 累加可回收和不可回收的 slab 页面数。
pages += lruvec_page_state_local(
lruvec, NR_SLAB_RECLAIMABLE_B) >> PAGE_SHIFT;
pages += lruvec_page_state_local(
lruvec, NR_SLAB_UNRECLAIMABLE_B) >> PAGE_SHIFT;
} else
#endif
// 如果不在 cgroup 上下文中,则使用整个 NUMA 节点的物理页面数。
pages = node_present_pages(sc->nid);

// 计算最大节点数。pages / (2^(XA_CHUNK_SHIFT) / 8)。
// XA_CHUNK_SHIFT 通常为 6 (64个条目),所以相当于 pages / (64/8) = pages / 8。
max_nodes = pages >> (XA_CHUNK_SHIFT - 3);

// 如果当前节点数未超过上限,则不需要回收。
if (nodes <= max_nodes)
return 0;
// 返回需要回收的节点数,即当前数量减去允许的最大数量。
return nodes - max_nodes;
}

/**
* @brief shadow_lru_isolate - 从LRU链表中隔离一个影子节点以准备回收。
* @param item: 指向节点在LRU链表中的 list_head 成员的指针。
* @param lru: 指向 list_lru_one 实例的指针。
* @param arg: 用户自定义参数 (未使用)。
* @return enum lru_status: 返回隔离操作的状态 (成功、重试等)。
* @note 此函数在执行时必须已持有 lru->lock。
*/
static enum lru_status shadow_lru_isolate(struct list_head *item,
struct list_lru_one *lru,
void *arg) __must_hold(lru->lock)
{
// 通过 list_head 指针找到其所属的 xa_node 结构体。
struct xa_node *node = container_of(item, struct xa_node, private_list);
struct address_space *mapping; /// < 节点所属的地址空间。
int ret; /// < 操作返回值。

// 从节点的 XArray 成员找到其所属的 address_space 结构体。
mapping = container_of(node->array, struct address_space, i_pages);

// 从LRU链表开始,需要反转锁的获取顺序。尝试获取 i_pages 锁。
if (!xa_trylock(&mapping->i_pages)) {
// 如果无法立即获取(锁被占用),则释放LRU锁,要求上层重试。
spin_unlock_irq(&lru->lock);
ret = LRU_RETRY;
goto out;
}

// 对于页缓存,我们还需要持有 inode 的 i_lock。
if (mapping->host != NULL) {
// 尝试获取 inode 锁。
if (!spin_trylock(&mapping->host->i_lock)) {
// 如果失败,释放已获取的 i_pages 锁和 LRU 锁,然后要求重试。
xa_unlock(&mapping->i_pages);
spin_unlock_irq(&lru->lock);
ret = LRU_RETRY;
goto out;
}
}

// 成功获取所有锁后,将节点从 LRU 链表中隔离出来。
list_lru_isolate(lru, item);
// 将此节点的页状态计数器(WORKINGSET_NODES)减一。
__dec_node_page_state(virt_to_page(node), WORKINGSET_NODES);

// 此时已将节点与LRU解耦,可以释放LRU锁了。
spin_unlock(&lru->lock);

// 警告:如果节点中没有影子条目,说明状态异常。
if (WARN_ON_ONCE(!node->nr_values))
goto out_invalid;
// 警告:如果节点中的总条目数不等于影子条目数(意味着含有页面),状态异常。
if (WARN_ON_ONCE(node->count != node->nr_values))
goto out_invalid;
// 从 XArray 中删除此节点,删除过程中会调用 workingset_update_node 更新状态。
xa_delete_node(node, workingset_update_node);
// 增加 lruvec 中节点回收的统计计数。
__inc_lruvec_kmem_state(node, WORKINGSET_NODERECLAIM);

out_invalid:
// 释放 i_pages 锁,并恢复中断。
xa_unlock_irq(&mapping->i_pages);
// 如果存在宿主 inode。
if (mapping->host != NULL) {
// 如果这个 mapping 是可收缩的,则将其 inode 添加到 LRU 中以便后续处理。
if (mapping_shrinkable(mapping))
inode_add_lru(mapping->host);
// 释放 inode 锁。
spin_unlock(&mapping->host->i_lock);
}
// 返回状态,表示已成功移除一个节点,并建议重试扫描,因为锁已被释放。
ret = LRU_REMOVED_RETRY;
out:
// 提供一个调度点,允许其他任务运行。
cond_resched();
return ret;
}

/**
* @brief scan_shadow_nodes - 扫描并回收超出限额的影子节点。
* @param shrinker: 指向 shrinker 控制块的指针 (未使用)。
* @param sc: 指向 shrinker 控制参数的指针。
* @return unsigned long: 返回成功回收的对象数量。
*/
static unsigned long scan_shadow_nodes(struct shrinker *shrinker,
struct shrink_control *sc)
{
// 调用通用的 LRU 链表遍历和收缩函数。
// 它会遍历 shadow_nodes 链表,并对每个元素调用 shadow_lru_isolate 函数。
return list_lru_shrink_walk_irq(&shadow_nodes, sc, shadow_lru_isolate,
NULL);
}