一,概述
当linux系统内存压力就大时,就会对系统的每个压力大的zone进行内存回收,内存回收主要是针对匿名页和文件页进行的。对于匿名页,内存回收过程中会筛选出一些不经常使用的匿名页,将它们写入到swap分区中,然后作为空闲页框释放到伙伴系统。而对于文件页,内存回收过程中也会筛选出一些不经常使用的文件页,如果此文件页中保存的内容与磁盘中文件对应的内容一致,说明此文件页是一个干净的文件页,就不需要进行回写,直接将此页作为空闲页框释放到伙伴系统中,相反,如果文件页保存的数据与磁盘中文件对应的数据不一致,则认定此文件页为脏页,需要先将此文件页回写到磁盘中对应数据所在位置上,然后再将此页作为空闲页框释放到伙伴系统中。这样当内存回收完成后,系统空闲的页框数量就会增加,能够缓解内存压力,听起来很厉害,它也有一个弊端,就是在回收过程中会对系统的IO造成很大的压力,所以,在系统内,一般每个zone会设置一条线,当空闲页框数量不满足这条线时,就会执行内存回收操作,而系统空闲页框数量满足这条线时,系统是不会进行内存回收操作的。
二,zone的阀值
内存回收是以zone为单位进行的(也会以memcg为单位,这里不讨论这种情况),而系统判断一个zone需不需要进行内存回收,如上面所说,为zone设置一条线,当此zone的空闲页框不足以到达这条线时,就会对此zone进行内存回收,实际上一个zone有三条线,这三条线分别是最小阀值(WMARK_MIN),低阀值(WMARK_LOW),高阀值(WMARK_HIGH),它们都保存在zone的watermark[NR_WMARK]数组中,这个数组中保存的是各个阀值要求的页框数量,而每个阀值都会对内存回收造成影响。而它们的描述如下:
这三个阀值的关系是:min阀值 < low阀值 < high阀值。在系统初始化期间,根据系统中整个内存的数量与每个zone管理的页框数量,计算出每个zone的min阀值,然后low阀值 = min阀值 + (min阀值 / 4),high阀值 = min阀值 + (min阀值 / 2)。这样就得出了这三个阀值的数值,我们可以通过/proc/zoneinfo中查看这三个阀值的数值:
可以很明显看出来,相对于整个zone管理的总页框数量(managed),这三个值是非常非常小的,连managed的1%都不到,这些都是在系统初始化期间进行设置的,具体设置函数是__setup_per_zone_wmarks()。有兴趣的可以去看看。这个阀值对内存回收的进行具有很重要的意义,后面会详细进行说明。
对于zone的内存回收,它针对三样东西进程回收:slab、lru链表中的页、buffer_head。这里只讨论内存回收针对lru链表中的页是如何进行回收的。lru链表主要用于管理进程空间中使用的内存页,它主要管理三种类型的页:匿名页、文件页以及shmem使用的页。在内存回收过程中,说简单些,就是将lru链表中的一些页数据放到磁盘中,然后将这些页释放,当然实际上可没有那么简单,这个后面会详细说明。
在说内存回收前,要先补充一些知识,因为内存回收并不是一个孤立的功能,它内部会涉及到其他很多东西,比如内存分配、lru链表、反向映射、swapcache、pagecache等。
三,判断页是否能够回收
抛开内存回收不谈,在内核中,只有一种页能够进行回收,就是页描述符中的_count为0的页,每个页都有自己唯一的页描述符,而每个页描述符中都有一个_count,这个_count代表的是此页的引用计数,当_count为0时,说明此页是空闲的,存放在伙伴系统中,每当有一个进程映射了此页时,此页的_count就会++,也就是当某个页被10个进程映射了,它的page->_count肯定大于10(不等于10是因为可能还有其他模块引用了此页,比如块层、驱动等),所以也可以反过来说,如果某个页的page->_count == 0,那就说明此页可以直接释放回收了。也就是说,内核实际上回收的是那些page->_count == 0的页,但是如果真的是这样,内存回收这就没有任何意义了,因为当最后一个引用此页的模块释放掉此页的引用时,如果page->_count为0,肯定会释放回收此页的。实际上内存回收做的事情,就是想办法将一些page->_count不为0的页,尝试将它们的page->_count降到0,这样系统就可以回收这些页了。下面是我总结出来在内存回收过程中会对页的page->_count产生影响的操作:
四,lru链表
lru链表主要作用就是将页排序,将最应该回收的页放到最后面,最不应该回收的页放到最前面,,然后进行内存回收时,就会从后面向前面进行扫描,将扫描到的页尝试进行回收,具体见linux内存源码分析 – 内存回收(lru链表)。这里只需要记住一点,回收的页都是非活动匿名页lru链表或者非活动文件页lru链表上的页。这些页包括:进程堆、栈、匿名mmap共享内存映射、shmem共享内存映射使用的页、映射磁盘文件的页。
五,页的换入换出
首先先说明一下页描述符中对内存回收来说非常必要的标志:
内存回收做的事情就是想办法将目标页的page->_count降到0,对于那些没有进程映射了页,释放起来就很简单,如果页映射了磁盘文件,并且页为脏页(被写过),那就就把页中的数据回写到磁盘中映射的文件中,而如果页没有映射磁盘文件,那么直接释放即可。但是对于有进程映射的页,如果此页映射了磁盘文件,并且页为脏页,那么和之前一样,将此页进行回写,然后释放回收即可,但是此页没有映射磁盘文件,情况就会稍微复杂,会将页数据写入到swap分区中,然后将此页释放回收。总结如下:
接下来会分为非活动匿名页lru链表的页的换入换出,非活动文件页lru链表的页的换入换出进行描述。
匿名页lru链表上保存的页为:进程堆、栈、数据段,匿名mmap共享内存映射,shmem映射。这些类型的页都有个特点,在磁盘上没有映射对应的文件(shmem有对应的文件,是/dev/zero,但它不是映射此设备文件)。而在内存回收时,会从非活动匿名页lru链表末尾向前扫描一定数量的页框,然后尝试将这些页框进行回收,而如果这些页框没有进程映射它们,那么它们可以直接释放,而如果有进程映射了它们,那么系统就必须将这些页框回写到磁盘上。在linux系统中,你可以给系统挂载一个swap分区,这个分区就是专门用于保存这些类型的页的。当这些页需要回收,并且有进程映射了它们时,系统就会将这些页写入swap分区,需要注意,它们需要回收只有在内存不足进行内存回收时才会发生,也就是当系统内存充足时,是不会将这些类型的页写入到swap分区中的(使用memcg除外),在磁盘上,一个swap分区是一组连续的物理扇区,比如一个1G大小的swap分区,那么它在磁盘上会占有1G大小磁盘块,然后这块磁盘块的第一个4K,专门用于存swap分区描述结构的,而之后的磁盘块,会被划分为一个一个4K大小的页槽(正好与普通页大小一致),然后将它们标以ID,如下:
每个页槽可以保存一个页的数据,这样,一个被换出的页就可以写入到磁盘中,系统也能够将这些页组织起来了。虽然是叫swap分区,但是内核似乎并不将swap分区当做一个磁盘分区来看待,更像的是将其当做一个文件来看待,因为这个,每个swap分区都有一个address_space结构,这个结构是每个磁盘文件都会有一个的,这个address_space结构中最重要的是有一个基树和一个address_space操作集。而这里swap分区有一个,swap分区的address_space叫做swap cache,它的作用是从非文件页在回写到swap分区到此非文件页被回收前的这段时间里,起到一个将swap类型的页表项与此页关联的作用和同步的作用。在这个swap cache的基树中,将此swap分区的所有页槽组织在了一起。当非活动匿名页lru链表中的一个页需要写入到swap分区时,步骤如下:
- swap分配一个空闲的页槽
- 根据这个空闲页槽的ID,从swap分区的swap cache的基树中找到此页槽ID对应的结点,将此页的页描述符存入当中
- 内核以页槽ID作为偏移量生成一个swap页表项,并将这个swap页表项保存到页描述符中的private中
- 对页进行反向映射,将所有映射了此页的进程页表项改为此swap页表项
- 将此页的mapping改为指向此swap分区的address_space,并将此页设置为脏页
- 通过swap cache中的address_space操作集将此页回写到swap分区中
- 回写完成
- 此页要被回收,将此页从swap cache中拿出来
当一个进程需要访问此页时,系统则会将此页从swap分区换入内存中,具体步骤如下:
- 一个进行访问了此页,会先访问到之前设置的swap页表项
- 产生缺页异常,在缺页异常中判断此页在swap分区中,而不在内存中
- 分配一个新页
- 根据进程的页表项中的swap页表项找到对应的页槽和swap cache
- 如果以页槽ID在swap cache中没有找到此页,说明此页已被回收,从分区中将此页读取进来
- 如果以页槽ID在swap cache中找到了此页,说明此页还在内存中,还没有被回收,则直接映射此页
这样再此页没有被换出或者正在换出的情况下,所有映射了此页的进程又可以重新访问此页了,而当此页被完全换出到swap分区然后被回收后,此页就会从swap cache中移除,之后如果进程想要访问此页,就需要等此页被完全换入之后才行了。也就是这个swap cache完全为了提高效率,在页没有被回收前,即使此页已经回写到swap分区了,只要有进映射此页,就可以直接映射内存中的页,而不需要将页从磁盘读进来。对于非活动匿名页lru链表上的页进行换入换出这里就算是说完了。记住对于非活动匿名页lru链表上的页来说,当此页加入到swap cache中时,那么就意味着这个页已经被要求换出,然后进行回收了。
但是相反文件页则不是这样,接下来简单说说映射了磁盘文件的文件页的换入换出,实际上与非活动匿名页lru链表上的页进行换入换出是一模一样的,因为每个磁盘文件都有一个自己的address_space,这个address_space就是swap分区的address_space,磁盘文件的address_space称为page cache,接下来的处理就是差不多的,区别为以下三点:
- 对于磁盘文件来说,它的数据并不像swap分区这样是连续的。
- 当文件数据读入到一个页时,此文件页就需要在文件的page cache中做关联,这样当其他进程也需要访问文件的这块数据时,通过page cache就可以知道此页在不在内存中了。
- 并不会为映射了此文件页的进程页表项生成一个新的页表项,会将所有映射了此页的页表项清空,因为在缺页异常中通过vma就可以判断发生缺页的页是映射了文件的哪一部分,然后通过文件系统可以查到此页在不在内存中。而对于匿名页的vma来说,则无法做到这一点。
六,内存分配过程
要说清楚内存回收,就必须要先理清楚内存分配过程,在调用alloc_page()或者alloc_pages()等接口进行一次内存分配时,最后都会调用到__alloc_pages_nodemask()函数,这个函数是内存分配的心脏,对内存分配流程做了一个整体的组织。具体可以见我博客的另一篇文章linux内存源码分析 – 伙伴系统(初始化和申请页框)。主要需要注意的,就是在__alloc_pages_nodemask()中会进行一次使用low阀值的快速内存分配和一次使用min阀值的慢速内存分配,快速内存分配使用的函数是get_page_from_freelist(),这个函数是分配页框的基本函数,也就是说,在慢速内存分配过程中,收集到和足够数量的页框后,也需要调用这个函数进行分配。先简单说明快速内存分配和慢速内存分配:
通过以下这幅图,来说明流程:
说到内存分配过程,就必须要说说中的preferred_zone和zonelist,preferred_zone可以理解为内存分配时,最希望从这个zone进行分配,而zonelist理解为,当没办法从preferred_zone分配内存时,则根据zonelist中zone的顺序尝试进行分配,为什么会有这两个参数,是因为numa架构导致的,我们知道,当有多个node结点时,CPU跨结点访问内存是效率比较低的工作,所以CPU会优先在本node上的zone进行内存分配工作,如果本node上实在分配不出内存,那就尝试在离本node最近的node上分配,如果还是无法分配到,那就找再下一个node。这样每个node会将其他node的距离进行一个排序形成了其他node的一个链表,这个链表越前面的node就表示里本node越近,越后面的node就离本node越远。而在32位系统中,每个node有3个zone,分别是ZONE_HIGHMEM、ZONE_NORMAL、ZONE_DMA。每个区管理的内存数量不一样,导致每个区的优先级不同,优先级为ZONE_HIGHMEM > ZONE_NORMAL > ZONE_DMA,对于进程使用的页,系统优先分配ZONE_HIGHMEM的页框,如果ZONE_HIGHMEM无法分配页框,则从ZONE_NORMAL进行分配,当然,对于内核使用的页来说,大部分只会从ZONE_NORMAL和ZONE_DMA进行分配,这样,将这个zone优先级与node链表结合,就得到zonelist链表了,比如对于node0,它完整的zonelist链表就可能如下:
node0的管理区 node1的管理区
ZONE_HIGHMEM(0) -> ZONE_NORMAL(0) -> ZONE_DMA(0) -> ZONE_HIGHMEM(1) -> ZONE_NORMAL(1) -> ZONE_DMA(1)
因为每个node都有自己完整的zonelist链表,所以对于node1,它的链表时这样的
node1的管理区 node0的管理区
ZONE_HIGHMEM(1) -> ZONE_NORMAL(1) -> ZONE_DMA(1) -> ZONE_HIGHMEM(0) -> ZONE_NORMAL(0) -> ZONE_DMA(0)
这样得到了两个node自己的zonelist,但是在内存分配中,还不一定会使用node自己的zonelist,因为有些内存只希望从ZONE_NORMAL和ZONE_DMA中进行分配,所以,在每次进行内存分配时,都会此次内存分配形成一个满足的zonelist,比如:某次内存分配在node0的CPU上执行了,希望从ZONE_NORMAL和ZONEDMA区中进行分配,那么就会形成下面这个链表
node0的管理区 node1的管理区
ZONE_NORMAL(0) -> ZONE_DMA(0) -> ZONE_NORMAL(1) -> ZONE_DMA(1)
这样就是preferred_zone和zonelist,preferred_zone一般都是指向zonelist中的第一个zone,当然这个还会跟nodemask有关,这个就不细说了。
七,扫描控制结构
之前说内存压缩的文章也有涉及这个结构,现在详细说明一下,扫描控制结构用于内存回收和内存压缩,它的主要作用时保存对一次内存回收或者内存压缩的变量和参数,一些处理结果也会保存在里面,结构如下:
/* 扫描控制结构,用于内存回收和内存压缩 */struct scan_control { /* 需要回收的页框数量 */ unsigned long nr_to_reclaim; /* 申请内存时使用的分配标志 */ gfp_t gfp_mask; /* 申请内存时使用的order值,因为只有申请内存,然后内存不足时才会进行扫描 */ int order; /* 允许执行扫描的node结点掩码 */ nodemask_t *nodemask; /* 目标memcg,如果是针对整个zone进行的,则此为NULL */ struct mem_cgroup *target_mem_cgroup; /* 扫描优先级,代表一次扫描(total_size >> priority)个页框 * 优先级越低,一次扫描的页框数量就越多 * 优先级越高,一次扫描的数量就越少 * 默认优先级为12 */ int priority; /* 是否能够进行回写操作(与分配标志的__GFP_IO和__GFP_FS有关) */ unsigned int may_writepage:1; /* 能否进行unmap操作,就是将所有映射了此页的页表项清空 */ unsigned int may_unmap:1; /* 是否能够进行swap交换,如果不能,在内存回收时则不扫描匿名页lru链表 */ unsigned int may_swap:1; unsigned int hibernation_mode:1; /* 扫描结束后会标记,用于内存回收判断是否需要进行内存压缩 */ unsigned int compaction_ready:1; /* 已经扫描的页框数量 */ unsigned long nr_scanned; /* 已经回收的页框数量 */ unsigned long nr_reclaimed;};
结构很简单,主要就是保存一些参数,在内存回收和内存压缩时就会根据这个结构中的这些参数,做不同的处理,后面代码会详细说明。
这里我们只说说会几个特别的参数:
在快速内存回收、直接内存回收、kswapd内存回收中,这几个值的设置不一定会一致,也导致了它们对不同类型的页处理方式也不同。
除了sc->may_writepage会影响页的回写外,还有进行内存分配时使用的分配标志gfp_mask中的__GFP_IO和__GFP_FS会影响页的回写,具体如下:
这里还需要说说三个重要的内核配置:
/proc/sys/vm/zone_reclaim_mode
这个参数只会影响快速内存回收,其值有三种,
当此参数为0时,会导致快速内存回收只会对最优zone附近的几个需要进行内存回收的zone进行内存回收(说快速内存会解释),而只要不为0,就会对zonelist中所有应该进行内存回收的zone进行内存回收。
当此参数为0x1(001)时,就如上面一行所说,允许快速内存回收对zonelist中所有应该进行内存回收的zone进行内存回收。
当此参数为0x2(010)时,在0x1的基础上,允许快速内存回收进行匿名页lru链表中的页的回写操作。
当此参数0x4(100)时,在0x1的基础上,允许快速内存回收进行页的unmap操作。
/proc/sys/vm/laptop_mode
此参数只会影响直接内存回收,只有两个值:
/proc/sys/vm/swapiness
此参数影响进行内存回收时,扫描匿名页lru链表和扫描文件页lru链表的比例,范围是0~200,系统默认是30:
八,内存回收
对zone进行一次内存回收流程
内存回收可以针对某个zone进行回收,也可以针对某个memcg进行回收,这里我们就只讨论针对某个zone进行回收的情况,无论是针对zone进行内存回收还是针对memcg进行内存回收,整个内核只有一个函数入口,就是是shrink_zone()函数,也就是内核中无论怎么样进行内存回收,最终调用到的函数都会是这个shrink_zone(),这个函数要求调用者传入一个设置好的struct scan_control结构以及目标zone的指针。虽然是对zone进行一次内存回收,但是实际上在这个函数里,如果此zone还可以回收页框时,可能会对zone进行多次的内存回收,这是因为两个方面
- 如果每次仅回收2^order个页框,满足于本次内存分配(内存分配失败时才会导致内存回收),那么下次内存分配时又会导致内存回收,影响效率,所以,每次zone的内存回收,都是尽量回收更多页框,制定回收的目标是2^(order+1)个页框,比要求的2^order多了一倍。但是当非活动lru链表中的数量不满足这个标准时,则取消这种状态的判断。
- zone的内存回收后往往伴随着zone的内存压缩(见linux内存源码分析 – 内存压缩),所以进行zone的内存回收时,会回收到空闲页框数量满足进行内存压缩为止。
我们看一下这个shrink_zone():
/* 对zone进行内存回收 * 返回是否回收到了页框,而不是十分回收到了sc中指定数量的页框 * 即使没回收到sc中指定数量的页框,只要回收到了页框,就返回真 */static bool shrink_zone(struct zone *zone, struct scan_control *sc){ unsigned long nr_reclaimed, nr_scanned; bool reclaimable = false; do { /* 当内存回收是针对整个zone时,sc->target_mem_cgroup为NULL */ struct mem_cgroup *root = sc->target_mem_cgroup; struct mem_cgroup_reclaim_cookie reclaim = { .zone = zone, .priority = sc->priority, }; struct mem_cgroup *memcg; /* 记录本次回收开始前回收到的页框数量 * 第一次时是0 */ nr_reclaimed = sc->nr_reclaimed; /* 记录本次回收开始前扫描过的页框数量 * 第一次时是0 */ nr_scanned = sc->nr_scanned; /* 获取最上层的memcg * 如果没有指定开始的root,则默认是root_mem_cgroup * root_mem_cgroup管理的每个zone的lru链表就是每个zone完整的lru链表 */ memcg = mem_cgroup_iter(root, NULL, &reclaim); do { struct lruvec *lruvec; int swappiness; /* 获取此memcg在此zone的lru链表 * 如果内核没有开启memcg,那么就是zone->lruvec */ lruvec = mem_cgroup_zone_lruvec(zone, memcg); /* 从memcg中获取swapiness,此值代表了进行swap的频率,此值较低时,那么就更多的进行文件页的回收,此值较高时,则更多进行匿名页的回收 */ swappiness = mem_cgroup_swappiness(memcg); /* 对此memcg的lru链表进行回收工作 * 此lru链表中的所有页都是属于此zone的 * 每个memcg中都会为每个zone维护一个lru链表 */ shrink_lruvec(lruvec, swappiness, sc); /* 如果是对于整个zone进行回收,那么会遍历所有memcg,对所有memcg中此zone的lru链表进行回收 * 而如果只是针对某个memcg进行回收,如果回收到了足够内存则返回,如果没回收到足够内存,则对此memcg下面的memcg进行回收 */ if (!global_reclaim(sc) && sc->nr_reclaimed >= sc->nr_to_reclaim) { mem_cgroup_iter_break(root, memcg); break; } /* 下一个memcg,对于整个zone进行回收和对某个memcg进行回收但回收数量不足时会执行到此 */ memcg = mem_cgroup_iter(root, memcg, &reclaim); } while (memcg); /* 计算此memcg的内存压力,保存到memcg->vmpressure */ vmpressure(sc->gfp_mask, sc->target_mem_cgroup, sc->nr_scanned - nr_scanned, sc->nr_reclaimed - nr_reclaimed); if (sc->nr_reclaimed - nr_reclaimed) reclaimable = true; /* 判断是否再次此zone进行内存回收 * 继续对此zone进行内存回收有两种情况: * 1. 没有回收到比目标order值多一倍的数量页框,并且非活动lru链表中的页框数量 > 目标order多一倍的页 * 2. 此zone不满足内存压缩的条件,则继续对此zone进行内存回收 * 而当本次内存回收完全没有回收到页框时则返回,这里大概意思就是想回收比order更多的页框 */ } while (should_continue_reclaim(zone, sc->nr_reclaimed - nr_reclaimed, sc->nr_scanned - nr_scanned, sc)); return reclaimable;}
在此函数中,首先会遍历memcg,根据memcg获取lru链表描述符lruvec与swapiness,这个swapiness的值的范围是0~200,它会影响扫描匿名页lru链表和文件页lru链表的页框数量,当此值越低时,就需要扫描的匿名页lru链表的页框越少,当此值为0时,则不扫描匿名页lru链表的页框,相反,此值越高,则需要扫描的匿名页lru链表的页框越多,当其为200时,则只扫描匿名页lru链表中的页框,不扫描文件页lru链表中的页框。然后调用shrink_lruvec()对此lru链表描述符的lru链表进行扫描,最后遍历完所有memcg后,判断是否继续对此zone进行内存回收,总的来说,流程如下:
- 从root_memcg开始遍历memcg获取memcg的lru链表描述符lruvec获取memcg的swapiness调用shrink_lruvec()对此memcg的lru链表进行处理
- 遍历完所有memcg后,检查是否还要对此zone再次进行内存回收。
核心函数就是shrink_lruvec(),我们先看代码:
声明:本站部分文章内容及图片转载于互联 、内容不代表本站观点,如有内容涉及侵权,请您立即联系本站处理,非常感谢!