我们知道,linux应用程序read/write后,在内核会产生大量的pagecache。这会导致可直接分配的内存很少,进而引发一些列内存分配难、内存回收cpu消耗大、业务性能抖动等问题。pagecache本身起到缓存加速作用,但实际测试表明有相当数目的pagecache纯属滥竽充数,读写一次后就很少使用了,需要回收掉这些pagecache的内存!并且,这些pagecache有个明显的特征,就是以文件为单位进行分布:有些文件的pagecahce频繁被访问,有些文件的pagecache很少被访问,需要回收掉后者的内存。这就是本异步内存回收方案的核心思路,以文件为单位回收很少访问的pagecache的内存,与其他方案有明显差异,但内存回收效果很好:既能精确找到冷文件页page并回收掉,对发生refault问题的page还能单独管理,有效避免频繁refault,对内核的改动极小。源码已开源 https://github.com/dongzhiyan-stack/async_memory_reclaime_for_cold_file_area 。下边是本内存回收方案的ppt下载链接,ppt介绍的更简洁,可以先看下,有个大概的了解(如果下载不了,可以私信我)。
https://github.com/ChinaLinuxKernel/CLK2023/raw/main/%E5%88%86%E8%AE%BA%E5%9D%9B1%EF%BC%88%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86%EF%BC%89/11%20%20%E5%BC%82%E6%AD%A5%E5%86%85%E5%AD%98%E5%9B%9E%E6%94%B6%E6%96%B0%E6%80%9D%E8%B7%AF%E6%8E%A2%E7%B4%A2--%E5%9F%BA%E4%BA%8E%E5%86%B7%E7%83%AD%E6%96%87%E4%BB%B6%E7%9A%84%E5%86%B7%E7%83%AD%E5%8C%BA%E5%9F%9F%E7%B2%BE%E5%87%86%E7%9A%84%E5%9B%9E%E6%94%B6%E5%86%B7%E6%96%87%E4%BB%B6%E9%A1%B5page%E2%80%94%E2%80%94%E8%83%A1%E4%BF%8A%E9%B9%8F.pptxicon-default.png?t=N7T8https://github.com/ChinaLinuxKernel/CLK2023/raw/main/%E5%88%86%E8%AE%BA%E5%9D%9B1%EF%BC%88%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86%EF%BC%89/11%20%20%E5%BC%82%E6%AD%A5%E5%86%85%E5%AD%98%E5%9B%9E%E6%94%B6%E6%96%B0%E6%80%9D%E8%B7%AF%E6%8E%A2%E7%B4%A2--%E5%9F%BA%E4%BA%8E%E5%86%B7%E7%83%AD%E6%96%87%E4%BB%B6%E7%9A%84%E5%86%B7%E7%83%AD%E5%8C%BA%E5%9F%9F%E7%B2%BE%E5%87%86%E7%9A%84%E5%9B%9E%E6%94%B6%E5%86%B7%E6%96%87%E4%BB%B6%E9%A1%B5page%E2%80%94%E2%80%94%E8%83%A1%E4%BF%8A%E9%B9%8F.pptx

注意,本文讨论的是针对read/write系统调用产生的文件页的内存回收,而以文件为单位回收mapped文件页,见Linux内核低损耗、精准的异步内存回收冷mapped文件页的探索-CSDN博客

问题背景:线上经常遇到,因pagecache太多且可直接分配的内存太少,导致业务进程内存分配时阻塞,发生性能抖动、卡顿,严重影响业务。并且,网卡软中断里分配page因进入内存分配流程的slow分支而频繁触发dump_stack告警,严重的还会导致内核crash。pagecache很多是谁导致的?测试证实,根源是有不少文件在读写后产生了100M到1G大小的pagecache。如下所示:

用systemtap或kprobe动态跟踪并打印这些100M~1G大小文件被读写的文件页page索引,发现总是只打印几个固定的文件页page索引。这说明这些文件的pagecache中只有少部分pagecache被经常读写,大部分都很少被读写,这种文件本文称为冷文件,这些很少被读写的pagecache就是冷文件页page。内存回收时,如果能找出这些冷文件,并能提前精确统计这些文件的文件页page的访问频次,为判断出冷文件页提供准确依据。这样将能从这些冷文件找出大量的冷文件页page并回收掉,还基本不会发生refault。

针对这种典型的问题场景,有没有好的解决方案呢?内核原生的active/inactive 双LRU内存回收方案,无法精确统计文件页page的访问频次,还容易发生refault。damon方案也很难精确统计文件页page的访问频次,cpu消耗可能也会较高。MRLRU方案估计会有不错的内存回收效果,但是跟前两个方案一样,无法找出冷文件(尤其是产生pagecache很多的冷文件),只回收冷文件的冷文件页page。为什么要尽量只回收冷文件的冷文件页page?因为这样不容易refault,因为内存回收一旦不当,就容易refault,将影响到业务性能,得不偿失!最后,一旦内存回收过程发生refault,这3个方案无法单独管理refault page而有效避免再次发生refault。这些都是内核主流的内存回收方案,也都有不错的内存回收效果。但是针对这个问题场景,不太容易发挥出理想的效果,适合的才是最好的!

因此产生了一个想法,内存回收的单位能否是文件?并且要能找出产生pagecache很多但大部分pagecache都很少被读写的文件,专门回收这些文件的pagecache对应的文件页page,内存回收效率将很高。如果能对内存回收过程发生refault的文件页page进行单独管理,会更好。最后,大部分互联网公司没有能力修改定制内核,用的都是红帽、ubuntu发行版原生的内核,而要想有效解决pagecache太多带来的内存回收和分配难题,不从内核里解决根本不行。并且,据了解google对安卓手机厂商修改定制内核的限制也越来越严格!如果能把这个以文件为单位的内存回收方案做成一个内核ko,既能实现对pagecache高效的内存回收,还不用修改编译内核,就更完美了。

经过艰难探索,这个异步内存回收方案已经实现了:达到了预期的异步内存回收效果,并且cpu性能损耗较低。异步内存回收线程的cpu使用率低于5%,在SATA和SSD盘上测试读文件,没有明显的性能损耗。并能精确统计文件页page的访问频次,并准确判断出冷文件,从而只回收冷文件的冷文件页page,尤其是产生pagecache很多的冷文件。针对发生refault的文件页page,可以做到由每个文件单独管理,长时间禁止再参与内存回收,避免再次refault。这些下文详细介绍。

本文介绍的针对pagecache的内存回收方案,内存回收的单位是一个个文件,再把文件的pagecache分成一个个小区域(或者叫小单元),一个区域由4个索引连续的文件页page组成。比如把索引是0~3的文件页page组成一个区域,索引是4~7的文件页page再组成一个区域,其他区域类推。一个区域内的文件页page冷热属性接近,每个区域分配一个file_area结构,精确统计该区域内的page的访问频次。然后,提前判断出文件的pagecache哪些区域是进程频繁访问的(即热区域,该区域的文件页page频繁被读写),哪些区域是进程很少访问的(即冷区域,该区域的文件页page很少被读写)。异步内存回收线程工作时,一个个遍历指定数目的文件,再把每个文件pagecache的冷区域找出来,最后回收掉冷区域对应的文件页page。

如图,演示了把一个文件的pagecache分成3个小区域:索引是0到3、4到7、8到11的文件页page分别组成3个小区域。标红的文件页page是频繁访问的,标蓝的文件页page很少访问。

为什么要以文件为单位进行内存回收呢?为什么要把文件的pagecache分成一个个小区域呢?优势是什么?有以下几点:

  • 1:系统总有较多数目的文件,产生的pagecache很多,但是大部分pagecache都很少被读写,这种文件的pagecache中冷区域占比高(称为冷文件)。内存回收时优先找到这种文件,因为能从这种文件找到很多的冷区域,继而高效回收到很多的冷文件页page!
  • 2:有些文件的pagecache大部分都被频繁读写(称为热文件),这种文件的pagecache中热区域占比很高。内存回收时尽量避开这种文件,不回收这种文件的文件页page,因为有较大概率会发生refault。
  • 3:针对内存回收后发生refault的文件页page,该文件页page所在区域的file_area数据结构将移入所属文件的refault链表,长时间禁止回收该page,有效避免频繁refault。

简单说,就是把文件按照冷热属性进行分类,尽可能只回收有很多pagecache的冷文件的冷文件页page,这样更容易回收到冷page。除此之外,还有其他几个优势:

  • 1:可以精确统计每个文件pagecache对应的文件页page的访问频次,因此可以精确控制文件页page多久没访问后再回收。比如,设定文件页page 1个小时都没被访问过后,判定为冷文件页,然后才能回收掉。这个功能目前已经实现了,并可以通过proc接口设置这个时间。
  • 2:针对加载该ko前已产生的pagecache,开发了异步drop_cache功能,可以更平滑的回收这些pagecache。
  • 3:每个内存回收周期,被访问的文件页page所在区域的file_area数据结构将移动到的自定义的内存回收链表头(有一定策略,不是每次都移动),这样链表尾对应的都是冷page对应的file_area。内存回收时就能从链表尾扫描这些冷page对应的file_area,遇到不是冷page的file_area就结束遍历。这样内存回收效率比较高,因为不用遍历完整个链表就能找出冷page。

为什么一定要把文件的pagecache分成一个个区域?首先是同一个区域的文件页page冷热属性接近,一起参与内存回收有一定道理。其次,因为最初考虑把该方案做成了一个内核ko,没有修改编译内核,想要统计并保存每一个文件页page的冷热信息,比较麻烦。而通过文件pagecache的一个个区域对应的数据结构,实时统计每个区域的访问频次,这就是该区域内对应的4个文件页page的总访问频次。根据文件pagecache每个区域的访问频次,判断出冷热区域,继而判断出冷热文件。这为将来的异步内存回收精准的找到冷文件和冷区域创造条件,最终效果就是可以高效的回收很多冷区域对应的冷文件页page。

有人说这跟damon很像,都是利用了局部性原理。只能说是殊途同归,毕竟最初把文件的pagecache分成一个个区域的目的,是因为想做成内核ko的限制:要统计每个page的访问频次太麻烦了,才想到用一个区域的代表该区域内4个文件页的访问频次。同时,本方案内存回收的单位是一个个文件,把文件按照冷热属性进行了归类,尽可能只回收冷文件的文件页page,这点与damon就完全不一样了。最后,可以精确统计每个文件pagecache的每个区域里文件页page的访问频次,damon这点不好做到。

本异步内存回收方案已经可以做成内核ko,不用编译内核,目前已经在红帽8和9系列的centos 8.3(内核版本4.18.0-240)和rocky 9.2(内核版本5.14.0-284.11.1)实现异步内存回收功能(其他内核发行版,如阿里龙蜥OS、腾讯opencloud OS、安卓手机等内核,理论上适配后也可以不修改编译内核的前提下使用该内核ko。有一点需注意,据查4.9的安卓内核启用了cfi功能后,无法使用kallsyms_lookup_name获取内核函数指针,这种情况下就无法做成ko了,只能编译进内核了)。源码见GitHub - dongzhiyan-stack/async_memory_reclaime_for_cold_file_area: linux内核内存回收的另一个思路探索——基于冷热文件的冷热区域而更精准的回收冷page 。

注意,本方案也支持不做成ko而编译进内核,只需改动1+3行代码,这样在极端场景测试性能损耗会更低,详情看文章最后。

需要说明一下,近期火热的MGLRU内存回收新方案,已经合入了6.1内核。作为全新的内存回收方案,回收效果是挺好的,但是基本抛弃内核原有lru内存回收方案,对内核做了大幅改动,比较担忧它的稳定性,主流操作系统发行版短时间内估计很难会用到!

并且,内存回收大部分场景针对的是文件的pagecache,针对pagecache的内存回收不一定得大幅改动内核吧?如果能把管理文件pagecache的页高速缓存address_space管理起来,根据每个文件页page访问频率,找出冷page,然后回收掉,不就可以实现pagecache的内存回收了?这就是本文的异步内存回收方案基本思路,目前已经实现了基本功能,性能损耗正常,下文详细介绍这个方案。

1:内存回收的现状

线上经常遇到pagecache 200G~300G且free内存很少的场景,此时进程频繁因分配内存失败而内存回收,容易造成阻塞、性能抖动!此时大概率kswapd进程也在疯狂回收内存!并且,网卡软中断里分配skb有关数据结构的page时因指定了GFP_ATOMIC标记而禁止内存回收时休眠,导致内存分配时进入slow分支而频繁触发dump_stack告警信息,严重的还会导致内核crash。

这200G+的内存如果能提前异步回收,就没那么多事了!可是内核只能在内存不足时才会按需回收一小部分内存!对了,还遇到一个centos 7.6内核bug,触发后容器里内存回收就io hung。遇到这么多内存回收问题,在2021年就迫切想做一个异步内存回收的内核ko工具,灵活方便,关键是不用修改编译内核!

Linux内核原生的内存回收方案简单说下:内存page分为匿名页page和文件页page两类,分别存入inactive/active anon、inactive/active file lru链表。文件页page主要来自pagecache,内存回收大部分情况下也是从pagecache里回收的。注意,本文讨论是针对pagecache的文件页page内存回收,不讨论匿名页的内存回收。

内核原生内存回收方案比较被动:必须要等到进程分配时,当前内存zone的可直接分配的内存page数小于内存zone的min/low/high的内存水位值+内核预留内存时,才会进行直接内存回收或唤醒kswapd线程回收内存。这对于敏感业务,很容易因分配不出内存而性能抖动!

并且,保存在active lru链表的page一般是最近多次访问的(热page),保存在inactive lru链表的page一般是最近没访问过的(冷page)。内存回收时是从inactive lru链表尾扫描一些page,尝试内存回收,这个策略貌似看着是合理的,因为inactive lru链表上的page是最近没访问过的,内存回收就应该回收掉不经常访问的page,而经常访问的page不能回收。但是却经常有如下情况:

  • 1:一些page在某个时间点被频繁访问后被移入active lru链表,但是之后很长一段时间就不再被访问了。接着,有些page被少量访问了而移动到inactive lru链表。如果此时发生内存回收,是应该回收active lru链表长时间不被访问的page,还是回收inactive lru链表刚访问过的page?正常情况回收前者更合理,但是内核是回收后者。当然,内核也可以实时把active lru链表长时间不被访问的page移动到inactive lru链表,但是需要从active lru链表找到这种page,这个过程需要pgdat->lru_lock或lruvec->lru_lock加锁,损耗性能会较大。回收page的标准应该是它最近一段时间的访问频率,而不是它在处于哪个链表。
  • 2:遍历inactive lru链表尾上的page,在内存回收这些page后,很快又被访问了,此时发生了refault现象。内核改善措施之一是增大inactive lru链表长度,增加refault page在inactive lru链表停留的时间或者直接把该page移入active lru链表,防止再被回收掉。而更好点的方法是,对发生refault的page做个标记,后续内存回收尽量不回收这种page。

2:内存回收方案的改进

目前的异步内存回收方案都修改了内核,能否把异步内存回收做成一个ko,这样不用修改内核了,灵活很多。除了这点之外,主要是如下3点:

  • 1:文件回收的单位是一个个文件。同时,要能识别出消耗pagecache很多的文件(比如pagecache消耗了几个G的文件),内存回收时要先扫描这些pagecache很多的文件的文件页page,因为更容易扫描出很多的冷page,内存回收效率比较高。
  • 2:文件回收的单位是一个个文件,但是还要把文件的pagecache再分割成一个个小区域。为什么要这样?根据线上调试经验,文件索引是0的文件页page0被频繁访问后,索引是1的文件页page1有很大概率也会被访问。前后挨着的文件页page的冷热属性相近,因此组成一个内存page单元,一起参与内存回收比较合适。
  • 3:能否不再理会内核的active/inacvie  lru链表,而自定义一个链表,文件页page被访问则它所在区域的file_area结构移动到链表头(不是每次访问都移动到链表头,有一定策略),留在链表尾的都是冷page对应的区域file_area(冷file_area)。内存回收时直接扫描链表尾的冷file_area,不是冷file_area则结束遍历。最后只回收冷file_area对应的文件页page,这样的内存回收效率比较高,因为不用遍历完整个链表。

如下图所示,这是一个4k*12大小的文件读写后产生的pagecache示意图,page0是文件地址0~4k文件数据对应的pagecache,就是索引是0的文件页page,其他类推。page0、page1、page3、page8、page10、page11访问的很频繁,是热page。剩下的page很少访问,是冷page。

这种情况其实挺常见的,如前文所说,把 page0~page3、page4~page7、page8~page11分别作为3个内存page单元,一起参与内存回收比较合适。分组效果如下:

每个page被访问一次则内存page单元的访问计数加1。内存回收时先扫描这3个内存page单元,根据访问计数大小判断冷热程度,然后再回收冷的内存page单元的page,这样的内存回收效率比较高。但是又有一个问题,如果一个page在某个时间点被频繁访问,则它对应的内存page单元的访问计数将很大很大。但之后很长时间这个内存page单元里的page都不再被访问了,那内存回收时是否应该回收这些page呢?当然要回收,因为这些page很长时间都不被访问了。看来仅仅只有一个访问计数还不足以反应一个page的冷热!

于是想到一个办法,再定义一个全局变量age,每一分钟加1。每个内存page单元也定义一个变量age,当某个page被访问了,用全局age赋值于它对应的内存page单元的age。如果一个内存page单元里的page长时间不被访问,它的age与全局变量age将相差很大!内存回收时正是要回收这些age与全局age相差很大的内存page单元!

好的,有了以上铺垫,继续深化。本文介绍的异步内存回收主要思路如下:

1:为每个文件分配一个file_stat数据结构,管理每个文件的所有pagecache。并且把每个文件的pagecache均分成若干个区域(每个区域包含4个索引连续的page),每个区域作为一个内存page单元。并为每个内存page单元分配一个file_area数据结构,主要反应这个内存page单元page的冷热,如下图所示:

注意:内存page单元file_area是同一个概念,下文会经常用到。并且,file_area代表了它对应的4个page,file_area的冷热等同于page的冷热,通过file_area间接控制了page。

 2:创建一个内核线程,负责异步内存回收工作,默认每1分钟运行一次,每次运行时先令全局age计数加1(这个age的思路参考了MGLRU的方案,作用一致)。同时,每个文件的每个内存page单元file_area,也有一个age(注意,内存page单元file_area的age和访问计数,都反应file_area的冷热,但是作用有差异,3.2.3节详细介绍)。当某个内存page单元file_area对应的文件页page被访问了,则该file_area的age就要更新为全局age。file_area的age就是它的文件页page最近一次被访问时的全局age!如果一个file_area对应的文件页page长时间不被访问,它的age就很小,称为冷file_area,内存回收时就要回收这些冷file_area对应的文件页page。举个例子,如下图所示:

现在全局age是10,异步内存回收线程已经运行了10次(每个周期1分钟)。file_area1和file_area3的age是10,说明二者对应的文件页page最近一个周期被访问过,被判定是热file_area。file_area2的age是2,说明已经至少有8个周期(8分钟)内其对应的文件页page没有被访问过,被判定是冷file_area,内存回收时优先回收file_area2的4个文件页page。说明,判定为冷file_area的8分钟,是可以按照实际情况通过proc接口设置的,这里只是举例。

3:针对消耗pagecache多的文件,内存回收时优先扫描这些文件的内存page单元,大概率能回收很多冷page。这点第3节再介绍。

每个文件的内存page单元file_area在代码里是怎么组织起来呢?用一个内核双向链表list_head即可: 

如图,展示的一个文件的pagecache简易组织情况。这个文件的pagecache共有page0~page23这些文件页page(索引 0~23)。示意图中file_area表示一个内存page单元,它包含4个索引连续的文件页page。比如file_area1 对应的是索引是0、1、2、3的文件页page0、page1、page2、page3,其他file_area同理,这种形式下文会经常遇到。一共有6个file_area,最初全局age是0,每个file_area的age都是0。

注意,打断一下file_area除了保存在这个双向链表外,还按照file_area的索引(file_area对应的第一个文件页page索引除以4),把file_area指针保存在自定义的radix tree。这样可以根据文件页page的索引快速通过该自定义的radix tree找到对应的file_area指针。反过来,通过file_area的索引,再通过每个文件的file_stat结构的mapping成员(该文件的address_space页高速缓存结构)的radix/xarray tree,可以快速找到file_area对应的4个文件页page指针,然后对page进行内存回收。file_area指针的处理灵感,来自于内核文件页page的保存形式:page指针保存在radix/xarray tree,又链入active/inactive lru 双向链表。通过radix/xarray tree不管是查找page指针,还是file_area指针,速度都是极快的,这点性能损耗很低。找到file_area指针后,再统计该file_area对应的文件页page的访问频次,这点时间损耗也很低。整体来说,这个设计也是该异步内存回收方案性能损耗低很重要的原因。详情下文会一一讲解。

好的,接着被打断的上文继续说。假设在第5个周期,全局age增加为5,此时file_area1和file_area5对应的page被访问了,则把全局age赋值给这些file_area自己的age,如下:

这些file_area的组织比较混乱,有些file_area最近访问过,有些file_area最近没访问过。内存回收时只想把最近没有访问过的file_area(age很小)找出来,遍历整个链表代价太大了!于是当一个file_area的page被访问时,就把file_area移动到链表头(不是每次访问都移动到链表头,有一定策略),如下:

这样就好多了,我只用从链表尾遍历file_area,遇到age很小的file_area则一直向前遍历,如果遇到age偏大的则结束遍历。这样就避免了很多无用功,因为最近被访问过的文件页page对应的file_area都移动到了链表头!

最后,创建一个异步内存回收线程,从这个链表尾依次扫出 file_area6、file_area2、file_area4、file_area3这些age很小的file_area。因为这些file_area对应的的文件页page较长时间都没有访问了,那就把这些page找出来并回收掉。

这就是本文异步内存回收的基本方案,就是想办法把长时间未被访问的文件pagecache的文件页page找出来并回收掉!难点之一把产生pagecache的文件组织起来,判定这些文件pagecache中,哪些文件页page是频繁访问的(page),哪些文件页page是长时间不被访问的(page),最后把冷page回收掉!另外一个难点是怎么高效的组织这些文件的冷热文件页page,快速找到每个文件的冷文件页page,性能损耗还要低!当然还有很多其他细节,下文结合示意图再详细讲讲。

3:示意图详解异步内存回收机制

3.1 整体框架介绍

前文已经介绍了针对单个文件的pagecache回收思路,一个linux系统有成千上万个文件,每个文件对应一个file_stat结构,该怎么组织起来这些文件? 一个文件也会有相当多文件页page,这就导致会有相当多数目的内存page单元file_area(默认一个file_area对应4个索引连续的文件页page)。这些file_area对应的文件页page有些是频繁访问的(热page);有些是很少访问(冷page)而要参与内存回收的;有些file_area的文件页page被内存回收后短时间内又被访问了,发生refault现象,则这种file_area对应的文件页page需要一段时间内禁止再内存回收,防止再发生refault……..情况很复杂,该怎么组织起来这些file_area?一点一点讲解。

一个文件对应一个file_stat结构,一个文件的pagecache中,4个文件页page对应一个内存page单元file_area,file_stat怎么与file_area关联起来?file_stat又怎么与文件的inode、页高速缓存结构address_space产生联系的呢?当访问一个文件的pagecache时,便会执行mark_page_accessed()函数(现在也在考虑换成copy_page_to_iter等函数,效果一样,但是可以解决高版本内核buffer io write不执行mark_page_accessed函数的问题,阅读本方案的源码时需注意),原始定义如下:

void mark_page_accessed(struct page *page)

该函数默认功能是将inactive lru链表上的page随着访问次数的增加而移动到active lru链表。本方案 kprobe 该函数,获取到读写的文件页page指针,通过page->mapping得到文件页高速缓存结构struct address_space。而address_space结构体最后在红帽8和9系列内核有预留字段unsigned long rh_reserved1(大部分高版本的内核发行版address_space结构体最后都有预留字段,如红帽8和9、阿里龙蜥OS、腾讯opencloud OS、安卓手机内核)。

本方案正是把为文件分配的file_stat结构指针赋值给该文件唯一的address_space结构体的预留字段的成员unsigned long rh_reserved1。这样一来,就把文件的文件页page、文件页高速缓存address_space、radix/xarray tree(保存文件页page指针)、file_stat、file_area 串联起来了!如下示意图所示:

page、address_space、file_stat结构体通过其成员相互指向,而file_area默认添加在file_stat结构体成员file_area_temp这个链表上。那系统成百上千个文件的file_stat以及每个文件产生的属性繁多的file_area(频繁访问的、很少访问的、发生refault过的)又是怎么组织起来的呢?下文重点介绍。

3.2 文件file_stat、内存page单元file_area的组织关系

3.2.1 file_stat 是怎么组织起来的?

本方案把文件分成3类,普通文件、大文件、热文件。

  1. 普通文件:一个文件最开始读写产生pagecache默认被判定为普通文件
  2. 大文件:当一个文件的文件页page数量大于某个阀值(比如1G)则判定为大文件,异步内存回收时优先扫描大文件的文件页page,因为有较大概率能回收到很多冷page
  3. 热文件:当一个普通文件或大文件的文件页page大部分都访问频繁则判定为热文件,异步内存回收时一定时间内不会遍历这些文件。

每个文件对应一个file_stat结构,本方案定义了一个struct hot_cold_file_global全局结构体,把普通文件、大文件、热文件组织起来,如下:

为了叙述方便下文把struct hot_cold_file_global结构体 struct list_head file_stat_temp_headstruct file_stat_temp_large_file_headstruct list_head  file_stat_hot_head链表简称为global file_stat_temp_headglobal  file_stat_temp_large_file_headglobal  file_stat_hot_head链表。下一节开始介绍文件file_stat和内存page单元file_area的关系。

3.2.1 file_stat和file_area的关系

如图,file_stat结构体有各种各样的链表file_area_temp、file_area_hot、file_area_refault、file_area_free_temp、file_area_free,分别保存不同属性的file_area。

  1. 文件file_stat的struct list_head file_area_temp链表:file_area默认添加到file_area_temp链表。异步内存回收线程只会遍历file_area_temp链表上file_area!如图该文件的文件页page8~page11、page12~page15对应的内存page单元file_area3、file_area4就是在file_area_temp链表。
  2. 文件file_stat的struct list_head file_area_hot链表:如果file_area对应的文件页page被频繁访问,被判定是热file_area,于是把该热file_area移动到file_area_hot链表。异步内存回收线程在较长一段时间都不会遍历file_area_hot链表上的file_area,因为这些file_area最近大概率还会被访问。如图该文件的文件页page0~page3、page16~page19因为频繁访问,则把对应的内存page单元file_area1、file_area5移动到file_area_hot链表。
  3. 文件file_stat的struct list_head file_area_refault链表:如果file_area对应的文件页page被内存回收后,短时间又被访问,则被判定是refault  file_area。于是把该file_area移动到 file_area_refault链表,这些file_area在较长一段时间不会再参与内存回收,即便age与全局相差较大!如图该文件的文件页page20~page23在内存回收后,短时间内这几个索引的page又被访问了,发生refault现象,于是把page20~page23对应的内存page单元file_area6移动到file_area_refault链表。
  4. 文件file_stat的struct list_head file_area_free_temp链表:内存回收时,把file_area_temp链表尾的冷file_area移动到file_area_free_temp链表。内存回收时正是遍历每个文件file_stat的file_area_free_temp链表上的file_area对应的文件页page。如图,page36~page39、page4~page7 正在被内存回收,对应的内存page单元file_area10、file_area2正是在file_area_free_temp链表。
  5. 文件file_stat的struct list_head file_area_free链表:内存回收后的file_area要从file_area_free_temp链表移动到file_area_free链表,如果该file_area对应的文件页page还是长时间不被访问则释放掉file_area结构。如果又被访问了则要把file_area移动回file_area_temp或file_area_refault链表。如图page32~page35在内存回收后,对应的file_area9则被移动到file_area_free链表。

下文为了叙述方便,把文件file_statstruct list_head file_area_temp链表下文简称file_stat->file_area_tempstruct list_head file_area_hot下文简称file_stat->file_area_hotstruct list_head file_area_refault下文简称file_stat->file_area_refaultstruct list_head file_area_free_temp下文简称file_stat->file_area_free_tempstruct list_head file_area_free下文简称file_stat->file_area_free

3.2.3 file_area的冷热判断

前文多次提到冷热file_area,反应到代码层面是怎么界定的?先看下file_area结构体的主要成员,主要有两个:

file_area_age就是file_area的age。异步内存回收周期默认1分钟,每过一个周期令全局age加1,当file_area对应的文件页page被访问,则把全局age赋值给file_area的成员file_area_age。因此,file_areaage就是最近一次被访问时的全局agefile_areaage与全局age差值越小,说明file_area对应的文件页page最近被访问过;file_areaage与全局age差值很大,说明这个file_area对应的文件页page很长时间没有被访问了(file_area)。内存回收时正是优先遍历冷file_area对应的文件页page(page)

access_count是file_area在一个周期的访问计数,file_area对应的文件页page每访问一次则access_count加1。如果一个周期内file_area对应的文件页page被访问的总次数大于阀值,被判定为热file_area,则把该file_area移动到该文件的file_stat->file_area_hot链表。异步内存回收线程在较长一段时间都不会遍历file_stat->file_area_hot链表上的file_area,因为这些file_area最近大概率还会被访问。

file_area的age和access_count都与file_area的冷热有关系,但用途不一样。在一个内存回收周期内,如果file_area的access_count大于阀值,则立即判定该file_area是热的,然后移动到file_stat->file_area_hot链表,禁止一段时间内被异步内存回收线程遍历到。file_area的age表示file_area广义的冷热程度,如果异步内存回收线程遍历到某个file_areaaccess_count较小,但是file_areaage与全局age差值较小,也被判定是热file_area,这种file_area不参与内存回收,只是热的程度比不上access_count很大的file_area

好的,文件file_stat及内存page单元file_area的组织关系已经介绍过了。下文举个例子,介绍下一个文件的文件页page(pagecache)的回收过程。

3.3 异步内存回收举例

假设有个进程访问test文件,下文演示下怎么识别出哪些文件页page是频繁访问的,哪些是不经常访问,简单说就是识别出冷热文件页page,然后只回收冷文件页page。注意,这个演示与实际代码实现有差异,为了演示方便简化了很多。

在第1个内存回收周期,全部读取98304大小test文件。于是先为该文件创建一个file_stat结构并添加到global file_stat_temp_head链表。接着为该文件的98304大小的pagecache分配6个内存page单元file_area1~file_area6。file_area1表示索引是0~3的文件页page0~page3,其他类推。file_area1~file_area6最初添加到该文件file_stat->file_area_temp链表。如下图:

接着来到第2个内存回收周期,全局age加1为1。test文件的索引是16、20的文件页page16、page20被读取了两次,则对应的内存page单元file_area5、file_area6的age被赋值为全局age,且access_count增加到2。接着还要把file_area5、file_area6移动到file_stat->file_area_temp链表头,因为被访问过的file_area都要移动到链表头(实际代码不是每次访问都移动到链表头,有策略,这里是为了易于说明)!效果如下图:

注意,这里设定只有file_areaage与全局age的差值大于阀值N(假设N=5)时,才被判定是冷file_area,然后才会回收该file_area对应的文件页page显然此时还没有达到内存回收条件的file_area

接着来到第3个内存回收周期,全局age加1为2。这个周期内,该文件的文件页page16、page20连续被访问了6次,对应的file_area5和file_area6的access_count被赋值6。二者因为访问次数access_count超过了阀值M(假设M=5),则file_area5和file_area6判定为热file_area。于是把file_area5和file_area6移动到file_stat->file_area_hot链表,age也被赋值为2。同时该文件的文件页page5、page8也被访问了一次,则对应的file_area2、file_area3被移动到file_stat->file_area_temp链表头,且age被赋值2,access_count赋值1,最终效果如图所示:

随后一直到周期7,该文件都没再被访问了,此时出现了内存回收契机。因为此时全局age增加到6,而该文件在file_stat->file_area_temp链表尾的内存page单元file_area1、file_area4对应的文件页page0~page3、page12~page15 一直没有被访问,age是0,与全局age的差值大于阀值N(假设N=5),则二者被判定是冷file_area。然后在该周期,异步内存回收线程把file_area1、file_area4移动到file_stat->file_area_free_temp链表。如图:

注意,file_area的access_count只表示单个周期内file_area被访问的次数。每个周期开始,file_area的access_count要先清0,然后file_area每被访问一次则access_count加1。

接着,异步内存回收线程遍历file_stat->file_area_free_temp链表上的file_area1和file_area4,根据该文件address_space的radix/xarray tree(保存文件页page指针),找到对应的page0~page3、page12~page15文件页指针。然后就尝试回收掉这8个page,具体回收流程大体模仿内核原生内存回收代码,但有很大改动。这些文件页page回收完后,把file_area1和file_area4移动到file_stat->file_area_free链表。如图:

假设file_area1对应的索引是0的文件页page0在内存回收后,索引是0的page立即又被进程访问了。则发生了refault现象,于是把file_area1移动到file_stat-> file_area_refault链表,这样file_area1后期有相当长一段时间不会再被参与内存回收,即便它的age与全局age相差较大。如图:

 好的,以上把针对单个文件的文件页page的内存回收流程大体演示了一遍。系统有成百上千个文件时,每个文件的文件页page回收过程类似。但再结合普通文件、大文件、热文件的文件页page的回收,就很复杂了。下文从代码角度介绍下整体的异步内存回收流程,或许可以加深理解。

4:基于源码流程图聊聊异步内存回收

该方案的源码主要有两个流程:

1:文件页读写后,会执行到内核原生mark_page_accessed()函数。本方案kprobe mark_page_accessed()函数(现在也在考虑换成copy_page_to_iter等函数,效果一样,但是可以解决高版本内核buffer io write不执行mark_page_accessed函数的问题,阅读本方案的源码时需注意),然后在kprobe注册的handler函数里执行自定义hot_file_update_file_status()函数。

在函数hot_file_update_file_status()函数里:为读写的文件分配file_stat;为访问的文件页page分配对应的file_area;把file_stat、file_area添加到各种链表;增加file_area的age和访问计数access_count;判断热file_area;普通文件在达到一定条件升级到大文件或者热文件等等。

2:异步内存回收线程的入口函数是walk_throuth_all_file_area(),默认一分钟运行一次。在walk_throuth_all_file_area()函数里:令全局age加1;遍历global file_stat_temp_large_file_head大文件链表和global file_stat_temp_head普通文件链表的file_stat,从这些文件file_stat-> file_area_temp链表尾找出age与全局age差值大于阀值N的file_area(这些file_area就是冷file_area,对应的文件页page长时间不被访问,本次就回收这些page)。

如果遍历到的文件file_stat的file_stat->file_area_hot和file_stat-> file_area_refault链表上的file_area长时间不被访问,则要降级移动到file_stat->file_area_temp链表;如果global file_stat_temp_large_file_head 链表上的大文件,对应文件页page减少到阀值以下,则降级为普通文件,并把file_stat移动到普通文件global file_stat_temp_head链表;如果global file_stat_hot_head链表上的热文件,大部分file_area不再是热file_area,则也要降级为普通文件,并把file_stat移动到普通文件global file_stat_temp_head或大文件global  file_stat_temp_large_file_head链表等等。

细节还是比较复杂的,下边先看下文件被读写最后执行到的hot_file_update_file_status()函数的整体流程图:

可以发现,这个流程主要就是操作文件file_stat和本次访问的文件页page对应的内存page单元file_area:更新 file_area的age、增加file_are计数、热文件和大文件的判定、热file_area、refault file_area的判定和处理。下边看下异步内存回收线程入口函数walk_throuth_all_file_area()的整理流程:

可以发现,这个流程主要讲解怎么一个个扫描文件file_stat,从 file_stat->file_area_temp链表尾扫出冷file_area,然后把冷file_area对应的文件页page回收掉。还有就是file_stat->file_area_hot和file_stat->file_area_refault链表上file_area怎么降级到file_stat->file_area_temp链表,热文件、大文件怎么降级为普通文件等等。源码就不再列了,后续的文章再讲解。

5:测试效果

硬件配置:cpu i5-7500,内存DDR4 2133MHz、硬盘 WDC WD10EZEX-00MFCA0,xfs文件系统、内核版本5.14.0-284.11.1

目前主要测试了如下几个场景

1:每隔一段时间读写10个左右的大小在100M~2G的文件,然后cat /proc/meminfo观察系统cache总量。这些文件产生的pagecache总能在规定的时间内(这个时间可通过proc接口调整)不被访问后,被判定为冷page,然后被回收掉,系统的总cache量总能跌落回最初水平。

2:编译内核,把cpu消耗光。然后每隔一段时间读写几个大小在1G左右的文件,这些文件产生的pagecache也能在长时间不被访问后,及时被回收掉。top观察异步内存回收线程的cpu使用率,低于5%,大部分时间接近0。并且,perf top也没发现热点函数。这说明本异步内存回收方案的cpu损耗是很低的。

3:机械盘,每次drop_caches后,cat 1G大小的文件,启动/禁止该异步内存回收功能,总耗时都在12.5s左右(iostat显示IO读流量在80M/s~100M/s)。这说明该异步内存回收模块并没有带来明显的性能损耗!注意,在SSD盘上测试,结果也是一样的,没有明显的性能损耗。当然,不同硬盘、内核、文件系统 估计测试结果会有差异。

6:总结

本文只是简单介绍了一下异步内存回收的大体方案,实际还有很多细节没讲解,比如:

  • 1:针对在加载该异步内存回收驱动前被访问并产生pagecache的文件,之后一直没有再访问过,这种文件的pagecache无法被该驱动统计到并异步回收。空占着大量pagecache但是无法被回收掉!针对这种问题的解决方法是:遍历系统所有文件系统的super_block,再遍历super_block上的每个文件inode,找到这些不再被访问但消耗了pagecache的文件,异步回收掉pagecache。思路与drop_caches类似,但是会做不少性能优化。
  • 2:前文提过,文件的内存page单元file_area都是组织在file_stat->file_area_temp等双向链表。这就有个问题,当某个file_area对应的文件页page被访问,就要遍历这个双向链表找到对应的file_area。如果链表上的file_area很多,遍历链表就会很耗时!就是设计了类似的radix tree方案,给每个file_area设置一个类似page索引的编号start_index,start_index=该file_area对应的第一个文件页page的索引除以4,因为默认一个file_area对应4个索引连续文件页page。然后把file_area指针按照start_index保存到自定义的radix tree。后续知道一个文件页page的索引后,可以快速通过该radix tree找到对应的内存page单元file_area。
  • 3:单个文件file_stat->file_area_hot和file_stat->file_area_refault链表上的file_area,因为随后可能有较大概率被访问,即便age与全局age相差较大,也不参与内存。但是也不能无限期不参与内存回收!当满足一定条件下也需要把这些file_area移动到file_stat-> file_area_temp链表,参与内存回收,细节需要深究。
  • 4:如果文件inode被删除了,而该文件的file_stat、file_area正在被访问,怎么做好并发访问?这是实际代码开发时最难的一点。
  • 5:怎么模仿内核原生内存回收流程,实现本方案的page的隔离、回收,有很多重点。并且有很多内核非export函数不能在本方案的内核ko 里用,这个难题也很棘手。
  • 6:本方案把文件分配3种,普通文件、大文件、热文件,对应文件file_stat分别放置在global file_stat_temp_head、file_stat_temp_large_file_head、file_stat_hot_head链表。当普通文件的file_area数量大于阀值则被判定为大文件,当大文件的file_area数量减少小于阀值,则要降级到普通文件;当普通文件或大文件的大部分文件页都频繁访问,则该文件就被判定为热文件。而如果热文件的大部分file_area经过较长时间后都没访问了,变成冷file_area,就要降级为普通文件或大文件。内存回收时优先扫描大文件的file_area,其次是普通文件的file_area,热文件的file_area不参与内存回收。普通文件、大文件、热文件 3者之间的转化,具体在内存回收时是怎么运作的,也是一个复杂点。
  • 7:代码里不能无脑spin_lock加锁防止并发,而尝试用原子操作、内存屏障等手段控制并发,收获了经验。

当然,还有很多其他细节,代码开发测试过程也遇到很多棘手的bug!就不在本文介绍了,后续再单独写文章。

其实有一个想法,能否把本内存回收方案合入内核呢?比如:修改内核原来保存文件页page指针的radix/xarray tree,不再保存page指针,而是保存file_area指针。file_area结构体保存对应的4个文件页page指针,通过每个文件页page的索引从file_area结构体可找到对应的page指针。每次文件页page被访问后,直接从radix/xarray tree找到对应的file_area指针,再按照文件页page索引从file_area找到并返回该page指针。接着,还是用file_area精确统计对应的4个文件页page的访问频次,据此判断这些文件页page的冷热。最后,再开一个内核线程回收冷file_area对应的文件页page。整体原理与本文的异步内存回收方案是一样的,但对内核的改动不大,同时应该能实现不错的异步内存回收效果。

更重要的,不用再kprobe 打点mark_page_accessed等函数获取每次读写的文件页page,这部分损耗也可以省掉。因为kprobe本身是靠异常中断机制实现的,是有性能损耗的。并且,还只用遍历一遍radix/xarray tree就能得到本次读写的文件页page指针和对应的file_area指针,又降低了性能损耗。因为做到内核ko的方案,统计page访问频次的过程是:先在内核里遍历radix/xarray tree得到本次访问的page指针,然后通过kprobe 产生的中断执行hot_file_update_file_status函数。在里边再遍历保存file_area指针的radix tree,才最终得到file_area,这相当于遍历了两次radix tree!总之,把本异步内存回收方案做进内核,肯定是能进一步降低性能损耗的!

本次实践是个不错的学习内核内存回收的机会!在模仿内核原生内存回收源码,修改成适用于本方案的内存回收代码的过程中,对内存回收的理解加深了很多。目前已经解决了很多bug,但不排除还存在隐藏bug,欢迎大佬的指导和批评指正。

7:后续补充及编译进内核测试

这几天又想到一个测试,就是把一个文件cat读到cache后,再连续cat读取该文件。此时就是纯从cache中读文件了,不再与磁盘有交互,读取的速度将极快。这个场景就很极端了,本异步内存回收模块的性能损耗能否扛得住吗?以下是测试数据

cat读取的文件大小禁止异步内存回收模块时,从cache中cat读取文件的耗时启用异步内存回收模块时,从cache中cat读取文件的耗时
100M20ms26ms
200M31ms45ms
500M

56ms

105ms
1G101ms212ms
2G195ms415ms

可以发现,在启用异步内存回收模块后,从cache中cat读取文件,有额外增加的耗时。尤其是当从cache中cat读取读取的文件大小是1G、2G时,额外耗时大于100ms。这对极短时间内大量读写文件cache并且对时间很敏感的业务,估计难以接受。造成这些额外耗时的根源在哪里呢?是否是我的异步回收模块导致的?还是kprobe打点内核mark_page_accessed、copy_page_to_iter等函数,触发的int3中断导致的?

简单测试一下就知道原因了,在kprobe打点内核mark_page_accessed、copy_page_to_iter等函数而注册的handler函数里,直接return掉,不再执行异步内存回收模块的函数,测试数据竟然相差不大。如下是测试数据:

cat读取的文件大小启用异步内存回收模块时,从cache中cat读取文件的耗时启用异步内存回收模块时,但是kprobe的hander函数直接return,从cache中cat读取文件的耗时
100M26ms25ms
200M45ms43ms
500M105ms102ms
1G212ms205ms
2G415ms401ms

可以发现,额外耗时确实是在kprobe 的int3中断!这说明,启用异步内存回收模块后,从cache中cat读取文件增加的额外耗时,根源是kprobe打点内核mark_page_accessed、copy_page_to_iter等函数,触发的int3中断导致的!perf跟踪cat读取文件的过程,发现确实kprobe的int3中断耗时较长!并且,大体计算了一下,这额外耗时的95%(甚至更高)都在kprobe的int3中断。

注意,这是x86的情况,如果是arm64架构,可能kprobe触发的中断耗时会少点,以实际测试数据为准。ok,原因查清楚了,问题该怎么解决呢?这个问题很好解决!

把本异步内存回收模块编译进内核,读文件时不再kprobe 打点内核mark_page_accessed、copy_page_to_iter等函数就行了,改动极小:把本异步内存回收源码里的"#define CONFIG_ENABLE_KPROBE"这一行注释掉,在mm/filemap.c增加如下3行标红的代码就可以了。如下是我在红帽9.2内核(5.14.0-284.11.1)做的改动:

  1. extern int hot_file_update_file_status(struct page *page);
  2. //pagecache读调用的函数
  3. ssize_t filemap_read(struct kiocb *iocb, struct iov_iter *iter,
  4.                 ssize_t already_read)
  5. {
  6.     //把本次读取的文件页page数据复制到用户空间传入的buf
  7.     copied = copy_folio_to_iter(folio, offset, bytes, iter);
  8.     //执行本异步内存回收模块统计文件页page访问频次的hot_file_update_file_status函数
  9.     hot_file_update_file_status(&folio->page);
  10. }
  11. //pagecache写调用的函数
  12. ssize_t generic_perform_write(struct kiocb *iocb, struct iov_iter *i)
  13. {
  14.     //把本次写入的数据从用户空间传入的buf复制到文件页page
  15.     copied = copy_page_from_iter_atomic(page, offset, bytes, i);
  16.     //执行本异步内存回收模块统计文件页page访问频次的hot_file_update_file_status函数
  17.     hot_file_update_file_status(page);
  18. }

之前kprobe打点copy_folio_to_iter(就是copy_page_to_iter)函数,然后在kprobe注册的handler函数里执行hot_file_update_file_status函数,统计每个文件的每个区域的文件页page的访问频次。现在不使用kprobe了,在pagecache读最后调用的filemap_read函数里,在copy_folio_to_iter(就是copy_page_to_iter)函数执行后,直接执行hot_file_update_file_status函数。在pagecache写最后执行的generic_perform_write函数里,copy_page_from_iter_atomic执行后,也是直接执行hot_file_update_file_status函数。这两处修改后,与原本内核ko方案kprobe打点的效果是一样的!

按照以上就改动几行代码的修改思路,把本异步内存回收模块编译进内核。测试再从cache中cat读取2G大小的文件,额外耗时仅增加了5%,即10ms左右,这个就可以接受了吧。并且,这个耗时还能进一步降低,就是按照第6节提到的思路:原本保存文件页page指针radix/xarray tree,不再保存page指针,而是保存该page对应的file_area指针,file_area指针里再保存4个page指针。这样只用遍历一次radix/xarray tree就能得到file_area指针和page指针,cpu损耗能进一步降低,预计可以把cpu损耗降低到2%!既能精确统计每个文件页page的访问频次,又能精准的回收冷文件页page,cpu损耗还很低,还是挺有实用价值的!

如果你的业务会极短时间内频繁读写几个G的文件cache,并且又对时间损耗又很敏感,需要把该异步内存回收方案编译进内核。否则,可以直接编译成内核ko使用,不用修改编译内核!当然,实际情况繁多,需要实际测试一下看看效果再做决定。

Logo

更多推荐