Linux内存回收机制:系统性能的幕后守护者
在Linux 系统的庞大体系中,内存扮演着极为关键的角色,堪称系统运行的 “血液”。就像血液对于人体,一刻不停地循环流动,为各个器官输送氧气和营养物质,维持人体正常运转一样,内存为 Linux 系统中的各个进程输送数据和指令,保障系统的稳定运行。
当我们在 Linux 系统中启动一个应用程序时,程序的代码和数据会被加载到内存中。CPU 从内存中读取这些指令和数据进行处理,处理结果也会暂时存储在内存中。可以说,内存是连接 CPU 与外部存储设备(如硬盘)的桥梁。由于 CPU 的运算速度极快,而硬盘等外部存储设备的读写速度相对较慢,如果没有内存作为数据的临时存储和快速交换区域,CPU 在等待数据从硬盘传输的过程中会处于空闲状态,极大地降低系统的整体性能。内存的存在使得 CPU 能够高效地与外部存储设备协同工作,让系统能够快速响应用户的操作。
一、内存紧张引发的 “危机”
在 Linux 系统的运行过程中,内存资源并非总是充足的。当系统中运行的进程过多,或者某些进程占用了大量内存时,内存紧张的情况就会出现,这如同人体血液循环不畅,会给系统带来一系列 “危机”。
最直观的表现就是系统运行卡顿。当内存紧张时,系统不得不频繁地将内存中的数据交换到磁盘的虚拟内存(Swap Space)中,这种操作被称为 Swap。由于磁盘的读写速度远远低于内存,频繁的 Swap 会导致系统响应速度大幅下降。比如,在使用 Linux 系统进行多任务处理时,同时打开多个大型文件、运行多个程序,如果内存不足,系统就会出现明显的卡顿,打开文件的速度变慢,程序之间的切换也变得迟缓,原本流畅的操作变得磕磕绊绊,严重影响用户体验。
更为严重的是,内存紧张还可能导致系统崩溃。当内存资源耗尽,系统无法为新的进程分配内存,也无法满足现有进程对内存的进一步需求时,就会触发 OOM(Out Of Memory)机制 ,即内存溢出。OOM Killer 会根据一定的算法选择并杀死一些占用内存较多的进程,试图释放内存资源。但在某些极端情况下,这种方式可能无法有效解决问题,最终导致整个系统崩溃,所有正在运行的程序都将被迫终止,数据丢失,给用户带来巨大的损失。
为了避免这些 “危机” 的发生,Linux 系统需要一套高效的内存回收机制,就像人体拥有强大的自我调节能力一样,及时清理和回收不再使用的内存资源,确保系统的稳定运行。
二、什么时候回收内存?
因为在不同的内存分配路径中,会触发不同的内存回收方式,内存回收针对的目标有两种,一种是针对zone的,另一种是针对一个memcg的,而这里我们只讨论针对zone的内存回收,个人把针对zone的内存回收方式分为三种,分别是快速内存回收、直接内存回收、kswapd内存回收。
快速内存回收:处于get_page_from_freelist()函数中,在遍历zonelist过程中,对每个zone都在分配前进行判断,如果分配后zone的空闲内存数量 < 阀值 + 保留页框数量,那么此zone就会进行快速内存回收,即使分配前此zone空闲页框数量都没有达到阀值,都会进行此zone的快速内存回收。注意阀值可能是min/low/high的任何一种,因为在快速内存分配,慢速内存分配和oom分配过程中如果回收的页框足够,都会调用到get_page_from_freelist()函数,所以快速内存回收不仅仅发生在快速内存分配中,在慢速内存分配过程中也会发生。直接内存回收:处于慢速分配过程中,直接内存回收只有一种情况下会使用,在慢速分配中无法从zonelist的所有zone中以min阀值分配页框,并且进行异步内存压缩后,还是无法分配到页框的时候,就对zonelist中的所有zone进行一次直接内存回收。注意,直接内存回收是针对zonelist中的所有zone的,它并不像快速内存回收和kswapd内存回收,只会对zonelist中空闲页框不达标的zone进行内存回收。并且在直接内存回收中,有可能唤醒flush内核线程。kswapd内存回收:发生在kswapd内核线程中,每个node有一个swapd内核线程,也就是kswapd内核线程中的内存回收,是只针对所在node的,并且只会对 分配了order页框数量后空闲页框数量 < 此zone的high阀值 + 保留页框数量 的zone进行内存回收,并不会对此node的所有zone进行内存回收。这三种内存回收虽然是在不同状态下会被触发,但是如果当内存不足时,kswapd内存回收和直接内存回收很大可能是在并发的进行内存回收的。而实际上,这三种回收再怎么不同,进行内存回收的执行代码是一样的,只是在内存回收前做的一些处理和判断不同。
2.1快速内存回收
无论是在快速分配还是慢速分配过程中,只要内核希望从一个zonelist中获取连续页框,就必须调用get_page_from_freelist()函数,在此函数中会对zonelist中的所有zone进行判断,判断能否从此zone分配连续页框,而判断一个zone能否进行分配的唯一标准是:分配后剩余的页框数量 > 阀值 + 此zone的保留页框数量。当zone不满足这个标准,内核会对zone进行快速内存回收,这个快速内存回收的执行路径是:
由于篇幅关系,就不列代码了,之前也说了,/proc/sys/vm/zone_reclaim_mode会影响快速内存回收,在get_page_from_freelist()函数中就有这么一段:
zone_allows_reclaim()用于计算zone与preferred_zone之间的距离,这个跟node距离有关,当距离不满足时,则不会对此zone进行快速内存回收,也就是当zone_reclaim_mode开启后,才会对zonelist中的所有zone进行内存回收。
需要注意阀值,之前也说了,在一次分配过程中,可能很多地方会调用get_page_from_freelist()函数,而每次传入的阀值很可能是不同的,在第一次进行快速分配时,使用的是zone的low阀值进行get_page_from_freelist()调用,在慢速分配过程中,会使用zone的min阀值进行get_page_from_freelist()调用,而在oomkill进行分配过程中,会使用high阀值调用get_page_from_freelist(),当zone的分配后剩余的页框数量 < 阀值 + 此zone的保留页框数量 时,则会调用zone_reclaim()对此zone进行内存回收而zone_reclaim()又会调用到__zone_relcaim()。
在__zone_reclaim()中,主要做三件事:初始化一个struct scan_control结构、循环调用shrink_zone()进行对zone的内存回收、从调用shrink_slab()对slab进行回收,struct scan_ control结构初始化如下:
nr_pages是1<<order。可以看到优先级为4,sc->may_writepage和sc->may_unmap与zone_reclaim_mode有关,这个sc是针对一个zone的,上面也说了,只有当zone不满足 分配后剩余的页框数量 > 阀值 + 此zone保留的页框数量 时,才会对zone进行内存回收,也就是它不是针对整个zonelist进行内存回收的,而是针对不满足情况的zone进行。再看看循环调用shrink_zone():
可以看到,每次调用shrink_zone后都会sc.priority--,也就是最多进行4次调用shrink_zone(),并且每次调用shrink_zone()扫描的页框会越来越多,直到回收到了1<<order个页框为止。
注意:在快速内存回收中,即使zone_reclaim_mode允许回写,也不会对脏文件页进行回写操作的,但是如果zone_reclaim_mode允许,会对非文件页进行回写操作。
可以对快速内存回收总结出:
开始标志是:此zone分配后剩余的页框数量 > 此zone的阀值 + 此zone的保留页框数量(阀值可能是:min,low,high其中一个)。结束标志是:对此zone回收到了本次分配时需要的页框数量 或者 sc->priority降为0(可能会进行多次shrink_zone()的调用)。回收对象:zone的干净文件页、slab、可能会回写匿名页2.2直接内存回收
调用流程:
直接内存回收发生在慢速分配中,在慢速分配中,首先唤醒所有node结点的kswap内核线程,然后会调用get_page_from_freelist()尝试用min阀值从zonelist的zone中获取连续页框,如果失败,则对zonelist的zone进行异步压缩,异步压缩之后再次调用get_page_from_freelist()尝试使用min阀值从zonelist的zone中获取连续页框,如果还是失败,就会进入到直接内存回收。
在进行直接内存回收时,进程是有可能加入到node的pgdat->pfmemalloc_wait这个等待队列中,当kswapd进行内存回收后如果node空闲内存达到平衡,那么就会唤醒pgdat->pfmemalloc_wait中的进程,其实也就是,加入到pgdat->pfmemalloc_wait这个等待队列的进程,自身就不会进行直接内存回收,而是让kswapd进行,之后kswapd会唤醒它们。之后的文章会详细说明这种情况。
先看初始化的struct scan_control,是在try_to_free_pages()中进行初始化的:
在直接内存回收过程中,这个sc结构是对zonelist中所有zone使用的,而不是像快速内存回收,是针对zonelist中不满足条件的一个一个zone进行使用,对于直接内存回收,以下需要注意:
sc的c初始使用的是默认的优先级12,那么就会对遍历12遍zonelist中的所有zone,每次遍历后sc->priority--,相当于让每个zone执行12次shrink_zone()
只有sc->priority == 12时会对zonelist中的所有zone强制执行shrink_zone(),而当sc->priority == 12这轮循环过后,会通过判断来确定zone是否要执行shrink_zone(),这个判断标志就是:此zone已经扫描的页数 < (此zone所有没有锁在内存中的文件页和非文件页之和 * 6) 。如果扫描页数超过此值,就说明已经对此zone扫描过太多页框了,就不对此zone进行shrink_zone()了。
并且当优先级降到10以下时,即使原来sc->may_writepage不允许回写,这时候会开始允许回写。这样做是因为不回写很难回收到页框。
只打算回收的页框为32个,并且在此期间,如果扫描页数超过(sc->nr_to_reclaim + sc->nr_to_reclaim / 2),则是会根据laptop_mode的情况唤醒flush内核线程的。
直接内存回收无论如何都不会对脏文件页进行回写操作,如果sc->may_writepage为1,那么会对非文件页进行回写操作
会对文件页和非文件页进行unmap操作会对非文件页处理(加入swap cache,unmap,回写)会先回收在memcg中并且超过所在memcg的soft_limit_in_bytes的进程的内存也会调用shrink_slab()对slab进行回收个人认为直接内存回收是为了让更多的页得到扫描,然后进行回写操作,也可能是为了后面的内存压缩回收一些页框,其实这里不太理解,为什么只回收32个页框,它并不像直接内存回收,打算回收的页框数量是1<<order。
可以对直接内存回收总结出:
开始标志是:zonelist的所有zone都不能通过min阀值获取到页框时。结束标志:回收到32个页框,或者sc->priority降到0,或者空闲页框足够进行内存压缩了(可能会进行多次shrink_zone()的调用)。回收对象:超过所在memcg的soft_limit_in_bytes的进程的内存、zone的干净文件页、slab、匿名页swap2.3kswapd内存回收
调用过程:
在分配过程中,只要get_page_from_freelist()函数无法以low阀值从zonelist的zone中获取到连续页框,并且分配内存标志gfp_mask没有标记__GFP_NO_KSWAPD,则会唤醒kswapd内核线程,在当中执行kswapd内存回收,先看初始化的sc结构:
由于此sc是针对整个node的所有zone的,这里没有设置sc->nr_to_reclaim,在确定对某个zone进行内存回收时,这个sc->nr_to_reclaim被设置为:
可以看到,如果回收的页框数量达到了zone的high阀值,其实意思就是尽可能的回收页框了,kswapd内核线程是每个node有一个的,那也意味着,此node的kswapd只会对此node的zone进行内存回收工作,也就不需要zonelist了。
要点:
优先级使用默认为的12,会执行多次遍历node(并不是node中的所有zone),但并不会每次遍历都进行sc->priority--,当能够回收的内存时,才进行sc->priority--以ZONE_HIGHMEM -> ZONE_NORMAL ->ZONE_DMA的顺序找出第一个不平衡的zone,平衡条件是: 此zone分配页框后剩余的页框数量 > 此zone的high阀值 + 此zone保留的页框数量。不满足则表示此zone不平衡。
对第一个不平衡的zone及其后面的zone进行回收在memcg中并且超过所在memcg的soft_limit_in_bytes的进程的内存,比如第一个不平衡的zone是ZONE_NORMAL,那么执行内存回收的zone就是ZONE_NORMAL和ZONE_DMA。
如果zone是平衡的,则不对zone进行内存回收(但是上面那部不会因为zone平衡而不执行),而如果zone是不平衡的,那么会调用shrink_zone()进行内存回收,以及调用shrink_slab()进行slab的回收。
对于node中所有 zone分配后剩余内存 < zone的low阀值 + zone保留的页框数量 的zone,会进行内存压缩
检查node中所有zone是否都平衡,没有平衡则继续循环
如果laptop == 0,那么会对文件页和非文件页进行回写操作,如果laptop == 1,那么只有当sc->priority < 10时才会对文件页和非文件页进行回写操作
会对文件页和非文件页进行回写unmap操作
会对非文件页进行处理(加入swapcache,unmap,回写)
可以看出来,kswapd内存回收会将node结点中的所有zone的空闲页框都至少拉高high阀值。
可以对kswapd内存回收总结出:
开始标志:zonelist的所有zone都不能通过min阀值获取到页框时,会唤醒所有node的kswapd内核线程,然后在kswapd中会对不满足 zone分配页框后剩余的页框数量 > 此zone的high阀值 + 此zone保留的页框数量 的zone进行内存回收。结束标志:node中所有zone都满足 zone分配页框后剩余的页框数量 > 此zone的high阀值 + 此zone保留的页框数量(可能会进行多次shrink_zone()的调用)。回收对象:超过所在memcg的soft_limit_in_bytes的进程的内存、zone的干净的文件页、zone的脏的文件页、slab、匿名页swap2.4回收哪些内存
(1)Page CacheCPU如果要访问外部磁盘上的文件,需要首先将这些文件的内容拷贝到内存中,由于硬件的限制,从磁盘到内存的数据传输速度是很慢的,如果现在物理内存有空余,干嘛不用这些空闲内存来缓存一些磁盘的文件内容呢,这部分用作缓存磁盘文件的内存就叫做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结构体具体是怎样构成的:
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的。这里是用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。
(3)Reverse 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种数据结构中。
三、Linux内存回收机制
2.1回收对象:匿名页与文件页
在 Linux 系统中,内存回收主要针对匿名页和文件页展开。匿名页是一种比较特殊的内存页,它不像文件页那样与磁盘上的文件存在直接映射关系,通常用于存储进程的堆、栈数据等 。当系统需要回收匿名页时,会筛选出那些访问频率较低、不经常使用的匿名页,将它们写入到 swap 分区中。swap 分区就像是内存的 “临时仓库”,当内存空间紧张时,把暂时不用的数据存放到这里,等需要时再取回来。写入 swap 分区后,这些匿名页就可以作为空闲页框释放到伙伴系统,供其他进程申请使用,从而有效缓解内存压力。
文件页则涵盖了内核缓存的磁盘数据(Buffer)以及内核缓存的文件数据(Cache)。在回收文件页时,系统会先判断文件页的状态。如果文件页保存的内容与磁盘中文件对应内容一致,即该文件页是干净的,那么无需进行回写操作,可直接将其作为空闲页框释放到伙伴系统;反之,如果文件页保存的数据和磁盘中文件对应的数据不一致,这样的文件页被称为脏页,就需要先将其回写到磁盘中对应数据所在的位置,确保数据的一致性,然后才能作为空闲页框释放 。
例如,当我们编辑一个文本文件时,在保存之前,文件在内存中的对应页就是脏页,只有保存后,数据写入磁盘,相应的文件页才会变成干净页。通过这种有针对性的回收策略,系统能够合理地管理内存资源,提高内存的使用效率。
2.2zone:内存回收的基本单位
在 Linux 系统中,内存回收是以 zone 为基本单位进行的。zone 是对内存的一种逻辑划分,它将物理内存按照不同的特性和用途进行分类管理,主要包括 DMA zone、Normal zone 和 HighMem zone 等 。不同的 zone 适用于不同类型的内存访问需求,例如,DMA zone 主要用于直接内存访问设备,Normal zone 用于常规的内存分配,而 HighMem zone 用于高端内存的管理。
在每个 zone 中,都有三条重要的阈值线,即 watermark [WMARK_MIN](最小阈值)、watermark [WMARK_LOW](低阈值)和 watermark [WMARK_HIGH](高阈值),它们在内存分配和回收过程中起着关键的判断和触发作用。当系统进行内存分配时,如果是快速分配,默认会以 watermark [WMARK_LOW] 作为阈值进行判断。
如果某个 zone 的空闲页数量低于这个低阈值,说明该 zone 的内存资源较为紧张,系统会立即对该 zone 执行快速内存回收操作,以获取更多的空闲内存,满足当前的内存分配请求 。比如,当一个新的进程启动需要申请内存时,如果发现所在 zone 的空闲页数量低于低阈值,系统就会迅速启动快速内存回收,优先保障新进程的内存需求。
若快速内存分配失败,系统会进入慢速分配阶段,此时会使用 watermark [WMARK_MIN] 这个最小阈值进行内存分配。如果即使使用最小阈值也无法完成内存分配,那就意味着系统内存极度紧张,会触发直接内存回收以及快速内存回收机制,尽力从各个方面回收内存,避免因内存不足导致系统出现异常。
而 watermark [WMARK_HIGH] 代表着 zone 对于空闲页数量比较满意的一个数值状态 。当 zone 的空闲页数量高于这个高阈值时,说明该 zone 的内存资源充足,系统处于比较良好的运行状态;当对 zone 进行内存回收时,通常会将目标设定为把 zone 的空闲页数量提高到此高阈值以上,使内存资源达到一个较为理想的平衡状态 。在系统运行过程中,通过不断地根据这三条阈值线对内存进行监控和调整,Linux 系统能够有效地管理内存资源,保障系统的稳定运行和高效性能。我们可以通过/proc/zoneinfo文件查看各个 zone 的这三个阈值的具体数值,以便更好地了解系统内存状态。
四、Linux内存回收的方式
4.1zone的阀值
内存回收是以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分区4.2lru链表
lru链表主要作用就是将页排序,将最应该回收的页放到最后面,最不应该回收的页放到最前面,,然后进行内存回收时,就会从后面向前面进行扫描,将扫描到的页尝试进行回收。这里只需要记住一点,回收的页都是非活动匿名页lru链表或者非活动文件页lru链表上的页。这些页包括:进程堆、栈、匿名mmap共享内存映射、shmem共享内存映射使用的页、映射磁盘文件的页。
(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:页被锁在内存中(此标志可以保证不被换出,但是无法保证不被被做内存迁移)内存回收做的事情就是想办法将目标页的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来说,则无法做到这一点。4.3内存分配过程
要说清楚内存回收,就必须要先理清楚内存分配过程,在调用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链表就可能如下:
4.4扫描控制结构
之前说内存压缩的文章也有涉及这个结构,现在详细说明一下,扫描控制结构用于内存回收和内存压缩,它的主要作用时保存对一次内存回收或者内存压缩的变量和参数,一些处理结果也会保存在里面,结构如下:
结构很简单,主要就是保存一些参数,在内存回收和内存压缩时就会根据这个结构中的这些参数,做不同的处理,后面代码会详细说明。这里我们只说说会几个特别的参数:
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链表。五、内存回收实现方式
5.1页面回收与LRU算法
页面回收是 Linux 内存回收机制的基础环节,其核心在于精准地识别并释放那些不再被频繁使用的内存页面,而 LRU(Least Recently Used)算法则在这一过程中扮演着 “筛选器” 的关键角色 。LRU 算法基于一个简单而有效的假设:如果一个页面在过去很长一段时间内都未被访问,那么在未来的短时间内,它被访问的概率也相对较低。这就好比图书馆里的书籍,如果某本书籍长时间无人借阅,那么在接下来的一段时间里,它被借阅的可能性也不大,就可以考虑将其从常用书架上移除,为其他更受欢迎的书籍腾出空间。
在 Linux 系统中,内核通过维护一个双向链表来实现 LRU 算法。链表中的每个节点都代表一个内存页面,每当一个页面被访问时,它就会被移动到链表的头部,表示它是最近被使用的页面;而链表尾部的页面则是最近最少使用的,当系统需要回收内存时,就会优先从链表尾部选择页面进行回收 。以浏览器的页面缓存为例,当我们频繁浏览不同的网页时,浏览器会将最近访问的网页页面缓存到内存中,采用 LRU 算法管理这些缓存页面。
如果内存不足,浏览器就会将链表尾部,也就是那些长时间未被访问的网页页面缓存回收,释放出内存空间,以便缓存新的网页页面,确保浏览器能够高效运行。通过这种方式,LRU 算法能够有效地管理内存页面,使得系统能够及时回收不再使用的页面,将释放的内存重新分配给其他急需内存的进程,从而提高内存的使用效率,保障系统的稳定运行。
5.2页面交换:内存与磁盘的 “互动”
页面交换是 Linux 内存回收机制应对内存不足的重要手段,它建立在虚拟内存技术的基础之上,实现了内存与磁盘之间的数据交换,就像在仓库与临时存储点之间搬运货物,以解决仓库空间不足的问题。当系统内存紧张时,那些不活跃的页面,也就是长时间未被访问的页面,会被操作系统视为 “暂时不需要的货物”,从物理内存中移出,交换到磁盘上的交换分区(Swap Partition)中,这个过程被称为 “换出”(Swap Out) 。交换分区就像是内存的 “备份仓库”,专门用于存储这些被换出的页面。
当这些被换出的页面在未来某个时刻又需要被访问时,操作系统会将其从交换分区重新调入内存,这个过程被称为 “换入”(Swap In) 。在 Linux 系统中,页面交换由内核的页替换算法自动执行,常见的算法如 LRU 算法在这一过程中发挥着重要作用,它帮助系统确定哪些页面是最不活跃的,应该被优先换出 。例如,当我们在使用 Linux 系统进行多任务处理时,同时运行多个大型程序,随着内存逐渐被占用,系统会将一些暂时不使用的程序页面换出到交换分区,如后台运行的数据库程序中一些不常用的数据页面。
当这些程序再次需要这些页面时,系统又会及时将它们从交换分区换入内存,确保程序能够正常运行。虽然页面交换机制有效地增加了系统的可用内存,但频繁的页面交换会导致系统的磁盘 I/O 负载过高,因为磁盘的读写速度远远低于内存,这就好比频繁地在仓库与临时存储点之间搬运货物,会耗费大量的时间和精力,进而影响系统的响应速度。因此,在实际应用中,需要合理地设置交换分区的大小和内核的页面交换算法,以及优化系统的内存使用方式,以避免过度使用交换分区,保障系统的性能。
5.3内存压缩:向空间要效率
内存压缩是 Linux 内存回收机制中一项旨在提高内存使用效率、减少磁盘 I/O 的创新技术,它通过运用高效的压缩算法,对那些不活跃的页面进行压缩处理,从而在有限的内存空间中存储更多的数据,就像将蓬松的物品压缩成紧凑的状态,以节省存储空间。当系统内存不足时,传统的页面交换机制会将不活跃页面写入磁盘交换分区,这一过程伴随着大量的磁盘 I/O 操作,严重影响系统性能。而内存压缩机制则另辟蹊径,它将不活跃页面在内存中直接进行压缩,然后存储在内存的特定区域,避免了频繁的磁盘 I/O 。
在 Linux 系统中,内存压缩机制通常借助 zRAM 等技术来实现。zRAM 虚拟出一个块设备,当系统触发内存回收时,会先从系统中查找不活跃的内存页面,然后将这些页面发送到 zRAM 设备。zRAM 设备接收到页面后,会使用特定的压缩算法,如 lzo、lz4 等对页面进行压缩,将压缩后的数据存储在内存中 。当进程需要访问这些被压缩的页面时,系统会先从 zRAM 设备中读取压缩数据,然后进行解压缩,将解压缩后的页面重新放置在内存中供进程使用 。
以手机系统为例,在运行多个应用程序时,内存资源容易紧张。采用内存压缩机制后,系统可以将后台应用程序中不活跃的页面进行压缩,如压缩图片处理应用在后台时占用的大量图像数据页面,将其压缩后存储在内存中,为前台运行的应用程序腾出更多内存空间,同时避免了将这些页面交换到磁盘,减少了磁盘 I/O 操作,提高了系统的整体性能,使得手机在多任务处理时更加流畅。内存压缩机制在一定程度上缓解了内存压力,提高了系统性能,是 Linux 内存回收机制中一项重要的优化技术。
5.4匿名页面丢弃:特殊情况下的内存释放
匿名页面丢弃是 Linux 内存回收机制在特定情况下采取的一种内存释放策略,主要针对那些不属于文件系统缓存的匿名页面,这些页面通常由进程的堆栈和堆分配产生,就像临时搭建的帐篷,在不需要时可以拆除以腾出空间。当系统内存极度紧张,且其他内存回收机制无法满足内存需求时,匿名页面丢弃机制就会启动 。
在这种情况下,操作系统会对匿名页面进行评估,选择那些可以安全丢弃的页面。对于进程堆栈和堆分配的匿名页面,如果这些页面中的数据在后续操作中可以重新生成,或者对进程的正常运行没有直接影响,那么它们就有可能被丢弃 。例如,在一些计算密集型的进程中,堆栈中可能会临时存储一些中间计算结果,这些结果在计算完成后可以通过重新计算得到,当系统内存不足时,这些匿名页面就可以被丢弃,释放出内存空间 。
不过,匿名页面丢弃机制的实施需要谨慎,因为错误地丢弃关键的匿名页面可能会导致进程崩溃或数据丢失。因此,Linux 系统在执行匿名页面丢弃操作时,会严格遵循一定的规则和条件,确保丢弃的页面不会对系统和进程的正常运行造成损害 。匿名页面丢弃机制为 Linux 系统在极端内存压力下提供了一种有效的内存释放手段,保障了系统的基本运行和关键进程的正常执行 。