目录
前面历经了分配内存,当然内容也会在一些情况下进行回收。
1. 页面的使用
在许多情况下,会使用到页面:
1、内核代码可能调用 alloc_pages 之类的函数,从管理物理页面的伙伴系统(管理区zone上的free_area空闲链表)上直接分配页面。比如:驱动程序可能用这种方式来分配缓存;创建进程时,内核也是通过这种方式分配连续的两个页面,作为进程的thread_info结构和内核栈;等等。从伙伴系统分配页面是最基本的页面分配方式,其他的内存分配都是基于这种方式的;
2、内核中的很多对象都是用slab机制来管理的,slab就相当于对象池,它将页面“格式化”成“对象”,存放在池中供人使用。当slab中的对象不足时,slab机制会自动从伙伴系统中分配页面,并“格式化”成新的对象;
3、磁盘高速缓存。读写文件时,页面被从伙伴系统分配并用于磁盘高速缓存,然后磁盘上的文件数据被载入到对应的磁盘高速缓存页面中;
4、内存映射。这里所谓的内存映射实际上是指将内存页面映射到用户空间,供用户进程使用。进程的task_struct->mm结构中的每一个vma就代表着一个映射,而映射的真正实现则是在用户程序访问到对应的内存地址之后,由缺页异常引起的页面被分配和页表被更新
2. 为什么要进行页面回收
这里说的回收,分为了两种:
1、主动释放。就像用户程序通过free函数释放曾经通过malloc函数分配的内存一样,页面的使用者明确知道页面什么时候要被使用,什么时候又不再需要了。
2、通过 Linux 内核提供的页框回收算法(PFRA)进行回收。页面的使用者一般将页面当作某种缓存(读写磁盘产生的 page cache),以提高系统的运行效率。这种无法被页面的使用者主动释放,因为它们不知道这些页面何时应该被释放。Linux 中页缓存存在的最大好处就是可以让程序从缓存中快速获取数据,从而提升系统的性能。在系统负载不重的情况下,Linux 操作系统会分配较多的物理页面用于页缓存,从而提高程序的运行效率;但是在系统负载较重的情况下,Linux 操作系统就可能会适当回收用于缓存的页面,并减少用于缓存的页面的分配,从而满足系统中优先级更高的内存分配请求。
PFRA要做的事就是回收这些可以被回收的页面。为了避免系统陷入页面紧缺的困境,PFRA会在内核线程中周期性地被调用运行。或者由于系统已经页面紧缺,试图分配页面的内核执行流程因为得不到需要的页面,而同步地调用PFRA。
所以,废话,不回收,那么哪里来的页面可供后续继续运行。
3. 哪些页面可以被回收
磁盘高速缓存的页面(包括文件映射的页面)都是可以被丢弃并回收的。但是如果页面是脏页面,则丢弃之前必须将其写回磁盘。
匿名映射的页面则都是不可以丢弃的,因为页面里面存有用户程序正在使用的数据,丢弃之后数据就没法还原了。相比之下,磁盘高速缓存页面中的数据本身是保存在磁盘上的,可以复现。
于是,要想回收匿名映射的页面,只好先把页面上的数据转储到磁盘,这就是页面交换(swap)。显然,页面交换的代价相对更高一些。匿名映射的页面可以被交换到磁盘上的交换文件或交换分区上(分区即是设备,设备即也是文件。所以下文统称为交换文件)。
于是,除非页面被保留或被上锁(页面标记PG_reserved/PG_locked被置位。某些情况下,内核需要暂时性地将页面保留,避免被回收),所有的磁盘高速缓存页面都可回收,所有的匿名映射页面都可交换。
4. 进行页面回收的时机
Linux 操作系统使用如下这两种机制检查系统内存的使用情况,从而确定可用的内存是否太少从而需要进行页面回收。
- 周期性的检查:这是由后台运行的守护进程 kswapd 完成的。该进程定期检查当前系统的内存使用情况,当发现系统内空闲的物理页面数目少于特定的阈值时,该进程就会发起页面回收的操作。
- “内存严重不足”事件的触发:在某些情况下,比如,操作系统忽然需要通过伙伴系统为用户进程分配一大块内存,或者需要创建一个很大的缓冲区,而当时系统中的内存没有办法提供足够多的物理内存以满足这种内存请求,这时候,操作系统就必须尽快进行页面回收操作,以便释放出一些内存空间从而满足上述的内存请求。这种页面回收方式也被称作“直接页面回收”。
如果操作系统在进行了内存回收操作之后仍然无法回收到足够多的页面以满足上述内存要求,那么操作系统只有最后一个选择,那就是使用 OOM ( out of memory ) killer,它从系统中挑选一个最合适的进程杀死它,并释放该进程所占用的所有页面。
上面介绍的内存回收机制主要依赖于三个字段:pages_min,pages_low 以及 pages_high。每个内存区域( zone )都在其区域描述符中定义了这样三个字段,这三个字段的具体含义如下表所示。
名称 | 字段描述 |
---|---|
pages_min | 区域的预留页面数目,如果空闲物理页面的数目低于 pages_min,那么系统的压力会比较大,此时,内存区域中急需空闲的物理页面,页面回收的需求非常紧迫。 |
pages_low | 控制进行页面回收的最小阈值,如果空闲物理页面的数目低于 pages_low,那么操作系统内核会开始进行页面回收。 |
pages_high | 控制进行页面回收的最大阈值,如果空闲物理页面的数目多于 pages_high,则内存区域的状态是理想的。 |
5. 页面回收算法
Linux 中的页面回收是基于 LRU(least recently used,即最近最少使用 ) 算法的。LRU 算法基于这样一个事实,过去一段时间内频繁使用的页面,在不久的将来很可能会被再次访问到。反过来说,已经很久没有访问过的页面在未来较短的时间内也不会被频繁访问到。因此,在物理内存不够用的情况下,这样的页面成为被换出的最佳候选者。
LRU 算法的基本原理很简单,为每个物理页面绑定一个计数器,用以标识该页面的访问频度。操作系统内核进行页面回收的时候就可以根据页面的计数器的值来确定要回收哪些页面。然而,在硬件上提供这种支持的体系结构很少,Linux 操作系统没有办法依靠这样一种页计数器去跟踪每个页面的访问情况,所以,Linux 在页表项中增加了一个 Accessed 位,当页面被访问到的时候,该位就会被硬件自动置位。该位被置位表示该页面还很年轻,不能被换出去。此后,在系统的运行过程中,该页面的年龄会被操作系统更改。在 Linux 中,相关的操作主要是基于两个 LRU 链表以及两个标识页面状态的标志符,下文会逐一介绍这些相应的数据结构以及 Linux 如何使用这些数据结构进行页面回收。
LRU 链表组织的页包括:可以存放到swap分区中的匿名页,映射了文件的文件页,以及被锁在内存中禁止换出的进程页。所有属于这些情况的页都必须加入到lru链表中,无一例外,而剩下那些没有加入到lru链表中的页,基本也就剩内核使用的页框了。
5.1 LRU 链表
在 Linux 中,操作系统对 LRU 的实现主要是基于一对双向链表:active 链表和 inactive 链表,这两个链表是 Linux 操作系统进行页面回收所依赖的关键数据结构,每个内存区域都存在一对这样的链表。顾名思义,那些经常被访问的处于活跃状态的页面会被放在 active 链表上,而那些虽然可能关联到一个或者多个进程,但是并不经常使用的页面则会被放到 inactive 链表上。页面会在这两个双向链表中移动,操作系统会根据页面的活跃程度来判断应该把页面放到哪个链表上。页面可能会从 active 链表上被转移到 inactive 链表上,也可能从 inactive 链表上被转移到 active 链表上,但是,这种转移并不是每次页面访问都会发生,页面的这种转移发生的间隔有可能比较长。那些最近最少使用的页面会被逐个放到 inactive 链表的尾部。进行页面回收的时候,Linux 操作系统会从 inactive 链表的尾部开始进行回收。
每个zone有一个,那么下面针对一个zone来分析 LRU 链表:
一个 LRU 链表描述符中总共有5个双向链表头,它们分别描述五中不同类型的链表:
-
LRU_INACTIVE_ANON:称为非活动匿名页 LRU 链表(swap)
-
LRU_ACTIVE_ANON:称为活动匿名页 LRU 链表(swap)
-
LRU_INACTIVE_FILE:称为非活动文件页 LRU 链表(磁盘)
-
LRU_ACTIVE_FILE:称为活动文件页 LRU 链表(磁盘)
-
LRU_UNEVICTABLE:此链表中保存的是此zone中所有禁止换出的页的描述符。
简单说,就是针对匿名页和文件页都拆分成一个活跃,一个不活跃的链表。
5.2 如何在两个 LRU 链表之间移动页面
Linux 引入了两个页面标志符 PG_active 和 PG_referenced 用于标识页面的活跃程度,从而决定如何在两个链表之间移动页面。PG_active 用于表示页面当前是否是活跃的,如果该位被置位,则表示该页面是活跃的。PG_referenced 用于表示页面最近是否被访问过,每次页面被访问,该位都会被置位。Linux 必须同时使用这两个标志符来判断页面的活跃程度,假如只是用一个标志符,在页面被访问时,置位该标志符,之后该页面一直处于活跃状态,如果操作系统不清除该标志位,那么即使之后很长一段时间内该页面都没有或很少被访问过,该页面也还是处于活跃状态。为了能够有效清除该标志位,需要有定时器的支持以便于在超时时间之后该标志位可以自动被清除。然而,很多 Linux 支持的体系结构并不能提供这样的硬件支持,所以 Linux 中使用两个标志符来判断页面的活跃程度。
核心思想如下所示:
- 如果页面被认为是活跃的,则将该页的 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 链表之间移动页面的关键函数如下所示:
- mark_page_accessed():当一个页面被访问时,则调用该函数相应地修改 PG_active 和 PG_referenced。
- page_referenced():当操作系统进行页面回收时,每扫描到一个页面,就会调用该函数设置页面的 PG_referenced 位。如果一个页面的 PG_referenced 位被置位,但是在一定时间内该页面没有被再次访问,那么该页面的 PG_referenced 位会被清除。
- activate_page():该函数将页面放到 active 链表上去。
- shrink_active_list():该函数将页面移动到 inactive 链表上去。
5.3 LRU 缓存
前边提到,页面根据其活跃程度会在 active 链表和 inactive 链表之间来回移动,如果要将某个页面插入到这两个链表中去,必须要通过自旋锁以保证对链表的并发访问操作不会出错。为了降低锁的竞争,Linux 提供了一种特殊的缓存:LRU 缓存,用以批量地向 LRU 链表中快速地添加页面。有了 LRU 缓存之后,新页不会被马上添加到相应的链表上去,而是先被放到一个缓冲区中去,当该缓冲区缓存了足够多的页面之后,缓冲区中的页面才会被一次性地全部添加到相应的 LRU 链表中去。Linux 采用这种方法降低了锁的竞争,极大地提升了系统的性能。
LRU 缓存用到了 pagevec 结构,如下所示 :
#define PAGEVEC_SIZE 14
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 缓存在其中起到的作用:
5.4 回收过程 LRU 的扫描
由于存在多组LRU(系统中有多个zone,每个zone又有多组LRU),如果PFRA每次回收都扫描所有的LRU找出其中最值得回收的若干个页面的话,回收算法的效率显然不够理想。
linux内核PFRA使用的扫描方法是:定义一个扫描优先级,通过这个优先级换算出在每个LRU上应该扫描的页面数。整个回收算法以最低的优先级开始,先扫描每个LRU中最近最少使用的几个页面,然后试图回收它们。如果一遍扫描下来,已经回收了足够数量的页面,则本次回收过程结束。否则,增大优先级,再重新扫描,直到足够数量的页面被回收。而如果始终不能回收足够数量的页面,则优先级将增加到最大,也就是所有页面将被扫描。这时,就算回收的页面数量还是不足,回收过程都会结束。
每次扫描一个LRU时,都从active链表和inactive链表获取当前优先级对应数目的页面,然后再对这些页面做处理:如果页面不能被回收(如被保留或被上锁),则放回对应链表头部(同上,假设回收从头部开始);否则如果页面的访问标记置位,则清除该标记,并将页面放回对应链表尾部(同上,假设回收从头部开始);否则页面将从active链表被移动到inactive链表、或从inactive链表被回收。
PFRA不倾向于从active链表回收匿名映射的页面,因为用户进程使用的内存一般相对较少,且回收的话需要进行交换,代价较大。所以在内存剩余较多、匿名映射所占比例较少的情况下,都不会去回收匿名映射对应的active链表中的页面。(而如果页面已经被放到inactive链表中,就不再去管那么多了。)
5.5 页面回收的实现
Linux 操作系统进行页面回收需要考虑的方面很多,下图列出了 Linux 操作系统进行页面回收的关键代码流程图,该图给出了实现页面回收的关键代码函数名,并说明它们之间是如何彼此链接的。
上文提到 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() 上。
5.5.1 函数 shrink_zone()
其中,shrink_zone() 函数是 Linux 操作系统实现页面回收的最核心的函数之一,它实现了对一个内存区域的页面进行回收的功能,该函数主要做了两件事情:
- 将某些页面从 active 链表移到 inactive 链表,这是由函数 shrink_active_list() 实现的。
- 从 inactive 链表中选定一定数目的页面,将其放到一个临时链表中,这由函数 shrink_inactive_list() 完成。该函数最终会调用 shrink_page_list() 去回收这些页面。
函数 shrink_page_list() 返回的是回收成功的页面数目。概括来说,对于可进行回收的页面,该函数主要做了这样几件事情,其代码流程图如下所示:
函数 shrink_page_list() 实现的关键功能
- 对于匿名页面来说,在回收此类页面时,需要将其数据写入到交换区。如果尚未为该页面分配交换区槽位,则先分配一个槽位,并将该页面添加到交换缓存。同时,将相关的 page 实例加入到交换区,这样,对该页面的处理就可以跟其他已经建立映射的页面一样;
- 如果该页面已经被映射到一个或者多个进程的页表项中,那么必须找到所有引用该页面的进程,并更新页表中与这些进程相关的所有页表项。在这里,Linux 操作系统会利用反向映射机制去检查哪些页表项引用了该页面;
- 如果该页面中的数据是脏的,那么数据必须要被回写;
- 释放页缓存中的干净页面。
5.5.2 函数 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 函数负责用于文件系统元数据的缓存。
6. 反向映射
前文介绍过,在回收一个物理页面之前,需要查找到所有关联了该物理页面的页表项,并逐一更新这些页表项。Linux 使用了反向映射这种机制用于快速定位那些引用了某个物理页面的所有页表项。Linux 操作系统为物理页面建立一个链表,用于指向引用了该物理页面的所有页表项。其基本思想如下图所述:
反向映射的基本思想
反向映射技术的发展历史
在 Linux 2.4 中,为了确定某个要回收的物理页面都被哪些页表项引用,必须要遍历所有进程,这是一项非常耗资源和时间的工程。为了更加有效地回收一个共享页面,Linux 在 2.5 版本的开发期间引入了反向映射这样一种机制。这种机制建立了物理页面和所有映射了该物理页面的页表项之间的一种关联,从而让操作系统可以快速定位引用了该物理页面的所有页表项。在 Linux 2.6 版本中,反向映射算法又经历了大量改进。
在 Linux 2.5 版本中,反向映射技术的实现主要是基于页表项链表。操作系统为每一个物理页面都维护了一个链表,所有与该物理页面关联的页表项都会被放到这个链表上。这种方法会存在一些问题:
- 空间资源的消耗:为每个物理页面维护这样一个链表,需要占用大量的内存空间。
- 时间资源的消耗:回收一个物理页面的时候,需要先获取该链表上的锁,然后遍历相应的反向映射链表,链表上的项越多,需要的时间就越多。
后来,Linux 2.6 引入了基于对象的反向映射机制。这种方法也是为物理页面设置一个用于反向映射的链表,但是链表上的节点并不是引用了该物理页面的所有页表项,而是相应的虚拟内存区域( vm_area_struct 结构),虚拟内存区域通过内存描述符( mm_struct 结构)找到页全局目录,从而找到相应的页表项。相对于前一种方法来说,用于表示虚拟内存区域的描述符比用于表示页面的描述符要少得多,所以遍历后边这种反向映射链表所消耗的时间也会少很多。
过程
通过反向映射可以找到一个被映射的页面对应的vma,通过vma->vm_mm->pgd就能找到对应的页表。然后通过page->index得到页面的虚拟地址。再通过虚拟地址从页表中找到对应的页表项。(前面说到的获取页表项中的accessed标记,就是通过反向映射实现的。)
页面对应的page结构中,page->mapping如果最低位置位,则这是一个匿名映射页面,page->mapping指向一个anon_vma结构;否则是文件映射页面,page->mapping文件对应的address_space结构。(显然,anon_vma结构和address_space结构在分配时,地址必须要对齐,至少保证最低位为0。)
对于匿名映射的页面,anon_vma结构作为一个链表头,将映射这个页面的所有vma通过vma->anon_vma_node链表指针连接起来。每当一个页面被(匿名)映射到一个用户空间时,对应的vma就被加入这个链表。
对于文件映射的页面,address_space结构除了维护了一棵用于存放磁盘高速缓存页面的radix树,还为该文件映射到的所有vma维护了一棵优先搜索树。因为这些被文件映射到的vma并不一定都是映射整个文件,很可能只映射了文件的一部分。所以,这棵优先搜索树除了索引到所有被映射的vma,还要能知道文件的哪些区域是映射到哪些vma上的。每当一个页面被(文件)映射到一个用户空间时,对应的vma就被加入这个优先搜索树。于是,给定磁盘高速缓存上的一个页面,就能通过page->index得到页面在文件中的位置,就能通过优先搜索树找出这个页面映射到的所有vma。
上面两步中,神奇的page->index做了两件事,得到页面的虚拟地址、得到页面在文件磁盘高速缓存中的位置。
vma->vm_start记录了vma的首虚拟地址,vma->vm_pgoff记录了该vma在对应的映射文件(或共享内存)中的偏移,而page->index记录了页面在文件(或共享内存)中的偏移。
通过vma->vm_pgoff和page->index能得到页面在vma中的偏移,加上vma->vm_start就能得到页面的虚拟地址;而通过page->index就能得到页面在文件磁盘高速缓存中的位置。
7. 最后的必杀
前面说到,PFRA可能扫描了所有的LRU还没办法回收需要的页面。同样,在slab、dentry cache、inode cache、等地方,可能也无法回收到页面。
这时,如果某段内核代码一定要获得页面呢(没有页面,系统可能就要崩溃了)?PFRA只好使出最后的必杀技——OOM(out of memory)。所谓的OOM就是寻找一个最不重要的进程,然后将其杀死。通过释放这个进程所占有的内存页面,以缓解系统压力。
参考文献:
https://www.ibm.com/developerworks/cn/linux/l-cn-pagerecycle/index.html
https://blog.csdn.net/u010154760/article/details/45032195
https://blog.csdn.net/wh8_2011/article/details/52275231
https://www.cnblogs.com/alantu2018/p/8447611.html
https://www.jianshu.com/p/7ab51b8a6368