在 Linux 系统的庞大体系中,内存扮演着举足轻重的角色,堪称系统运行的 “血液”。它是计算机的关键部件,如同一个高速运转的临时仓库,暂时存放着 CPU 中的运算数据,以及与硬盘等外部存储器交换的数据 ,是外部存储与 CPU 沟通的重要桥梁。
只要 Linux 系统开始运行,操作系统就会如同勤劳的搬运工,将需要运算的数据从内存精准地调到 CPU 中进行运算。当运算圆满完成后,CPU 又会迅速地将结果传送出来 。可以说,内存的运行状况直接决定了计算机整体运行的快慢程度,影响着整个系统的性能表现。就像人体的血液循环,如果血液流动不畅,身体各个器官就无法获得充足的养分,进而导致身体机能下降。同样,在 Linux 系统中,若内存管理出现问题,系统的运行效率也会大幅降低,甚至可能引发系统崩溃等严重后果。
一、内存回收概述
在 Linux 系统中,内存回收机制就像是一位默默守护系统的 “内存管家”,肩负着至关重要的职责。它的主要任务是在系统运行过程中,对内存资源进行有效的管理和回收,确保内存的合理利用,维持系统的稳定运行。
当系统中的内存资源变得紧张时,内存回收机制就会迅速启动,开始筛选那些暂时不用或者可以被释放的内存空间。比如,有些进程可能已经完成了当前的任务,处于闲置状态,但它们占用的内存却没有被及时释放。这时,内存回收机制就会将这些闲置进程占用的内存回收回来,重新分配给那些急需内存的新进程,就像一位精明的管家,合理调配家中的资源,让每一份资源都能物尽其用 。
从性能角度来看,内存回收机制对系统的高效运行起着关键作用。假设没有这个机制,随着系统中进程的不断启动和停止,内存中会逐渐积累大量被闲置进程占用的内存碎片。这些碎片就像一个个零散的小空间,难以满足新进程对连续内存空间的需求,导致新进程在申请内存时,需要花费大量时间寻找合适的内存块,从而使系统响应变得迟缓,运行效率大幅降低。例如,在服务器环境中,如果内存回收不及时,当大量用户同时访问服务器时,新的服务请求可能因为无法及时获得足够的内存资源而被延迟处理,严重影响用户体验。
在稳定性方面,内存回收机制更是系统的 “稳定器”。如果内存一直处于高占用且无法回收的状态,系统就如同一个被过度填充的容器,随时可能出现崩溃等严重问题。比如,在长时间运行的数据库服务器中,如果内存持续被无效占用,当达到一定程度时,数据库可能会因为无法分配到足够的内存来处理事务而出现异常,甚至导致整个服务器死机,造成巨大的损失 。
二、Linux内存回收机制
2.1内存回收的主要对象
在 Linux 内存回收机制的精密体系中,内存回收主要聚焦于匿名页和文件页这两大关键对象 ,它们就像是内存世界中的两类不同 “居民”,有着各自独特的 “生存法则” 和回收方式。
匿名页,作为内存中的一类特殊存在,如同漂泊的 “游子”,没有对应的磁盘文件作为坚实 “后盾”。进程中的堆、栈数据等便是匿名页的典型代表。在内存回收的关键时刻,系统这位 “精明的管家” 会仔细筛选出那些活跃度较低、不经常被访问的匿名页 。这些被选中的匿名页就像暂时被 “请出家门”,被写入到 swap 分区这个特殊的 “临时住所” 中。经过这样的处理后,它们就可以作为空闲页框被释放到伙伴系统中 ,如同重新获得自由的资源,等待着被其他进程申请使用,再次投入到系统运行的 “工作” 中。
而文件页,则像是有 “固定住所” 的居民,它涵盖了内核缓存的磁盘数据(Buffer)以及内核缓存的文件数据(Cache)等 ,与磁盘文件有着紧密的联系。在回收文件页时,系统同样会运用一套筛选机制,找出那些不常被访问的页面 。如果这个文件页中保存的内容与磁盘中文件对应内容完全一致,就如同一个整洁有序、与外界保持同步的 “房间”,那么它就是一个干净的文件页。
这种情况下,就不需要进行繁琐的回写磁盘操作,直接将其作为空闲页框释放到伙伴系统中,实现资源的快速回收和再利用 。但要是文件页保存的数据和磁盘中文件对应的数据不一致,就像一个 “房间” 内部发生了变化却还未与外界同步,那么这个文件页就被认定为脏页 。此时,就需要先将此文件页回写到磁盘中对应数据所在的位置上,完成数据的同步更新,然后才可以把它作为空闲页框释放到伙伴系统中 。通过这样有针对性的回收处理,当内存回收完成后,系统空闲的页框数量就会增加,进而有效缓解内存压力,保障系统能够相对稳定且高效地运行下去。
2.2以 zone 为单位的内存回收规则
内存回收是以zone为单位进行的(也会以memcg为单位,这里不讨论这种情况),而系统判断一个zone需不需要进行内存回收,如上面所说,为zone设置一条线,当此zone的空闲页框不足以到达这条线时,就会对此zone进行内存回收,实际上一个zone有三条线,这三条线分别是最小阀值(WMARK_MIN),低阀值(WMARK_LOW),高阀值(WMARK_HIGH),它们都保存在zone的watermark[NR_WMARK]数组中,这个数组中保存的是各个阀值要求的页框数量,而每个阀值都会对内存回收造成影响。而它们的描述如下:
- watermark[WMARK_MIN](min阀值):在快速分配失败后的慢速分配中会使用此阀值进行分配,如果慢速分配过程中使用此值还是无法进行分配,那就会执行直接内存回收和快速内存回收
- watermark[WMARK_LOW](low阀值):也叫低阀值,是快速分配的默认阀值,在分配内存过程中,如果zone的空闲页框数量低于此阀值,系统会对zone执行快速内存回收
- watermark[WMARK_HIGH](high阀值):也叫高阀值,是zone对于空闲页框数量比较满意的一个值,当zone的空闲页框数量高于这个值时,表示zone的空闲页框较多。所以对zone进行内存回收时,目标也是希望将zone的空闲页框数量提高到此值以上,系统会使用此阀值用于oomkill进行内存回收。
这三个阀值的关系是: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等。
(1)页描述符页描述符中对内存回收来说非常必要的标志:
- PG_lru:表示页在lru链表中
- PG_referenced: 表示页最近被访问(只有文件页使用)
- PG_dirty:页为脏页,文件页被修改,以及非文件页加入到swap cache后,就会被标记为脏页。在此页回写前会被清除,但是回写失败时又会被置位
- PG_active:页为活动页,配合PG_lru就可以得出页是处于非活动页lru链表还是活动页lru链表
- PG_private:页描述符中的page->private保存有数据
- PG_writeback:页正在进行回写
- PG_swapbacked:此页可写入swap分区,一般用于表示此页是非文件页
- PG_swapcache:页已经加入到了swap cache中(只有非文件页使用)
- PG_reclaim:页正在进行回收,只有在内存回收时才会对需要回收的页进行此标记
- PG_mlocked:页被锁在内存中
在内核中,只有一种页能够进行回收,就是页描述符中的_count为0的页,每个页都有自己唯一的页描述符,而每个页描述符中都有一个_count,这个_count代表的是此页的引用计数,当_count为-1时,说明此页是空闲的,存放在伙伴系统中,每当有一个进程映射了此页时,此页的_count就会++,也就是当某个页被10个进程映射了,它的page->_count肯定大于10(不等于10是因为可能还有其他模块引用了此页,比如块层、驱动等),所以也可以反过来说,如果某个页的page->_count == 0,那就说明此页可以直接释放回收了。
也就是说,内核实际上回收的是那些page->_count == 0的页,但是如果真的是这样,内存回收这就没有任何意义了,因为当最后一个引用此页的模块释放掉此页的引用时,如果page->_count为0,肯定会释放回收此页的。实际上内存回收做的事情,就是想办法将一些page->_count不为0的页,尝试将它们的page->_count降到0,这样系统就可以回收这些页了。
下面是我总结出来在内存回收过程中会对页的page->_count产生影响的操作:
- 一个进程映射此页,page->_count++
- 一个进程取消映射此页,page->_count--
- 此页加入到lru缓存中,page->_count++
- 此页从lru缓存加入到lru链表中,page->_count--
- 此页被加入到一个address_space中,page->_count++
- 此页从address_space中移除时,page->_count--
- 文件页添加了buffer_heads,page->_count++
- 文件页删除了buffer_heads,page->_count--
- swap分区
2.3lru链表
lru链表主要作用就是将页排序,将最应该回收的页放到最后面,最不应该回收的页放到最前面,,然后进行内存回收时,就会从后面向前面进行扫描,将扫描到的页尝试进行回收。这里只需要记住一点,回收的页都是非活动匿名页lru链表或者非活动文件页lru链表上的页。这些页包括:进程堆、栈、匿名mmap共享内存映射、shmem共享内存映射使用的页、映射磁盘文件的页。
首先先说明一下页描述符中对内存回收来说非常必要的标志:
- PG_lru:表示页在lru链表中
- PG_referenced: 表示页最近被访问(只有文件页使用)
- PG_dirty:页为脏页,文件页被修改,以及非文件页加入到swap cache后,就会被标记为脏页。在此页回写前会被清除,但是回写失败时又会被置位
- PG_active:页为活动页,配合PG_lru就可以得出页是处于非活动页lru链表还是活动页lru链表
- PG_private:页描述符中的page->private保存有数据
- PG_writeback:页正在进行回写
- PG_swapbacked:此页可写入swap分区,一般用于表示此页是非文件页
- PG_swapcache:页已经加入到了swap cache中(只有非文件页使用)
- PG_reclaim:页正在进行回收,只有在内存回收时才会对需要回收的页进行此标记
- PG_mlocked:页被锁在内存中(此标志可以保证不被换出,但是无法保证不被被做
- 内存迁移)
内存回收做的事情就是想办法将目标页的page->_count降到0,对于那些没有进程映射了页,释放起来就很简单,如果页映射了磁盘文件,并且页为脏页(被写过),那就就把页中的数据回写到磁盘中映射的文件中,而如果页没有映射磁盘文件,那么直接释放即可。但是对于有进程映射的页,如果此页映射了磁盘文件,并且页为脏页,那么和之前一样,将此页进行回写,然后释放回收即可,但是此页没有映射磁盘文件,情况就会稍微复杂,会将页数据写入到swap分区中,然后将此页释放回收。总结如下:
- 干净页,并且映射了磁盘文件的页,直接回收
- 脏页(PG_dirty置位),回写到对应磁盘文件中,然后回收
- 没有进程映射,并且没有映射磁盘文件的页,直接回收
- 有进程映射,并且没有映射磁盘文件的页,回写到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来说,则无法做到这一点。
2.4内存分配过程
要说清楚内存回收,就必须要先理清楚内存分配过程,在调用alloc_page()或者alloc_pages()等接口进行一次内存分配时,最后都会调用到__alloc_pages_nodemask()函数,这个函数是内存分配的心脏,对内存分配流程做了一个整体的组织。主要需要注意的,就是在__alloc_pages_nodemask()中会进行一次使用low阀值的快速内存分配和一次使用min阀值的慢速内存分配,快速内存分配使用的函数是get_page_from_freelist(),这个函数是分配页框的基本函数,也就是说,在慢速内存分配过程中,收集到和足够数量的页框后,也需要调用这个函数进行分配。先简单说明快速内存分配和慢速内存分配:
- 快速内存分配:是get_page_from_freelist()函数,通过low阀值从zonelist中获取合适的zone进行分配,如果zone没有达到low阀值,则会进行快速内存回收,快速内存回收后再尝试分配。
- 慢速内存分配:当快速分配失败后,也就是zonelist中所有zone在快速分配中都没有获取到内存,则会使用min阀值进行慢速分配,在慢速分配过程中主要做三件事,异步内存压缩、直接内存回收以及轻同步内存压缩,最后视情况进行oom分配。并且在这些操作完成后,都会调用一次快速内存分配尝试获取页框。
通过以下这幅图,来说明流程:
图片
说到内存分配过程,就必须要说说中的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链表就可能如下:
2.5扫描控制结构
扫描控制结构用于内存回收和内存压缩,它的主要作用时保存对一次内存回收或者内存压缩的变量和参数,一些处理结果也会保存在里面,结构如下:
结构很简单,主要就是保存一些参数,在内存回收和内存压缩时就会根据这个结构中的这些参数,做不同的处理,后面代码会详细说明。这里我们只说说会几个特别的参数:
- priority:优先级,这个参数主要会影响内存回收时一次扫描的页框数量、在shrink_lruvec()中回收到足够页框后是否继续回收、内存回收时的回写、是否取消对zone进行回收判断而直接开始回收,一共四个地方。
- may_unmap:是否能够进行unmap操作,如果不能进行unmap操作,就只能对没有进程映射的页进行回收。
- may_writepage:是否能够进行将页回写到磁盘的操作,这个值会影响脏的文件页与匿名页lru链表中的页的回收,如果不能进行回写操作,脏页和匿名页lru链表中的页都不能进行回收(已经回写完成的页除外,后面解释)
- may_swap:能否进行swap交换,同样影响匿名页lru链表中的页的回收,如果不能进行swap交换,就不会对匿名页lru链表进行扫描,也就是在本次内存回收中,完全不会回收匿名页lru链表中的页(进程堆、栈、shmem共享内存、匿名mmap共享内存使用的页)
在快速内存回收、直接内存回收、kswapd内存回收中,这几个值的设置不一定会一致,也导致了它们对不同类型的页处理方式也不同。除了sc->may_writepage会影响页的回写外,还有进行内存分配时使用的分配标志gfp_mask中的__GFP_IO和__GFP_FS会影响页的回写,具体如下:
- 扫描到的非活动匿名页lru链表中的页如果还没有加入到swapcache中,需要有__GFP_IO标记才允许加入swapcache和回写。
- 扫描到的非活动匿名页lru链表中的页如果已经加入到了swapcache中,需要有__GFP_FS才允许进行回写。
- 扫描到的非活动文件页lru链表中的页需要有__GFP_FS才允许进行回写。
这里还需要说说三个重要的内核配置:
这个参数只会影响快速内存回收,其值有三种:
- 0x1:开启zone的内存回收
- 0x2:开启zone的内存回收,并且允许回写
- 0x4:开启zone的内存回收,允许进行unmap操作
当此参数为0时,会导致快速内存回收只会对最优zone附近的几个需要进行内存回收的zone进行内存回收(说快速内存会解释),而只要不为0,就会对zonelist中所有应该进行内存回收的zone进行内存回收。
当此参数为0x1(001)时,就如上面一行所说,允许快速内存回收对zonelist中所有应该进行内存回收的zone进行内存回收。
当此参数为0x2(010)时,在0x1的基础上,允许快速内存回收进行匿名页lru链表中的页的回写操作。
当此参数0x4(100)时,在0x1的基础上,允许快速内存回收进行页的unmap操作。
此参数只会影响直接内存回收,只有两个值:
- 0:允许直接内存回收对匿名页lru链表中的页进行回写操作,并且允许直接内存回收唤醒flush内核线程
- 非0:直接内存回收不会对匿名页lru链表中的页进行回写操作
此参数影响进行内存回收时,扫描匿名页lru链表和扫描文件页lru链表的比例,范围是0~200,系统默认是30:
- 接近0:进行内存回收时,更多地去扫描文件页lru链表,如果为0,那么就不会去扫描匿名页lru链表。
- 接近200:进行内存回收时,更多地去扫描匿名页lru链表。
三、内存回收机制的工作方式
Linux 内存回收机制主要包括后台内存回收(kswapd)、直接内存回收(direct reclaim)和 OOM 机制(Out of Memory)。这三种方式就像三道紧密协作的防线,从日常的内存维护到极端情况下的系统拯救,层层递进,保障着系统内存的稳定供应 。
3.1定期扫描回收(kswapd)
定期扫描回收(kswapd)是Linux内核中的一个守护进程,用于管理系统内存和交换空间。它负责检查当前内存使用情况,并在需要时进行页面回收或交换页面到磁盘上的交换分区。
kswapd周期性地扫描系统中的页面,并根据一些策略来确定哪些页面可以被回收或者交换出去。这个过程称为页面置换(page swapping)。具体的策略可以包括LRU(Least Recently Used,最近最少使用)、LFU(Least Frequently Used,最不经常使用)等。
当系统内存紧张时,kswapd会开始工作,尝试将一些不再活跃或很少访问的页框写入交换分区。这样可以释放出物理内存供其他活跃进程使用。kswapd会根据设置的阈值和算法来调整页面置换的行为,以达到合适的内存利用率和性能。
操作系统内核线程kswapd定期进行回收内存,并通过设定三个内存阈值来衡量内存的使用情况,分别是:
- 页最小阈值(pages_min)
- 页低阈值(pages_low)
- 页高阈值(pages_high)
kswapd定期扫描内存的使用情况,并根据剩余内存落在这三个阈值的空间位置,进行内存的回收操作。
3.2直接内存回收(direct reclaim)
直接内存回收就像是一场在内存紧张时突然发起的 “紧急救援” 行动,与后台内存回收的悄无声息不同,它往往伴随着进程的 “紧张时刻” 。当某个进程急切地需要申请内存资源时,如果此时系统可用内存少得可怜,连满足进程最基本的内存分配需求都无法做到 ,直接内存回收就会被无情地触发 。这个过程就像是在战场上,当物资短缺时,不得不立即采取紧急措施来调配资源。在直接内存回收中,进程会被暂时阻塞,就像前进的道路被突然阻断 ,它只能无奈地等待内存回收完成,获取到足够的内存后才能继续前行 。
这种回收方式是同步进行的,意味着进程的命运与内存回收紧紧绑定在一起 。它虽然能够在紧急时刻迅速为进程提供所需内存,但同时也会带来一些负面影响 。长时间的阻塞会让进程的执行变得迟缓,就像一个被束缚住手脚的运动员,无法正常发挥 。而且,在回收内存的过程中,系统的 CPU 利用率会急剧升高,就像一台高速运转的机器,承受着巨大的压力 ,整个系统的负荷也会随之增大 。这就好比在一个繁忙的工厂里,突然的资源调配导致生产线停滞,工人和机器都在紧张地忙碌,却无法高效地运转 。所以,在系统运行过程中,我们要尽量避免直接内存回收的频繁发生,以保证系统的性能和稳定性 。
3.3OOM 机制(Out of Memory)
OOM 机制(Out of Memory),即内存溢出机制,堪称 Linux 内存管理体系中的 “最后一道防线”,是在系统内存资源极度匮乏、几乎陷入绝境时才会启动的 “紧急预案” 。当直接内存回收已经竭尽全力,但系统的剩余空闲内存依然少得可怜,连最基本的内存分配请求都无法满足时 ,OOM Killer 机制就会被无奈地触发 ,就像在一场激烈的战斗中,所有常规手段都已用尽,只能祭出最后的杀手锏 。
OOM Killer 机制的工作原理就像是一场残酷的 “生存竞赛” 。它会迅速扫描系统中正在运行的所有进程,如同一位严苛的评委,仔细评估每个进程的内存使用情况 。在这个评估过程中,它会运用一套复杂而精妙的算法,为每个进程打分 。这个分数综合考虑了进程占用的物理内存页框数量、进程的重要性以及其他一些相关因素 。最终,得分最高的进程就会被无情地选中,成为这场内存危机中的 “牺牲品” 。
OOM Killer 会毫不留情地杀死这个进程,就像在资源极度短缺时,不得不牺牲一些个体来换取整体的生存 。通过杀死这个占用内存较多的进程,系统成功释放出了大量的内存资源 ,这些资源就像注入系统的新鲜血液,使得系统能够继续艰难地运行下去 。如果一次杀死进程后,内存依然不足,OOM Killer 就会像一位冷酷的执行者,继续寻找下一个得分高的进程并将其杀死 ,直到系统内存资源能够满足基本的运行需求 。
四、回收哪些内存?
4.1Page Cache
CPU如果要访问外部磁盘上的文件,需要首先将这些文件的内容拷贝到内存中,由于硬件的限制,从磁盘到内存的数据传输速度是很慢的,如果现在物理内存有空余,干嘛不用这些空闲内存来缓存一些磁盘的文件内容呢,这部分用作缓存磁盘文件的内存就叫做page cache。
用户进程启动read()系统调用后,内核会首先查看page cache里有没有用户要读取的文件内容,如果有(cache hit),那就直接读取,没有的话(cache miss)再启动I/O操作从磁盘上读取,然后放到page cache中,下次再访问这部分内容的时候,就又可以cache hit,不用忍受磁盘的龟速了(比内存慢几个数量级)。
和CPU里的硬件cache是不是很像?两者其实都是利用的局部性原理,只不过硬件cache是CPU缓存内存的数据,而page cache是内存缓存磁盘的数据,这也体现了memory hierarchy分级的思想。
相对于磁盘,内存的容量还是很有限的,所以没必要缓存整个文件,只需要当文件的某部分内容真正被访问到时,再将这部分内容调入内存缓存起来就可以了,这种方式叫做demand paging(按需调页),把对需求的满足延迟到最后一刻,很懒很实用。
page cache中那么多的page frames,怎么管理和查找呢?这就要说到之前的文章提到的address_space结构体,一个address_space管理了一个文件在内存中缓存的所有pages。这个address_space可不是进程虚拟地址空间的address space,但是两者之间也是由很多联系的。上文讲到,mmap映射可以将文件的一部分区域映射到虚拟地址空间的一个VMA,如果有5个进程,每个进程mmap同一个文件两次(文件的两个不同部分),那么就有10个VMA,但address_space只有一个。每个进程打开一个文件的时候,都会生成一个表示这个文件的strut file,但是文件的struct inode只有一个,inode才是文件的唯一标识,指向address_space的指针就是内嵌在inode结构体中的。在page cache中,每个page都有对应的文件,这个文件就是这个page的owner,address_space将属于同一owner的pages联系起来,将这些pages的操作方法与文件所属的文件系统联系起来。
来看下address_space结构体具体是怎样构成的:
- host指向address_space对应文件的inode。
- address_space中的page cache之前一直是用radix tree的数据结构组织的,tree_lock是访问这个radix tree的spinlcok(现在已换成xarray)。
- i_mmap是管理address_space所属文件的多个VMA映射的,用priority search tree的数据结构组织,i_mmap_lock是访问这个priority search tree的spinlcok。
- nr_pages是address_space中含有的page frames的总数。
- a_ops是关于page cache如何与磁盘(backing store)交互的一系列operations。
4.2从Radix Tree到XArray
radix tree的每个节点可以存放64个slots(由RADIX_TREE_MAP_SHIFT设定,小型系统为了节省内存可以配置为16),每个slot的指针指向下一层节点,最后一层slot的指针指向struct page(关于struct page请参考这篇文章),因此一个高度为2的radix tree可以容纳64个pages,高度为3则可以容纳4096个pages。
如何在radix tree中找到一个指定的page呢?那就要回顾下struct page中的mapping和index了,mapping指向page所属文件对应的address_space,进而可以找到address_space的radix tree,index既是page在文件内的offset,也可作为查找这个radix tree的索引,因为radix tree就是按page的index来组织struct page的。具体的查找方法和使用VPN做索引的page table(参考这篇文章)以及使用PPN做索引的sparse section查找(参考这篇文章)都是类似的。这里是用page index中的一部分bit位作为radix tree第一层的索引,另一部分bit位作为第二层的索引,以此类推。因为一个radix tree节点存放64个slots,因此一层索引需要6个bits,如果radix tree高度为2,则需要12个bits。
内核中具体的查找函数是find_get_page(mapping, offset),如果在page cache中没有找到,就会触发page fault,调用__page_cache_alloc()在内存中分配若干物理页面,然后将数据从磁盘对应位置copy过来,通过add_to_page_cache()-->radix_tree_insert()放入radix tree中。在将一个page添加到page cache和从page cache移除时,需要将page和对应的radix tree都上锁。
linux中radix tree的每个slot除了存放指针,还存放着标志page和磁盘文件同步状态的tag。如果page cache中一个page在内存中被修改后没有同步到磁盘,就说这个page是dirty的,此时tag就是PAGE_CACHE_DIRTY。如果正在同步,tag就是PAGE_CACHE_WRITEBACK。只要下一层中有一个slot指向的page是dirty的,那么上一层的这个slot的tag就是PAGE_CACHE_DIRTY的,就像一滴墨水一样,放入清水后,清水也就不再完全清澈了。前面介绍struct page中的flags时提到,flags可以是PG_dirty或PG_writeback,既然struct page中已经有了标识同步状态的信息,为什么这里radix tree还要再加上tag来标记呢?这是为了管理的方便,内核可以据此快速判断某个区域中是否有dirty page或正在write back的page,而无须扫描该区域中的所有pages。
4.3Reverse Mapping
要回收一个page,可不仅仅是释放掉那么简单,别忘了linux中进程和内核都是使用虚拟地址的,多少个PTE页表项还指向这个page呢,回收之前,需要将这些PTE中P标志位设为0(not present),同时将page的物理页面号PFN也全部设成0,要不然下次PTE指向的位置存放的就是无效的数据了。可是struct page中好像并没有一个维护所有指向这个page的PTE组成的链表。
前面的文章说过,struct page数量极其庞大,如果每个page都有这样一个链表,那将显著增加内存占用,而且PTE中的内容是在不断变化的,维护这一链表的开销也是不小的。那如何找到这些PTE呢?从虚拟地址映射到物理地址是正向映射,而通过物理页面寻址映射它的虚拟地址,叫reverse mapping(逆向映射)。page的确没有直接指向PTE的反向指针,但是page所属的文件是和VMA有mmap线性映射关系的啊,通过page在文件中的offset/index,就可以知道VMA中的哪个虚拟地址映射了这个page。
在代码中的实现是这样的:
映射了某个address_space中至少一个page的所有进程的所有VMA,就共同构成了这个address_space的priority search tree(PST)。PST是一种糅合了radix tree和heap的数据结构,其实现较为复杂,现在已经被基于augmented rbtree的interval tree所取代。
对比一下,一个进程所含有的所有VMA是通过链表和红黑树组织起来的,一个文件所对应的所有VMA是通过基于红黑树的interval tree组织起来的。因此,一个VMA被创建之后,需要通过vma_link()插入到这3种数据结构中。
五、内存回收及对系统性能的影响
5.1对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的内存压缩,所以进行zone的内存回收时,会回收到空闲页框数量满足进行内存压缩为止。
我们看一下这个shrink_zone():
在此函数中,首先会遍历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(),我们先看代码:
此函数主要是对lru链表描述符中的lru链表进行处理,我们知道,lru链表描述符中一共有5个链表:LRU_ACTIVE_ANON,LRU_INACTIVE_ANON,LRU_ACTIVE_FILE,LRU_INACTIVE_FILE,LRU_UNEVICTABLE。对于内存回收来说,它只会处理前面4个lru链表,也就是活动匿名页lru链表,非活动匿名页lru链表,活动文件页lru链表,非活动文件页lru链表。此函数主要工作就是:
- 1.调用get_scan_count()计算每个lru链表需要扫描的页框数量,保存到nr数组中;
- 2.循环判断nr数组中是否还有lru链表没有扫描完成
- 3.如果非活动匿名页lru链表中页数量太少,则对活动匿名页进行一个32个页框的扫描;
- 4.如果太多脏页正在进行回写,则睡眠100ms
这里需要说明的有两点:计算每个lru链表需要扫描的数量和调整nr数组,在get_scan_count()函数中会计算每个lru链表需要扫描的页框数量,然后将它们保存到nr数组中,在此,有两个因素会影响这4个lru链表需要扫描的数量,一个是sc->priority(扫描优先级),一个是swapiness。
- sc->priority:影响的是这4个lru链表扫描页框数量的基准值,当sc->priority越小,每个lru链表需要扫描的页框数量就越多,当sc->priority为0时,则本次shrink_lruvec()会对每个lru链表都完全扫描一遍。在不同内存回收过程中,使用的sc->priority不同,而sc->priority默认值为12。
- swapiness:影响的是在基准值的基础上,是否做调整,让系统更多地去扫描文件页lru链表,或者更多地去扫描匿名页lru链表。当swapiness为100时,扫描文件页lru链表与扫描匿名页lru链表是平衡的,并不倾向与谁,也就是它们需要扫描的页框就是就是sc->priority决定的基准值,当swapiness为0,时,就不会去扫描匿名页lru链表,只扫描文件页lru链表。
有兴趣的可以去看看get_scan_count()函数,这个函数这里就不详细进行说明了,之后可能会出篇文章对此函数进行详细说明。
计算好每个lru链表需要扫描的页框数量后,就以活动匿名页lru链表、非活动匿名页lru链表、活动文件页lru链表、非活动文件页lru链表的顺序对每个链表进行一次最多32个页框的扫描,然后将对应的nr数组的数值进行减少,当对这4个lru链表都进行过一次扫描后,判断是否回收到了足够页框,如果没有回收到足够页框,则继续扫描,而如果已经回收到了足够页框的话,并且nr数组中的数还有剩余的情况下,这里会有两种可能,一种是直接返回,另一种是继续扫描,这两种情况发生的条件如下:
- 回收到足够页框后直接返回:快速内存回收、kswapd内存回收中会这样做,在回收到sc->nr_to_reclaim数量的页框后直接返回上一级
- 回收到足够页框后继续扫描:直接内存回收时第一次调用shrink_zone()时、kswapd针对某个memcg进行内存回收时会这样做,即使回收到sc->nr_to_reclaim数量的页框后,还会继续扫描,直到nr数组为0具体见后面直接内存回收
当回收到sc>nr_to_reclaim数量的页框后,还打算继续扫描的情况,则会继续扫描这4个lru链表,而对于kswapd针对某个memcg进行内存回收的情况会稍微有所不同,虽然这种情况也会继续扫描,但是它会执行一些代码去减少一些nr数组中的值,这样重新扫描时,就会扫描得少一些。
接下来说说对每个lru链表的处理,在shrink_lruvec()中已经设计出了每个lru链表一次扫描32个页框,然后调用shrink_list()函数,我们先看看shrink_list():
可以很明显看到,只有非活动lru链表中页框数量不足时,才会调用shrink_active_list()对活动lru链表进行处理,否则并不会进行处理,不过需要注意,即使并不对活动lru链表进行处理,在shrink_lruvec()中也会相应减少nr数组中的数值。而怎么判断非活动lru链表保存的页框数量过少的。需要注意,此函数调用成功后,返回值 >= 0。大于0说明回收到了页框,因为内存回收只会对非活动lru链表中的页进行回收,所以只有对非活动lru链表进行处理时返回值才会大于0。
5.2对活动lru链表处理
我们先看怎么对活动lru链表进行处理的,活动lru链表包括活动匿名页lru链表以及活动文件页lru链表,这两个lru链表都会调用shrink_active_list()进行处理:
这里面,首先,会将当前CPU所有的lru缓存中的页全部放到lru链表中,其次调用isolate_lru_pages()从lru链表的末尾隔离出一些页来放入到l_hold链表中,成功隔离出来的页的page->_count会进行++。这个函数在内存回收中是一个通用函数,也就是它即可以用来隔离活动lru链表的页,也可以用来隔离非活动lru链表的页,需要注意这个函数依赖于sc->may_writepage和sc->may_unmap,这两个变量在之前有过说明,也如注释上所说,当sc->may_writepage为0时,则不会将正在回写的页和脏页隔离出来,当sc->may_unmap为0时,则不会将有进程映射的页隔离出来,这些页都会被跳过,这样一来,在这些情况下,实际隔离的页就会少于需要扫描的页。隔离出一些页后,又会调用page_referenced()函数,此函数通过反向映射,检查映射了此页的进程页表项有多少个的Accessed被置1了,然后清除这些页表项的Accessed标志,此标志被置1说明这些进程最近访问过此页。
当最近有进程访问过此页,如果此页是映射了代码段的页,就把此页加入到l_active链表,其他页则清除PG_active标志,通过page->lru这个链表结点加入到l_inactive链表。也就是隔离出来的页,只有代码段最近被访问过了,才会留在活动lru链表中,其余的页,都必须要通过page->lru这个链表结点移动到非活动lru链表头中。不过因为代码段的页是属于文件页lru链表,也就是对于活动匿名页lru链表中隔离出来的页,所有都放到非活动匿名页lru链表头部,而对于活动文件页lru链表中隔离出来的页,除了最近被访问过的代码段的页,其余页都移动到非活动文件页lru链表头部。
之后调用move_active_pages_to_lru()函数,将l_active中的页加入到活动lru链表头部,将l_inactive中的页加入到非活动lru链表尾部,并且会对成功加入的页的page->_count进行--,这样与成功隔离时正好进行了一加一减的操作。在将活动页移动到非活动lru链表时,可能会扫描到一种页,它们的page->_count为0,也就是已经没有任何模块和进程对其进行引用了,这种页就可以直接释放了。所以看到shrink_active_list()函数最后将这些类型的页进行释放。关于isolate_lru_pages()、page_referenced()和move_active_pages_to_lru()函数,以后的文章再分析,这篇文章放不下了。
shrink_active_list()调用完成后,所有隔离出来的页都已经被放入到相应的lru链表中了。注意,这里在移动页框时并不会使用lru缓存,之前关于lru链表分析的文章也说过,在内存回收过程中,只有将页加入到LRU_UNEVICTABLE链表中时需要用到lru缓存,而对于页在相同类型的lru链表间移动时,是不会使用到lru缓存的。shrink_active_list()函数总结要点有五:
- 将本cpu的lru缓存全部清空,将lru缓存的页放到lru链表中,而其他CPU的则不处理
- 根据sc->may_writepage与sc->may_unmap选择要隔离的页
- 如果结点buffer_heads数量超过限制值,则会尝试对扫描到的文件页进行buffer_heads的释放,进行释放后的文件页的page->_count--
- 将所有映射了隔离页的页表项Accessed都跟清0
- 将最近被访问过的代码段的页移动到活动lru链表头,其余页都移动到非活动lru链表头
- 将page->_count == 0的页进行释放。
5.3对非活动lru链表进行处理
接下来我们看看对于非活动lru链表的分析,非活动lru链表包括非活动匿名页lru链表和非活动文件页lru链表,它们都会调用shrink_inactive_list(),此函数就要比活动lru链表的处理函数shrink_active_list()复杂得多,如下:
此函数与shrink_inactive_list()函数流程差不多,首先要求当前CPU的所有lru缓存将页放入到lru链表中,然后通过isolate_lru_pages()函数从活动lru链表末尾扫描出符合要求的页,这些页会通过page->lru加入到page_list链表中,然后调用shrink_page_list()对这个page_list链表中的页进行回收处理,之后将page_list链表中剩余的页放回到它们应该放入到链表中。
当把页放回对应的地方后,则将page->_count == 0的页进行释放,这里实际上回收的是在shrink_page_list()中没有进行回收,但是执行完shrink_page_list()后,正巧所有映射了此页的进程都取消了映射,并且此页也不为脏,这就以直接回收了。之后会根据状态标记一些zone的状态。主要进行具体的回收工作函数还是shrink_page_list():
shrink_page_list(),它的工作就是对page_list链表中的每个页尝试进行回收操作了,但是进行回收操作,并不等于此页就可以立即进行回收,因为如果为脏页的话,回写到磁盘的操作是异步的,而这些页将在回写完成后进行回收,具体怎么做的,我们慢慢道来。首先,在shrink_page_list()中会遍历page_list链表中的每一个页,然后对每个遍历到的页都进行处理,先总结一下这个shrink_page_list()对每个遍历到的页主要做哪几件事情:
- 检查此页是否正在回写(通过页描述符的PG_writeback标志),然后做相应的处理
- 检查此页最近是否有被访问过(非文件页通过页表项的Accessed判断,文件页通过页描述符的PG_referenced和页表项的Accessed判断),有则进行相应处理(此页就不一定被回收)
- 如果是非文件页,检查此页是否加入到了swap cache(置位PG_swapcache),没有则将此页加入到swap cache(通过PG_swapcache判断),并且标记非文件页为脏页(重要,标记PG_dirty)以及page->_count++,会为此非文件页分配一个swap类型的页表项,保存到page->private中
- 如果有进程映射了此页,则进行unmap操作(是否执行unmap操作与sc->may_unmap有关),如果是非文件页,那么映射了此非文件页的页表项被设置为之前分配的swap类型的页表项,如果是文件页,则清空页表项
- 如果页为脏页,则对此页进行异步回写(是否执行回写操作与sc->may_writepage有关),一些特殊的文件系统可能进行同步回写(比如ramdisk),然后设置此页的PG_reclaim。这里需要注意,只有kswap能够对文件页进行回写
- 如果此页是文件页,并且包含有buffer_heads(会以链表的形式保存在page->private中),则释放其buffer_heads链表,注意这个buffer_heads是文件页特有的,因为文件离散地保存在磁盘中,而swap分区是连续的,所以非文件页并不需要这个buffer_heads。
- 将page->_count == 2和page->_count == 0的干净页进行回收,并将它们从swap cache 或者 page cache中移除
- 除了回收的页,其余的页都放回到对应的lru链表中。
先讨论第一件事情,就是检查这个页是否在进行回写操作,这里要先说说块层的异步回写的结束后的处理函数end_buffer_async_write(),这个函数将一个页回写完成后会检查页的PG_reclaim标志,如果置位了则将此页移动到非活动lru链表末尾,因为内存回收扫描是从lru链表的末尾进行的,在下次进行内存回收扫描时,会优先扫描到此页,也可以对此页进行优先释放回收。
那么,现在在shrink_page_list()中,如果遍历到的页在进行回写操作(通过页的PG_writeback位判断),那么导致此页进行回写的情况有两种:页脏太久了,系统自动将其回写(PG_writeback置位,而PG_reclaim没有置位);页最近被内存回收处理过,是内存回收要求它进行回写(PG_writeback和PG_reclaim都置位了)。对于第一种情况,则将此页的PG_reclaim置位,这样此页在回写完成后,就会被放到非活动lru链表末尾,这样在下次内存回收时,此页就很大可能被作为一个干净页去释放回收。对于第二种可能,这个页本来就是进行内存释放时主动要求其回写的,那么此页的PG_writeback和PG_reclaim都在之前处理此页的内存回收时置位了,这里就不做什么了。
判断完遍历到的页是否正在进行回写后,还需要判断此页最近是否被访问过,处理如下:
(1)如果扫描的是非活动文件页lru链表,本次回收跳过的页有:
- 此文件页最近被多个进程访问(多个映射此页的进程页表项Accessed被置位),则将页移动到活动文件页lru链表头部。
- 此页的PG_referenced被置位,则将页移动到活动文件页lru链表头部。
- 对于最近被访问过的代码段文件页,移动到活动文件页lru链表头部。
(2)如果扫描的是非活动匿名页lru链表,本次回收跳过的页有:
- 对于最近访问过的页(一个或多个映射了此页的进程页表项的Accessed被置位),将页移动到活动匿名页lru链表尾部中。
- 对于正在回写的页,将页移动到非活动匿名页lru链表头部,并标记这些页的PG_reclaim。
(3)除了以上这些页,其他页都可以顺利通过检查,之前的工作相当于判断此页能否进行回收,现在开始的工作就是为此页的回收做准备了,总的来说,就是三件事:
- 非文件页加入到swap cache
- 对页进行unmap操作
- 调用page->mapping->a_ops->writepage进行异步回写
当一个非文件页加入swap cache时,主要对此文件做几件事,首先,分配一个swap类型的页表项,将所有映射了此页的进程页表项设置为这个swap类型的页表项;其次,置位此页的PG_dirty,标记此页是一个脏页,这样后面就会通过判断这个进行异步回写了;最后,将此页的mapping指向swap分区的address_space,这样在进行异步回写时,就能够通过swap分区的address_space->a_ops->writepage函数将此页异步回写到swap分区中。对于文件页来说,则没有这一步加入到swap cache中,因为每个文件都有自己的address_space,一个新的文件页就已经有对应文件的address_space了。
之后进行unmap操作,对于非文件页,这个的工作就是将映射了此非文件页的页表项设置为之前分配的swap类型的页表项,而对于文件页来说,则清空映射了此文件页的进程页表项。
然后,就调用页描述符中的page->mapping->a_ops->writepage将页进行异步回写,这里需要注意,只对脏页进行异步回写,这就是为什么当非文件页加入到swap cache后,要设置为脏页,这里就会将它回写到磁盘,而对于文件页,只有数据与磁盘中不一致时,才需要回写。并且这里会对脏页设置PG_reclaim标志,而干净页则不用。
好了 ,这几步做完了,可以将此页进行回收了吧,可惜这时候只能对不用进行回写的干净页进行回收,因为回写是异步进行的,这些正在进行回写的页,会被放到非活动lru链表头部,这里就与前面说的相呼应了,当回写完成后,通过判断PG_reclaim标志,会将页移动到非活动lru链表末尾,这样在下次进行内存回收时,这些页就更优先进行回收了。
假设现在内存回收扫描到了这个回写完成的页,如果此页是文件页,那么它还必定会有一个buffer_heads链表需要进行释放,这个buffer_heads用于描述此页需要回写到磁盘的位置。当文件页回写完后,如果此文件页又被内存回收扫描到了,准备对它回收,那么就会将此文件页的buffer_heads进行释放。buffer_heads都保存页描述符page->private中。
之后,如果回收的是文件页,那么还必须将此页从所属文件的page cache中移除,如果回收的是非文件页页,也必须将此页从所属swap分区的swap cache中移除。
到这里,此页已经可以进行回收了。
下面我们默认此页能够回收,忽略回收检查,并且默认没有进程在此期间访问页,将页分为干净文件页,脏文件页,非文件页描述一下回收过程(非文件页只要加入到swap cache中就会被设置为脏页):
图片
可以看到,对于干净文件页,由于文件页不加入swapcache,只需要进行一个unmap操作,就可以直接进行回收了,这种页回收效率是最高的。对于脏文件页:
图片
可以看到对于脏文件页,待其回写完成后,内核进行一次内存回收时,如果扫描到此页,只需要直接将其释放就可以了。注意:只有kswapd内核线程能够对脏文件页进行回写操作,并且回写完成后并不会主动要求内核进行一次内存回收,也有可能回写完成后,zone的内存足够了,就不进行内存回收了。再看看非文件页的回收流程:
图片
其实很简单,对于脏页,在回写之后的下次内存回收时,就可以将其回收,而对于干净的页,在本次内存回收时,就可以将其回收。而当非文件页加入swapcache后,就会被设置为脏页(PG_dirty置位)。
其实可以总结,非文件页相对于文件页来说,在内存回收处理过程中有以下区别:
- 一般回收的非文件页在非活动匿名页lru链表中,而回收的文件页在非活动文件页lru链表中。
- 非文件页回写前必须要加入swapcache,并会生成一个以页槽号为偏移量的swap类型的页表项;而文件页不会加入swapcache,并且没有swap类型的页表项
- unmap时,映射了非文件页的进程页表项会被设置为swao类型的页表项,而映射了文件页的进程页表项则直接清空
- 非文件页在有进程映射了的情况下,一定要进行回写后才能回收;而文件页即使没有进程映射的情况下,只要是脏页,回收时都要回写
- 非文件页没有buffer_heads,不需要对buffer_heads进行回收,而文件页回写完后进行需要进行buffer_heads的回收
现在再说说在回写过程中,又有进程映射了此页怎么办,这里我们结合page->_count来说,之前有说过,当有模块引用或者进程映射了此页的时候,此页的page->_count就会++,这里我们假设一个场景,有10个进程映射了一个非文件页,没有其他模块引用此非文件页,那么此页的page->_count就为10。
然后此页在非活动匿名页lru链表中被内存回收扫描到,内核打算对此页进行回收,第一件做的事情,将此页从lru链表隔离出来,这里page->_count++(就等于11了)。第二件事,将此页加入到swap cache中,page->_count++(现在等于12了)。第三件事,对此页进行unmap,由于有10个进程映射了此页,unmap后,此页的page->_count -= 10,现在page->_count就剩2了,如果此页是干净页,那么如之前说的,回收时判断page->_count == 2的可以进行回收。
如果此页是脏页,那么就回写,然后将此页放回到非活动匿名页lru链表,这时page->_count会减1(这时候就为1了,这里为1是因为swapcache在引用此页)。之后回写完成再被扫描到时,一样会进行隔离,那么page->_count++(现在为2了),最后一样可以通过page->_count == 2判断此页能够释放。这样说明,如果在回写过程中,有进程又映射了此页,因为映射此页那么page->_count就会增加,在回写完成后的回收时,此page->_count就不可能变为0了,更何况由于有进程映射了此页,说明此进程最近访问了此页,此页还会被移动到活动匿名页lru链表中。而对于文件页,即使没有进程映射它,它的page->_count就为1,因为它自出身一来,就被对应文件的page cache引用了。并且因为文件页不需要加入到swap cache,实际上在内存回收过程中,当没有进程映射此文件页时,它的page->_count一样为2。
5.4内存回收系统性能的影响
(1)内存回收与系统响应速度
内存回收机制对系统响应速度的影响可谓立竿见影。当内存回收不及时,就如同交通拥堵时没有交警指挥,系统会陷入混乱。例如,在一个多任务处理的场景中,同时运行着多个进程,如办公软件、浏览器、数据库服务等。随着时间的推移,一些进程产生的临时数据占用了大量内存,而这些数据在当前任务完成后本应被回收 。然而,由于内存回收不及时,这些占用内存的临时数据得不到释放,新的进程在申请内存时就会遇到阻碍 。
想象一下,当你在浏览器中打开多个网页,同时还在运行办公软件进行文档编辑,突然想要启动一个大型数据库查询任务。此时,如果内存回收不及时,数据库进程可能因为无法获得足够的内存而迟迟无法执行查询操作,导致你在等待查询结果时,浏览器和办公软件也变得卡顿,响应迟缓 。这是因为系统在努力协调有限的内存资源,各个进程都在竞争内存,导致整体的系统响应速度大幅下降 。
(2)内存回收与系统稳定性
内存持续高占用且无法回收,就像一座随时可能爆发的火山,对系统稳定性构成巨大威胁。在服务器环境中,这一问题的影响尤为严重 。例如,一个长时间运行的 Web 服务器,每天要处理大量的用户请求 。随着用户访问量的不断增加,服务器内存中的数据也越来越多 。如果内存回收机制出现故障,无法及时回收不再使用的内存,内存占用率会持续攀升 。
当内存占用达到一定程度,服务器可能会出现各种异常情况 。首先,进程之间可能会因为争夺内存资源而发生冲突,导致部分进程崩溃 。其次,系统可能会频繁出现内存分配失败的错误,使得新的服务请求无法得到处理 。最严重的情况是,整个服务器可能会因为内存耗尽而死机,无法正常响应任何用户请求 。这不仅会影响用户体验,还可能给企业带来巨大的经济损失,如电商平台在促销活动期间,若服务器因内存问题崩溃,可能会导致大量订单丢失,品牌声誉受损 。
六、如何优化内向回收机制
6.1合理设置内存水位标记
在 Linux 系统内存管理的精密体系中,内存水位标记起着关键作用,而 min_free_kbytes 就是调整内存水位标记的一把 “金钥匙”。min_free_kbytes 代表着系统中每个内存管理区(zone)需要保留的最少空闲内存数量 ,这个数值犹如系统内存的一道 “安全底线”,对内存回收的触发时机和力度有着深远影响。
在系统初始化时,min_free_kbytes 的数值会依据系统内存总量以及各内存管理区的页框数量,通过一套复杂的算法精准计算得出 。我们可以通过修改 /proc/sys/vm/min_free_kbytes 文件中的数值,轻松实现对它的调整 。举个例子,当我们将 min_free_kbytes 的值调高时,就好比提高了水位标记中的最低水位 。这会使得系统在内存分配时变得更加 “保守”,一旦空闲内存量接近这个提高后的最低水位 ,内存回收机制就会被迅速触发 ,就像提前拉响了警报,kswapd 会更早地开始回收内存 。这样做的好处是,能够有效避免内存过度使用,减少直接内存回收的发生频率 ,让系统在面对内存压力时更加从容,维持系统的稳定性和性能 。
然而,任何调整都需要谨慎权衡。如果 min_free_kbytes 设置得过高,虽然能在一定程度上避免内存紧张,但也会导致系统中可用于常规分配的内存减少 ,就像预留了过多的 “战略储备”,使得其他正常的内存需求无法得到充分满足 。比如,在一个同时运行多个大型应用程序的服务器上,如果 min_free_kbytes 设置过高,可能会导致新启动的应用程序在申请内存时遇到困难,出现运行缓慢甚至无法启动的情况 。
相反,若 min_free_kbytes 设置得过低,系统就像一个缺乏警惕的守卫,内存回收机制启动过晚 。当内存真正紧张时,可能就不得不频繁依赖直接内存回收这种 “紧急措施” ,这会严重影响系统性能,就像在危机时刻才匆忙应对,导致整个系统陷入混乱,出现卡顿、响应迟缓等问题 。所以,在设置 min_free_kbytes 时,我们需要根据系统的实际内存使用情况、运行的应用程序特点等因素,进行细致的评估和合理的调整 ,找到那个既能保障内存稳定供应,又能充分发挥系统性能的平衡点 。
6.2优化 swap 参数
在 Linux 内存管理的大舞台上,swap 参数中的 swappiness 就像一位掌控内存交换节奏的 “指挥家”,它的取值直接决定了系统对交换空间(swap space)的依赖程度,进而深刻影响着内存回收策略 。swappiness 的取值范围在 0 到 100 之间 ,默认值通常为 60 ,这个数值就像是一个 “开关”,调节着系统将内存页面换出到交换空间的积极性 。
当 swappiness 的值较低时,比如设置为 10 ,系统就如同一个偏好 “现用现取” 的使用者,更加倾向于优先使用物理内存 。这意味着只有当物理内存几乎被耗尽,实在没有其他可用内存时 ,系统才会极不情愿地将内存页面换出到交换空间 。在这种情况下,内存回收主要集中在对物理内存中不常用页面的清理 ,因为系统尽量避免使用交换空间,所以交换空间的使用频率会显著降低 。对于那些对响应速度要求极高的应用场景,如游戏服务器、实时数据分析系统等 ,较低的 swappiness 设置可以减少磁盘 I/O 操作,因为从磁盘交换数据比从物理内存读取数据要慢得多 ,从而大大提高系统的响应速度和整体性能 。
相反,当 swappiness 的值较高,例如设置为 90 时 ,系统就像一个 “未雨绸缪” 的管理者,非常积极地将内存中暂时不用的页面换出到交换空间 。这样做的好处是可以快速释放物理内存,让物理内存始终保持相对充足的状态 ,为新的内存分配请求提供更多的空间 。然而,频繁地进行内存与交换空间的数据交换,会导致磁盘 I/O 操作大幅增加 ,因为每次页面换出和换入都需要与磁盘进行数据传输 。
这对于磁盘性能较差的系统来说,无疑是雪上加霜,会严重影响系统的整体性能,导致系统运行变得迟缓 。在一些对内存容量需求较大,但对响应速度要求相对不那么高的应用场景中,如大型文件服务器、离线数据处理系统等 ,适当提高 swappiness 的值,可以更好地利用系统内存资源 ,避免因物理内存不足而导致的进程崩溃等问题 。所以,我们需要根据系统的实际应用场景和性能需求,灵活调整 swappiness 的值 ,以实现内存回收策略的优化,让系统在不同的工作负载下都能保持良好的运行状态 。