Linux内核内存回收逻辑和算法(LRU)

LRU 链表

在 Linux 中,操作系统对 LRU 的实现主要是基于一对双向链表:active 链表和 inactive 链表,这两个链表是 Linux 操作系统进行页面回收所依赖的关键数据结构,每个内存区域都存在一对这样的链表。顾名思义,那些经常被访问的处于活跃状态的页面会被放在 active 链表上,而那些虽然可能关联到一个或者多个进程,但是并不经常使用的页面则会被放到 inactive 链表上。页面会在这两个双向链表中移动,操作系统会根据页面的活跃程度来判断应该把页面放到哪个链表上。页面可能会从 active 链表上被转移到 inactive 链表上,也可能从 inactive 链表上被转移到 active 链表上,但是,这种转移并不是每次页面访问都会发生,页面的这种转移发生的间隔有可能比较长。那些最近最少使用的页面会被逐个放到 inactive 链表的尾部。进行页面回收的时候,Linux 操作系统会从 inactive 链表的尾部开始进行回收。

用于描述内存区域的 struct zone() 中关于这两个链表以及相关的关键字段的定义如下所示:

 struct zone { 
……
spinlock_t lru_lock;
struct list_head active_list;
struct list_head inactive_list;
unsigned long nr_active;
unsigned long nr_inactive;
……

}


各字段含义如下所示:

lru_lock:active_list 和 inactive_list 使用的自旋锁。

active_list:管理内存区域中处于活跃状态的页面。

inactive_list:管理内存区域中处于不活跃状态的页面。

nr_active:active_list 链表上的页面数目。

nr_inactive:inactive_list 链表上的页面数目。


如何在两个LRU 链表之间移动页面

Linux 引入了两个页面标志符 ​​PG_active​​ 和 ​​PG_referenced​​ 用于标识页面的活跃程度,从而决定如何在两个链表之间移动页面。

​PG_active​​ 用于表示页面当前是否是活跃的,如果该位被置位,则表示该页面是活跃的。​​PG_referenced​​ 用于表示页面最近是否被访问过,每次页面被访问,该位都会被置位。

Linux 必须同时使用这两个标志符来判断页面的活跃程度,假如只是用一个标志符,在页面被访问时,置位该标志符,之后该页面一直处于活跃状态,如果操作系统不清除该标志位,那么即使之后很长一段时间内该页面都没有或很少被访问过,该页面也还是处于活跃状态。为了能够有效清除该标志位,需要有定时器的支持以便于在超时时间之后该标志位可以自动被清除。然而,很多 Linux 支持的体系结构并不能提供这样的硬件支持,所以 Linux 中使用两个标志符来判断页面的活跃程度。

Linux 2.6 中这两个标志符密切合作,其核心思想如下所示:

如果页面被认为是活跃的,则将该页的 ​​PG_active​​ 置位;否则,不置位。当页面被访问时,检查该页的 ​​PG_referenced​​ 位,若未被置位,则置位之;若发现该页的 ​​PG_referenced​​ 已经被置位了,则意味着该页经常被访问,这时,若该页在 inactive 链表上,则置位其 ​​PG_active​​ 位,将其移动到 active 链表上去,并清除其 ​​PG_referenced​​位的设置;如果页面的 ​​PG_referenced​​ 位被置位了一段时间后,该页面没有被再次访问,那么 Linux 操作系统会清除该页面的 ​​PG_referenced​​ 位,因为这意味着这个页面最近这段时间都没有被访问。 ​​PG_referenced​​ 位同样也可以用于页面从 active 链表移动到 inactive 链表。对于某个在 active 链表上的页面来说,其 ​​PG_active​​ 位被置位,如果 ​​PG_referenced​​ 位未被置位,给定一段时间之后,该页面如果还是没有被访问,那么该页面会被清除其 ​​PG_active​​ 位,挪到 inactive 链表上去。

Linux 中实现在 LRU 链表之间移动页面的关键函数如下所示(本文涉及的源代码均是基于 Linux 2.6.18.1 版本的):

  • ​mark_page_accessed()​​:当一个页面被访问时,则调用该函数相应地修改 ​​PG_active​​ 和 ​​PG_referenced​​。
  • ​page_referenced()​​:当操作系统进行页面回收时,每扫描到一个页面,就会调用该函数设置页面的 ​​PG_referenced​​ 位。如果一个页面的 ​​PG_referenced​​ 位被置位,但是在一定时间内该页面没有被再次访问,那么该页面的 ​​PG_referenced​​ 位会被清除。
  • ​activate_page()​​:该函数将页面放到 active 链表上去。
  • ​shrink_active_list()​​:该函数将页面移动到 inactive 链表上去。

LRU 缓存

前边提到,页面根据其活跃程度会在 active 链表和 inactive 链表之间来回移动,如果要将某个页面插入到这两个链表中去,必须要通过自旋锁以保证对链表的并发访问操作不会出错。为了降低锁的竞争,Linux 提供了一种特殊的缓存:LRU 缓存,用以批量地向 LRU 链表中快速地添加页面。有了 LRU 缓存之后,新页不会被马上添加到相应的链表上去,而是先被放到一个缓冲区中去,当该缓冲区缓存了足够多的页面之后,缓冲区中的页面才会被一次性地全部添加到相应的 LRU 链表中去。Linux 采用这种方法降低了锁的竞争,极大地提升了系统的性能。

LRU 缓存用到了 pagevec 结构,如下所示 :

struct pagevec { 
unsigned long nr;
unsigned long cold;
struct page *pages[PAGEVEC_SIZE];
};


pagevec 这个结构就是用来管理 LRU 缓存中的这些页面的。该结构定义了一个数组,这个数组中的项是指向 page 结构的指针。一个 pagevec 结构最多可以存在 14 个这样的项(PAGEVEC_SIZE 的默认值是 14)。当一个 pagevec 的结构满了,那么该 pagevec 中的所有页面会一次性地被移动到相应的 LRU 链表上去。

用来实现 LRU 缓存的两个关键函数是 ​​lru_cache_add()​​ 和 ​​lru_cache_add_active()​​。前者用于延迟将页面添加到 inactive 链表上去,后者用于延迟将页面添加到 active 链表上去。这两个函数都会将要移动的页面先放到页向量 pagevec 中,当 pagevec 满了(已经装了 14 个页面的描述符指针),pagevec 结构中的所有页面才会被一次性地移动到相应的链表上去。

下图概括总结了上文介绍的如何在两个链表之间移动页面,以及 LRU 缓存在其中起到的作用:

页面在 LRU 链表之间移动示意图

Linux内核-内存回收逻辑和算法(LRU)【转】_链表

其中,

1 表示函数 ​​mark_page_accessed()​

2 表示函数 ​​page_referenced()​

3 表示函数 ​​activate_page()​

4 表示函数 ​​shrink_active_list()​​。

PFRA具体实现

PFRA必须处理多种属于用户态进程、磁盘高速缓存和内存高速缓存的页,而且必须遵照几条试探法准则。PFRA的大部分函数如下:

Linux内核-内存回收逻辑和算法(LRU)【转】_缓存_02

如上图在分配VFS缓冲区或缓冲区首部时,内核调用​​free_more_memory()​​;而当从伙伴系统分配一个或多个页框时,调用​​try_to_free_pages()​​。

页面回收关键代码流程图

Linux内核-内存回收逻辑和算法(LRU)【转】_链表_03

上文提到 Linux 中页面回收主要是通过两种方式触发的,一种是由“内存严重不足”事件触发的;一种是由后台进程 kswapd 触发的,该进程周期性地运行,一旦检测到内存不足,就会触发页面回收操作。对于第一种情况,系统会调用函数 ​​try_to_free_pages()​​ 去检查当前内存区域中的页面,回收那些最不常用的页面。对于第二种情况,函数 ​​balance_pgdat()​​ 是入口函数。

当 NUMA 上的某个节点的低内存区域调用函数 ​​try_to_free_pages()​​ 的时候,该函数会反复调用 ​​shrink_zones() ​​以及 ​​shrink_slab()​​ 释放一定数目的页面,默认值是 32 个页面。如果在特定的循环次数内没有能够成功释放 32 个页面,那么页面回收会调用 OOM killer 选择并杀死一个进程,然后释放它占用的所有页面。​​函数 shrink_zones() ​​会对内存区域列表中的所有区域分别调用 ​​shrink_zone()​​ 函数,后者是从内存回收最近最少使用页面的入口函数。

对于定期页面检查并进行回收的入口函数 ​​balance_pgdat() ​​来说,它主要调用的函数是 ​​shrink_zone()​​ 和 ​​shrink_slab()​​。从上图中我们也可以看出,进行页面回收的两条代码路径最终汇合到函数 ​​shrink_zone()​​和函数 ​​shrink_slab()​​ 上。

函数 shrink_zone()

其中,​​shrink_zone()​​ 函数是 Linux 操作系统实现页面回收的最核心的函数之一,它实现了对一个内存区域的页面进行回收的功能,该函数主要做了两件事情:

将某些页面从 active 链表移到 inactive 链表,这是由函数 ​​shrink_active_list()​​ 实现的。

从 inactive 链表中选定一定数目的页面,将其放到一个临时链表中,这由函数 ​​shrink_inactive_list()​​ 完成。该函数最终会调用 ​​shrink_page_list()​​ 去回收这些页面。 函数 ​​shrink_page_list()​​ 返回的是回收成功的页面数目。概括来说,对于可进行回收的页面,该函数主要做了这样几件事情,其代码流程图如下所示: 函数 ​​shrink_page_list()​​ 实现的关键功能

Linux内核-内存回收逻辑和算法(LRU)【转】_链表_04

函数 shrink_slab()

函数 ​​shrink_slab()​​ 是用来回收磁盘缓存所占用的页面的。Linux 操作系统并不清楚这类页面是如何使用的,所以如果希望操作系统回收磁盘缓存所占用的页面,那么必须要向操作系统内核注册 shrinker 函数,shrinker 函数会在内存较少的时候主动释放一些该磁盘缓存占用的空间。函数 ​​shrink_slab()​​ 会遍历 shrinker 链表,从而对所有注册了 shrinker 函数的磁盘缓存进行处理。

从实现上来看,shrinker 函数和 slab 分配器并没有固定的联系,只是当前主要是 slab 缓存使用 shrinker 函数最多。

注册 shrinker 是通过函数 ​​set_shrinker()​​ 实现的,解除 shrinker 注册是通过函数 ​​remove_shrinker()​​ 实现的。当前,Linux 操作系统中主要的 shrinker 函数有如下几种:

​shrink_dcache_memory()​​:该 shrinker 函数负责 dentry 缓存。 ​​shrink_icache_memory()​​:该 shrinker 函数负责 inode 缓存。 ​​mb_cache_shrink_fn()​​:该 shrinker 函数负责用于文件系统元数据的缓存。

具体的源代码实现细节有时间再做分析。后面将谈论交换。

 


【作者】​​张昺华​