概述
linux使用修改过的LRU算法简要介绍:
对于整个内存回收来说,lru链表是关键中的关键,实际上整个内存回收,做的事情就是处理lru链表的收缩,所以这篇文章就先说说系统的lru链表。
内存回收的核心思想,就是如果一些数据能够保存到磁盘,在内存不足时就把这些数据写到磁盘中,这样这些数据占用的内存页就可以作为空闲内存页给予系统使用了。
当内存不足时,系统就必须要将一些页框回收,而哪些页框可以回收呢,之前我们有说过,属于内核的大部分页框是不能够进行回收的,比如内核栈、内核代码段、内核数据段以及大部分内核使用的页框,它们都是不能够进行回收的;而相反,主要由进程使用的页框,比如进程代码段、进程数据段、进程堆栈、进程访问文件时映射的文件页、进程间共享内存使用的页,这些页框都是可以进行回收的。
当明确哪些页框可以回收,哪些页框不能够回收时,针对那些可以回收的页框,从中选择更应该进行回收的页框就变成一件很有必要的事情了,因为选择得好,能够减轻系统的负担,选择得不好,反而拖累了系统,让系统运行起来更艰难。比如:一个非常频繁地被访问的页,这个页可以进行回收,当内存不足时,系统选择对这个页进行回收,将这个页写入磁盘,而由于此页在写入磁盘之后立即又被访问了,系统又要将这个页从磁盘读到内存中,相当于系统进行了一次读写,而页又没有能够进行释放,一个页是这样可以接受,如果是1000个页是这种情况,可想而知,这样会大大拖累的系统,让系统做了非常多无用功。
lru链表在这时候就起到了这个重要作用,它能够让系统在那些可以回收的页框当中,选择到理想的回收页框。lru链表的核心思想就是做假设,如果一个页很久没有被访问到了,那么就假设在下一段时间中,这个页也可能不会被访问到。但是对于系统来说,它永远无法知道哪个页即将被访问,它认定一个页接下来的一段时间不会被访问到,但是有可能此页在下一刻就立刻被访问到了,也就是说,即使使用了lru链表,也不能保证不会发生上述的情况。
内核主要对进程使用的页进行回收,而回收操作,主要是两个方面:一.直接将一些页释放。二.将页回写保存到磁盘,然后再释放。对于第一种,最明显的就是进程代码段的页,这些页都是只读的,因为代码段是禁止修改的,对于这些页,直接释放掉就好,因为磁盘上对应的数据与页中的数据是一致的。那么对于进程需要回写的页,内核主要将这些页放到磁盘的两个地方,当进程使用的页中的数据是映射于具体文件的,那么只需要将此页中的数据回写到对应文件所在磁盘位置就可以了。而对于那些没有映射磁盘对应文件的页,内核则将它们存放到swap分区中。根据这个,整理出下面这些情况的页
- 进程堆、栈、数据段使用的匿名页:存放到swap分区中
- 进程代码段映射的可执行文件的文件页:直接释放
- 打开文件进行读写使用的文件页:如果页中数据与文件数据不一致,则进行回写到磁盘对应文件中,如果一致,则直接释放
- 进行文件映射mmap共享内存时使用的页:如果页中数据与文件数据不一致,则进行回写到磁盘对应文件中,如果一致,则直接释放
- 进行匿名mmap共享内存时使用的页:存放到swap分区中
- 进行shmem共享内存时使用的页:存放到swap分区中
由此可以看出,实际上lru链表只需要对两种情况进行分别处理就好了,一种是页需要存放到swap分区的情况,一种是页映射了文件的情况。但是还有一种情况,就是这些页是前面两种页中的一种,但是这些页被系统锁在内存中,禁止换出与回收,也就是整个lru链表主要组织上面三种情况的页。
lru链表描述符
如前面所说,lru链表组织的页包括:可以存放到swap分区中的页,映射了文件的页,以及被锁在内存中禁止换出的进程页。所有属于这些情况的页都必须加入到lru链表中,无一例外,而剩下那些没有加入到lru链表中的页,基本也就剩内核使用的页框了。
首先,lru链表并不是一个系统中只有一个,而是每个zone有一个,每个memcg在每个zone上又有一个。这样听起来很复杂,实际上只是并不是,而为了方便说明,本文中就只分析每个zone中包含的这个lru链表,而实际上memcg中为每个zone维护的lru链表,在代码和结构上是一样的。由于每个zone有自己的lru链表,我们先看看zone中与lru相关的变量:
struct zone { ...... /* lru链表使用的自旋锁 * 当需要修改lru链表描述符中任何一个链表时,都需要持有此锁,也就是说,不会有两个不同的lru链表同时进行修改 */ spinlock_t lru_lock; /* lru链表描述符 */ struct lruvec lruvec; ...... }
每当对此zone的lru链表进行修改时,一定需要获取这个lru_lock的锁防止并发的情况。
下面说说lru链表描述符,正如前面所说,系统主要会将进程使用的页框分为下面三类:
- 可以存放到swap分区中的页
- 映射了磁盘文件的文件页
- 被锁在内存中禁止换出的进程页(包括以上两种页)
由于进程使用的页框分为三类,而lru链表是一个大的整体,系统为了把这三种类型的页都会放入到lru链表中。就用一个struct lruvec结构来描述一个lru链表,也可以称struct lruvec为lru链表描述符,如下:
/* lru链表描述符,主要有5个双向链表 * LRU_INACTIVE_ANON = LRU_BASE, * LRU_ACTIVE_ANON = LRU_BASE + LRU_ACTIVE, * LRU_INACTIVE_FILE = LRU_BASE + LRU_FILE, * LRU_ACTIVE_FILE = LRU_BASE + LRU_FILE + LRU_ACTIVE, * LRU_UNEVICTABLE, */ struct lruvec { /* 5个lru双向链表头 */ struct list_head lists[NR_LRU_LISTS]; struct zone_reclaim_stat reclaim_stat; #ifdef CONFIG_MEMCG /* 所属zone */ struct zone *zone; #endif };
可以看到,一个lru链表描述符中总共有5个双向链表头,它们分别描述五中不同类型的链表。由于每个页有自己的页描述符,而内核主要就是将对应的页的页描述符加入到这些链表中。
对于此zone中所有可以存放到swap分区中并且没被锁在内存中的页(进程堆、栈、数据段使用的页,匿名mmap共享内存使用的页,shmem共享内存使用的页),lru链表描述符会使用下面两个链表进行组织:
- LRU_INACTIVE_ANON:称为非活动匿名页lru链表,此链表中保存的是此zone中所有最近没被访问过的并且可以存放到swap分区的页描述符,在此链表中的页描述符的PG_active标志为0。
- LRU_ACTIVE_ANON:称为活动匿名页lru链表,此链表中保存的是此zone中所有最近被访问过的并且可以存放到swap分区的页描述符,此链表中的页描述符的PG_active标志为1。
这两个链表我们统称为匿名页lru链表。
对于此zone中所有映射了具体磁盘文件页并且没有被锁在内存中的页(映射了内核映像的页除外),lru链表描述符会使用下面两个链表组织:
- LRU_INACTIVE_FILE:称为非活动文件页lru链表,此链表中保存的是此zone中所有最近没被访问过的文件页的页描述符,此链表中的页描述符的PG_active标志为0。
- LRU_ACTIVE_FILE:称为活动文件页lru链表,此链表中保存的是此zone中所有最近被访问过的文件页的页描述符,此链表中的页描述符的PG_active标志为1。
这两个链表我们统称为文件页lru链表。
而对于此zone中那些锁在内存中的页,lru链表描述符会使用这个链表进行组织:
- LRU_UNEVICTABLE:此链表中保存的是此zone中所有禁止换出的页的描述符。
为了方便对于LRU_INACTIVE_ANON和LRU_ACTIVE_ANON这两个链表,统称为匿名页lru链表,而LRU_INACTIVE_FILE和LRU_ACTIVE_FILE统称为文件页lru链表。当进程运行过程中,通过调用mlock()将一些内存页锁在内存中时,这些内存页就会被加入到它们锁在的zone的LRU_UNEVICTABLE链表中,在LRU_UNEVICTABLE链表中的页可能是文件页也可能是匿名页。
之前说了内核主要是将对应页的页描述符加入到上述几个链表中的某个,比如我一个页映射了磁盘文件,那么这个页就加入到文件页lru链表中,内核主要通过页描述符的lru和flags标志描述一个加入到了lru链表中的页。
struct page { /* 用于页描述符,一组标志(如PG_locked、PG_error),同时页框所在的管理区和node的编号也保存在当中 */ /* 在lru算法中主要用到的标志 * PG_active: 表示此页当前是否活跃,当放到或者准备放到活动lru链表时,被置位 * PG_referenced: 表示此页最近是否被访问,每次页面访问都会被置位 * PG_lru: 表示此页是处于lru链表中的 * PG_mlocked: 表示此页被mlock()锁在内存中,禁止换出和释放 * PG_swapbacked: 表示此页依靠swap,可能是进程的匿名页(堆、栈、数据段),匿名mmap共享内存映射,shmem共享内存映射 */ unsigned long flags; ...... union { /* 页处于不同情况时,加入的链表不同 * 1.是一个进程正在使用的页,加入到对应lru链表和lru缓存中 * 2.如果为空闲页框,并且是空闲块的第一个页,加入到伙伴系统的空闲块链表中(只有空闲块的第一个页需要加入) * 3.如果是一个slab的第一个页,则将其加入到slab链表中(比如slab的满slab链表,slub的部分空slab链表) * 4.将页隔离时用于加入隔离链表 */ struct list_head lru; ...... }; ...... }
由于struct page是一个复合结构,当page用于不同情况时,lru变量加入的链表不同(如注释),这里我们只讨论页是进程正在使用的页时的情况。这时候,页通过页描述符的lru加入到对应的zone的lru链表中,然后会置位flags中的PG_lru标志,表明此页是在lru链表中的。而如果flags的PG_lru和PG_mlocked都置位,说明此页是处于lru链表中的LRU_UNEVICTABLE链表上。如下图:
需要注意,此zone中所有可以存放于swap分区的页加入到匿名页lru链表,并不代表这些页现在就在swap分区中,而是未来内存不足时,可以将这些页数据放到swap分区中,以此来回收这些页。
lru缓存
上面说到,当需要修改lru链表时,一定要占有zone中的lru_lock这个锁,在多核的硬件环境中,在同时需要对lru链表进行修改时,锁的竞争会非常的频繁,所以内核提供了一个lru缓存的机制,这种机制能够减少锁的竞争频率。其实这种机制非常简单,lru缓存相当于将一些需要相同处理的页集合起来,当达到一定数量时再对它们进行一批次的处理,这样做可以让对锁的需求集中在这个处理的时间点,而没有lru缓存的情况下,则是当一个页需要处理时则立即进行处理,对锁的需求的时间点就会比较离散。首先为了更好的说明lru缓存,先对lru链表进行操作主要有以下几种:
- 将不处于lru链表的新页放入到lru链表中
- 将非活动lru链表中的页移动到非活动lru链表尾部(活动页不需要这样做,后面说明)
- 将处于活动lru链表的页移动到非活动lru链表
- 将处于非活动lru链表的页移动到活动lru链表
- 将页从lru链表中移除
除了最后一项移除操作外,其他四样操作除非在特殊情况下, 否则都需要依赖于lru缓存。可以看到上面的5种操作,并不是完整的一套操作集(比如没有将活动lru链表中的页移动到活动lru链表尾部),原因是因为lru链表并不是供于整个系统所有模块使用的,可以说lru链表的出现,就是专门用于进行内存回收,所以这里的操作集只实现了满足于内存回收所需要使用的操作。
大部分在内存回收路径中对lru链表的操作,都不需要用到lru缓存,只有非内存回收路径中需要对页进行lru链表的操作时,才会使用到lru缓存。为了对应这四种操作,内核为每个CPU提供了四种lru缓存,当页要进行lru的处理时,就要先加入到lru缓存,当lru缓存满了或者系统主要要求将lru缓存中所有的页进行处理,才会将lru缓存中的页放入到页想放入的lru链表中。每种lru缓存使用struct pagevec进行描述:
/* LRU缓存 * PAGEVEC_SIZE默认为14 */ struct pagevec { /* 当前数量 */ unsigned long nr; unsigned long cold; /* 指针数组,每一项都可以指向一个页描述符,默认大小是14 */ struct page *pages[PAGEVEC_SIZE]; };
一个lru缓存的大小为14,也就是一个lru缓存中最多能存放14个即将处理的页。
nr代表的是此lru缓存中保存的页数量,而加入到了lru缓存中的页,lru缓存中的pages指针数组中的某一项就会指向此页的页描述符,也就是当lru缓存满时,pages数组中每一项都会指向一个页描述符。
上面说了内核为每个CPU提供四种缓存,这四种lru缓存如下:
/* 这部分的lru缓存是用于那些原来不属于lru链表的,新加入进来的页 */ static DEFINE_PER_CPU(struct pagevec, lru_add_pvec); /* 在这个lru_rotate_pvecs中的页都是非活动页并且在非活动lru链表中,将这些页移动到非活动lru链表的末尾 */ static DEFINE_PER_CPU(struct pagevec, lru_rotate_pvecs); /* 在这个lru缓存的页原本应属于活动lru链表中的页,会强制清除PG_activate和PG_referenced,并加入到非活动lru链表的链表表头中 * 这些页一般从活动lru链表中的尾部拿出来的 */ static DEFINE_PER_CPU(struct pagevec, lru_deactivate_pvecs); #ifdef CONFIG_SMP /* 将此lru缓存中的页放到活动页lru链表头中,这些页原本属于非活动lru链表的页 */ static DEFINE_PER_CPU(struct pagevec, activate_page_pvecs); #endif
如注释所说,CPU的每一个lru缓存处理的页是不同的,当一个新页需要加入lru链表时,就会加入到cpu的lru_add_pvec缓存;当一个非活动lru链表的页需要被移动到非活动页lru链表末尾时,就会被加入cpu的lru_rotate_pvecs缓存;当一个活动lru链表的页需要移动到非活动lru链表中时,就会加入到cpu的lru_deactivate_pvecs缓存;当一个非活动lru链表的页被转移到活动lru链表中时,就会加入到cpu的activate_page_pvecs缓存。
注意,内核是为每个CPU提供四种lru缓存,而不是每个zone,并且也不是为每种lru链表提供四种lru缓存,也就是说,只要是新页,所有应该放入lru链表的新页都会加入到当前CPU的lru_add_pvec这个lru缓存中,比如同时有两个新页,一个将加入到zone0的活动匿名页lru链表,另一个将加入到zone1的非活动文件页lru链表,这两个新页都会先加入到此CPU的lru_add_pvec这个lru缓存中。用以下图进行说明更好理解,当前CPU的lru缓存中有page1,page2和page3这3个页,这时候page4加入了进来:
当page4加入后,当前CPU的lru_add_pvec缓存中有4个页待处理的页,而此时,如果当前CPU的lru_add_pvec缓存大小为4,或者一些情况需要当前CPU立即对lru_add_pvec缓存进行处理,那么这些页就会被放入到它们需要放入的lru链表中,如下:
这些页加入完后,当前CPU的lru_add_pvec缓存为空,又等待新一轮要被加入的新页。
对于CPU的lru_add_pvec缓存的处理,如上,而其他类型的lru缓存处理也是相同。只需要记住,要对页实现什么操作,就放到CPU对应的lru缓存中,而CPU的lru缓存满或者需要立即将lru缓存中的页放入lru链表时,就会将lru缓存中的页放到它们需要放入的lru链表中。同时,对于lru缓存来说,它们只负责将页放到页应该放到的lru链表中,所以,在一个页加入lru缓存前,就必须设置好此页的一些属性,这样才能配合lru缓存进行工作。
加入lru链表
将上面的所有结构说完,已经明确了几点:
- 不同类型的页需要加入的lru链表不同
- 在smp中,加入lru链表前需要先加入到当前CPU的lru缓存中
- 需要不同处理的页加入的当前CPU的lru缓存不同。
接下来我们看看不同操作的实现代码。
实现代码
新页加入lru链表
当需要将一个新页需要加入到lru链表中,此时必须先加入到当前CPU的lru_add_pvec缓存中,一般通过__lru_cache_add()函数进行加入,如下:
/* 加入到lru_add_pvec缓存中 */ static void __lru_cache_add(struct page *page) { /* 获取此CPU的lru缓存, */ struct pagevec *pvec = &get_cpu_var(lru_add_pvec); /* page->_count++ * 在页从lru缓存移动到lru链表时,这些页的page->_count会-- */ page_cache_get(page); /* 检查LRU缓存是否已满,如果满则将此lru缓存中的页放到lru链表中 */ if (!pagevec_space(pvec)) __pagevec_lru_add(pvec); /* 将page加入到此cpu的lru缓存中,注意,加入pagevec实际上只是将pagevec中的pages数组中的某个指针指向此页,如果此页原本属于lru链表,那么现在实际还是在原来的lru链表中 */ pagevec_add(pvec, page); put_cpu_var(lru_add_pvec); }
注意在此函数中加入的页的page->_count会++,也就是新加入lru缓存的页它的page->_count会++,而之后我们会看到,当页从lru缓存中移动到lru链表后,此页的page->_count就会--了。
pagevec_space()用于判断这个lru缓存是否已满,判断方法很简单:
static inline unsigned pagevec_space(struct pagevec *pvec) { return PAGEVEC_SIZE - pvec->nr; }
如果lru缓存已满的情况下,就必须先把lru缓存中的页先放入它们需要放入的lru链表中,之后再将这个新页放入到lru缓存中,通过调用pagevec_add()将页加入到lru缓存中,如下:
/* 将page加入到lru缓存pvec中 */ static inline unsigned pagevec_add(struct pagevec *pvec, struct page *page) { /* lru缓存pvec的pages[]中的pvec->nr项指针指向此页 */ pvec->pages[pvec->nr++] = page; /* 返回此lru缓存剩余的空间 */ return pagevec_space(pvec); }
在一些特殊情况或者lru缓存已满的情况下,都会将lru缓存中的页放入到它们对应的lru链表中,这个可通过__pagevec_lru_add()函数进行实现,在__pagevec_lru_add()函数中,主要根据lru缓存的nr遍历缓存中已经保存的页,在期间会对这些页所在的zone的lru_lock上锁,因为不能同时有2个CPU并发地修改同一个lru链表,之后会调用相应的回调函数,对遍历的页进行处理:
/* 将pagevec中的页加入到lru链表中,并且会将pvec->nr设置为0 */ void __pagevec_lru_add(struct pagevec *pvec) { /* __pagevec_lru_add_fn为回调函数 */ pagevec_lru_move_fn(pvec, __pagevec_lru_add_fn, NULL); }
实际上不同的lru链表操作,很大一部分不同就是这个回调函数的不同,回调函数决定了遍历的每个页应该进行怎么样的处理,而不同lru链表操作它们遍历lru缓存中的页的函数都是pagevec_lru_move_fn,我们先看看所有lru链表操作都共同使用的pagevec_lru_move_fn:
/* 将缓存中的页做move_fn处理,然后对页进行page->_count-- * 当所有页加入到lru缓存中时,都要page->_count++ */ static void pagevec_lru_move_fn(struct pagevec *pvec, void (*move_fn)(struct page *page, struct lruvec *lruvec, void *arg), void *arg) { int i; struct zone *zone = NULL; struct lruvec *lruvec; unsigned long flags = 0; /* 遍历pagevec中的所有页 * pagevec_count()返回lru缓存pvec中已经加入的页的数量 */ for (i = 0; i < pagevec_count(pvec); i++) { struct page *page = pvec->pages[i]; /* 获取页所在的zone */ struct zone *pagezone = page_zone(page); /* 由于不同页可能加入到的zone不同,这样就是判断是否是同一个zone,是的话就不需要上锁了 * 不是的话要先把之前上锁的zone解锁,再对此zone的lru_lock上锁 */ if (pagezone != zone) { /* 对之前的zone进行解锁,如果是第一次循环则不需要 */ if (zone) spin_unlock_irqrestore(&zone->lru_lock, flags); /* 设置上次访问的zone */ zone = pagezone; /* 这里会上锁,因为当前zone没有上锁,后面加入lru的时候就不需要上锁 */ spin_lock_irqsave(&zone->lru_lock, flags); } /* 获取zone的lru链表 */ lruvec = mem_cgroup_page_lruvec(page, zone); /* 将page加入到zone的lru链表中 */ (*move_fn)(page, lruvec, arg); } /* 遍历结束,对zone解锁 */ if (zone) spin_unlock_irqrestore(&zone->lru_lock, flags); /* 对pagevec中所有页的page->_count-- */ release_pages(pvec->pages, pvec->nr, pvec->cold); /* pvec->nr = 0 */ pagevec_reinit(pvec);
可以看到,这里最核心的操作,实际上就是遍历lru缓存pvec中每个指向的页,如果该页所在zone的lru_lock没有进行上锁,则上锁,然后对每个页进行传入的回调函数的操作,当所有页都使用回调函数move_fn处理完成后,就对lru缓存中的所有页进行page->_count--操作。
从之前的代码可以看到,这个move_fn就是传入的回调函数,对于新页加入到lru链表中的情况,这个move_fn就是__pagevec_lru_add_fn():
/* 将lru_add缓存中的页加入到lru链表中 */ static void __pagevec_lru_add_fn(struct page *page, struct lruvec *lruvec, void *arg) { /* 判断此页是否是page cache页(映射文件的页) */ int file = page_is_file_cache(page); /* 是否是活跃的页 * 主要判断page的PG_active标志 * 如果此标志置位了,则将此页加入到活动lru链表中 * 如果没置位,则加入到非活动lru链表中 */ int active = PageActive(page); /* 获取page所在的lru链表,里面会检测是映射页还是文件页,并且检查PG_active,最后能得出该page应该放到哪个lru链表中 * 里面就可以判断出此页需要加入到哪个lru链表中 * 如果PG_active置位,则加入到活动lru链表,否则加入到非活动lru链表 * 如果PG_swapbacked置位,则加入到匿名页lru链表,否则加入到文件页lru链表 * 如果PG_unevictable置位,则加入到LRU_UNEVICTABLE链表中 */ enum lru_list lru = page_lru(page); VM_BUG_ON_PAGE(PageLRU(page), page); SetPageLRU(page); /* 将page加入到lru中 */ add_page_to_lru_list(page, lruvec, lru); /* 更新lruvec中的reclaim_stat */ update_page_reclaim_stat(lruvec, file, active); trace_mm_lru_insertion(page, lru); }
如注释所说,判断页需要加入到哪个lru链表中,主要通过三个标志位:
- PG_active::此标志置位,表示此页需要加入或者处于页所在zone的活动lru链表中,当此页已经在lru链表中时,此标志可以让系统判断此页是在活动lru链表还是非活动lru链表中。
- PG_swapbacked:此标志置位,表示此页可以回写到swap分区,那么此页需要加入或者处于页所在zone的匿名页lru链表中。
- PG_unevictable:置位表示此页被锁在内存中禁止换出,表示此页需要加入或者处于页所在zone的LRU_UNEVICTABLE链表中。
好的,通过这三个标志就能过清楚判断页需要加入到所属zone的哪个lru链表中了,到这里,也能说明,在加入lru缓存前,页必须设置好这三个标志位,表明自己想加入到所属zone的哪个lru链表中。接下来我们看看add_page_to_lru_list()函数,这个函数就很简单了,如下:
/* 将页加入到lruvec中的lru类型的链表头部 */ static __always_inline void add_page_to_lru_list(struct page *page, struct lruvec *lruvec, enum lru_list lru) { /* 获取页的数量,因为可能是透明大页的情况,会是多个页 */ int nr_pages = hpage_nr_pages(page); /* 更新lruvec中lru类型的链表的页数量 */ mem_cgroup_update_lru_size(lruvec, lru, nr_pages); /* 加入到对应LRU链表头部,这里不上锁,所以在调用此函数前需要上锁 */ list_add(&page->lru, &lruvec->lists[lru]); /* 更新统计 */ __mod_zone_page_state(lruvec_zone(lruvec), NR_LRU_BASE + lru, nr_pages); }
这样一个新页加入lru缓存以及加入到lru链表中的代码就已经说完了,切记,并不是只有lru缓存满了,才会将其中的页加入到对应的lru链表中,一些特殊情况会要求lru缓存立即把存着的页加入到lru链表中。
将处于非活动链表中的页移动到非活动链表尾部
主要通过rotate_reclaimable_page()函数实现,这种操作主要使用在:当一个脏页需要进行回收时,系统首先会将页异步回写到磁盘中(swap分区或者对应的磁盘文件),然后通过这种操作将页移动到非活动lru链表尾部。这样这些页在下次内存回收时会优先得到回收。
rotate_reclaimable_page()函数如下:
/* 将处于非活动lru链表中的页移动到非活动lru链表尾部 * 如果页是处于非活动匿名页lru链表,那么就加入到非活动匿名页lru链表尾部 * 如果页是处于非活动文件页lru链表,那么就加入到非活动文件页lru链表尾部 */ void rotate_reclaimable_page(struct page *page) { /* 此页加入到非活动lru链表尾部的条件 * 页当前不能被上锁(并不是锁在内存,而是每个页自己的锁PG_locked) * 页必须不能是脏页(这里应该也不会是脏页) * 页必须非活动的(如果页是活动的,那页如果在lru链表中,那肯定是在活动lru链表) * 页没有被锁在内存中 * 页处于lru链表中 */ if (!PageLocked(page) && !PageDirty(page) && !PageActive(page) && !PageUnevictable(page) && PageLRU(page)) { struct pagevec *pvec; unsigned long flags; /* page->_count++,因为这里会加入到lru_rotate_pvecs这个lru缓存中 * lru缓存中的页移动到lru时,会对移动的页page->_count-- */ page_cache_get(page); /* 禁止中断 */ local_irq_save(flags); /* 获取当前CPU的lru_rotate_pvecs缓存 */ pvec = this_cpu_ptr(&lru_rotate_pvecs); if (!pagevec_add(pvec, page)) /* lru_rotate_pvecs缓存已满,将当前缓存中的页加入到非活动lru链表尾部 */ pagevec_move_tail(pvec); /* 重新开启中断 */ local_irq_restore(flags); } }
实际上实现方式与之前新页加入lru链表的操作差不多,简单看一下pagevec_move_tail()函数和它的回调函数:
/* 将lru缓存pvec中的页移动到非活动lru链表尾部 * 这些页原本就属于非活动lru链表 */ static void pagevec_move_tail(struct pagevec *pvec) { int pgmoved = 0; pagevec_lru_move_fn(pvec, pagevec_move_tail_fn, &pgmoved); __count_vm_events(PGROTATED, pgmoved); } /* 将lru缓存pvec中的页移动到非活动lru链表尾部操作的回调函数 * 这些页原本就属于非活动lru链表 */ static void pagevec_move_tail_fn(struct page *page, struct lruvec *lruvec, void *arg) { int *pgmoved = arg; /* 页属于非活动页 */ if (PageLRU(page) && !PageActive(page) && !PageUnevictable(page)) { /* 获取页应该放入匿名页lru链表还是文件页lru链表,通过页的PG_swapbacked标志判断 */ enum lru_list lru = page_lru_base_type(page); /* 加入到对应的非活动lru链表尾部 */ list_move_tail(&page->lru, &lruvec->lists[lru]); (*pgmoved)++; } }
可以看到与新页加入lru链表操作一样,都是使用pagevec_lru_move_fn()函数进行遍历lru缓存中的页,只是回调函数不同。
将活动lru链表中的页加入到非活动lru链表中
这个操作使用的场景是文件系统主动将一些没有被进程映射的页进行释放时使用,就会将一些活动lru链表的页移动到非活动lru链表中,在内存回收过程中并不会使用这种方式。注意,在这种操作中只会移动那些没有被进程映射的页。并且将活动lru链表中的页移动到非活动lru链表中,有两种方式,一种是移动到非活动lru链表的头部,一种是移动到非活动lru链表的尾部,由于内存回收是从非活动lru链表尾部开始扫描页框的,所以加入到非活动lru链表尾部的页框更容易被释放,而在这种操作中,只会将干净的,不需要回写的页放入到非活动lru链表尾部。
主要是将活动lru链表中的页加入到lru_deactivate_pvecs这个CPU的lru缓存实现,而加入函数,是deactivate_page():
/* 将页移动到非活动lru链表中 * 此页应该属于活动lru链表中的页 */ void deactivate_page(struct page *page) { /* 如果页被锁在内存中禁止换出,则跳出 */ if (PageUnevictable(page)) return; /* page->_count == 1才会进入if语句 * 说明此页已经没有进程进行映射了 */ if (likely(get_page_unless_zero(page))) { struct pagevec *pvec = &get_cpu_var(lru_deactivate_pvecs); if (!pagevec_add(pvec, page)) pagevec_lru_move_fn(pvec, lru_deactivate_fn, NULL); put_cpu_var(lru_deactivate_pvecs); } }
主要看回调函数lru_deactivate_fn():
/* 将处于活动lru链表中的page移动到非活动lru链表中 * 此页只有不被锁在内存中,并且没有进程映射了此页的情况下才会移动 */ static void lru_deactivate_fn(struct page *page, struct lruvec *lruvec, void *arg) { int lru, file; bool active; /* 此页不在lru中,则不处理此页 */ if (!PageLRU(page)) return; /* 如果此页被锁在内存中禁止换出,则不处理此页 */ if (PageUnevictable(page)) return; /* Some processes are using the page */ /* 有进程映射了此页,也不处理此页 */ if (page_mapped(page)) return; /* 获取页的活动标志,PG_active */ active = PageActive(page); /* 根据页的PG_swapbacked判断此页是否需要依赖swap分区 */ file = page_is_file_cache(page); /* 获取此页需要加入匿名页或者文件页lru链表,也是通过PG_swapbacked标志判断 */ lru = page_lru_base_type(page); /* 从活动lru链表中删除 */ del_page_from_lru_list(page, lruvec, lru + active); /* 清除PG_active和PG_referenced */ ClearPageActive(page); ClearPageReferenced(page); /* 加到非活动页lru链表头部 */ add_page_to_lru_list(page, lruvec, lru); /* 如果此页当前正在回写或者是脏页 */ if (PageWriteback(page) || PageDirty(page)) { /* 则设置此页需要回收 */ SetPageReclaim(page); } else { /* 如果此页是干净的,并且非活动的,则将此页移动到非活动lru链表尾部 * 因为此页回收起来更简单,不用回写 */ list_move_tail(&page->lru, &lruvec->lists[lru]); __count_vm_event(PGROTATED); } /* 统计 */ if (active) __count_vm_event(PGDEACTIVATE); update_page_reclaim_stat(lruvec, file, 0); }
可以看到3个重点:1.只处理没有被进程映射的页。2.干净的页放入到非活动lru链表尾部,其他页放入到非活动lru链表头部。3.如果页是脏页或者正在回写的页,则设置页回收标志。
将非活动lru链表的页加入到活动lru链表
还有最后一个操作,将活动lru链表的页加入到非活动lru链表中,这种操作主要在一些页是非活动的,之后被标记为活动页了,这时候就需要将这些页加入到活动lru链表中,这个操作一般会调用activate_page()实现:
/* smp下使用,设置页为活动页,并加入到对应的活动页lru链表中 */ void activate_page(struct page *page) { if (PageLRU(page) && !PageActive(page) && !PageUnevictable(page)) { struct pagevec *pvec = &get_cpu_var(activate_page_pvecs); page_cache_get(page); if (!pagevec_add(pvec, page)) pagevec_lru_move_fn(pvec, __activate_page, NULL); put_cpu_var(activate_page_pvecs); } }
我们直接看回调函数__activate_page():
/* 设置页为活动页,并加入到对应的活动页lru链表中 */ static void __activate_page(struct page *page, struct lruvec *lruvec, void *arg) { if (PageLRU(page) && !PageActive(page) && !PageUnevictable(page)) { /* 是否为文件页 */ int file = page_is_file_cache(page); /* 获取lru类型 */ int lru = page_lru_base_type(page); /* 将此页从lru链表中移除 */ del_page_from_lru_list(page, lruvec, lru); /* 设置page的PG_active标志,此标志说明此页在活动页的lru链表中 */ SetPageActive(page); /* 获取类型,lru在这里一般是lru_inactive_file或者lru_inactive_anon * 加上LRU_ACTIVE就变成了lru_active_file或者lru_active_anon */ lru += LRU_ACTIVE; /* 将此页加入到活动页lru链表头 */ add_page_to_lru_list(page, lruvec, lru); trace_mm_lru_activate(page); __count_vm_event(PGACTIVATE); /* 更新lruvec中zone_reclaim_stat->recent_scanned[file]++和zone_reclaim_stat->recent_rotated[file]++ */ update_page_reclaim_stat(lruvec, file, 1); } }
到这里所有对lru链表中页的操作就说完了,对于移除操作,则直接移除,并且清除页的PG_lru标志就可以了。需要切记,只有非内存回收的情况下对lru链表进行操作,才需要使用到这些lru缓存,而而内存回收时对lru链表的操作,大部分操作是不需要使用这些lru缓存的(只有将隔离的页重新加入lru链表时会使用)。
lru链表的更新
我们知道,lru链表是将相同类型的页分为两个部分,一部分是活动页,一部分是非活动页,而具体的划分方法,就是看页最近是否被访问过,被访问过则是活动页,没被访问过则是非活动页,这样看来,每当一个页被访问了,是不是都要判断这个页是否需要移动到活动lru链表?一个页久不被访问了,是不是要将这个页移动到非活动lru链表?实际上不是的,之前也说了很多遍,lru链表是专门为内存回收服务的,在内存回收没有进行之前,lru链表可以说是休眠的,系统可以将页加入到lru链表中,也可以将页从lru链表中移除,但是lru链表不会更新哪些没被访问的页需要移动到非活动lru链表,哪些经常被访问的页移动到活动lru链表。只有当进行内存回收时,lru链表才会开始干这件事。
这样就会涉及到一个问题,由于页被访问时,访问了此页的进程对应此页的页表项中的Accessed会置位,表面此页被访问了,而lru链表只有在进行内存回收时才会进行判断,那就会有一种情况,在一个小时之内,内存空闲页富足,这一个小时中都没有发生内存回收,而这一个小时中,所有进程使用的内存页都进行过了访问,也就是每个页反向映射到进程页表项中总能找到有进程访问过此页,这时候内存回收开始了,lru链表如何将这些页判断为活动页还是非活动页?可以说,在这种情况,第一轮内存回收基本上颗粒无收,因为所有页都会被判定为活动页,但是当第二轮内存回收时,就可以正常判断了,因为每一轮内存回收后,都会清除所有访问了此页的页表项的Accessed标志,在第二轮内存回收时,只有在第一轮内存回收后与第二轮内存回收开始前被访问过的页,才会被判断为最近被访问过的页。以匿名页lru链表进行说明,如下图:
开始内存回收前,所有加入的页都标记了被访问。
第一轮内存回收后,清空所有页的被访问标记。
在第二轮内存回收开始前,有些页又被访问了。
将最近被访问的页移动到活动匿名页lru链表,最近没被访问的页移动到非活动匿名页lru链表,由于非活动匿名页lru链表长度是固定的,所有多出来的页会放入到活动匿名页lru链表中。可以说,这个最近最少使用页链表,我个人认为更明确的叫法应该算是内存回收时最近最少使用页链表。
这里说到lru链表的长度,匿名页lru链表长度与文件页lru链表长度是不同的,对于匿名页lru链表长度,其经验公式如下:
/* 经验公式ratio等于 根号(10 * 管理区内存以GB为大小的数量): * zone中 非活动匿名页lru链表 * 总内存大小 包含的所有页的总大小 * ------------------------------------- * 10MB 5MB * 100MB 50MB * 1GB 250MB * 10GB 0.9GB * 100GB 3GB * 1TB 10GB * 10TB 32GB */
而对于文件页lru链表来说,就没有那么复杂,只要求非活动文件页lru链表长度必须要大于活动文件页lru链表长度。
也如之前所说,lru链表在没有进行内存回收时,几乎是休眠的,也就是说,当没有进行内存回收时,链表中页的数量超过以上要求的lru链表长度都没有问题,以匿名页lru链表为例,当前zone管理着1GB的内存,根据经验公式,此zone的非活动匿名页lru链表中页的总内存量最多为250MB,而当前此zone的非活动匿名页lru链表包含的页的总内存量为220MB,这时候一个进程进行了100MB的shmem共享内存,这100MB全部来自于此zone,这时候这些页被加入到了此zone的非活动匿名页lru链表,此时,此zone的非活动匿名页包含的页的总内存量为320MB,超过了经验公式的250MB,但是这并不会造成匿名页lru链表的调整,只有当内存不足时导致内存回收了,在内存回收中才会进行匿名页lru链表的调整,让非活动匿名页lru链表包含的页降低,总内存量降低到250MB以下。同理,对于文件页lru链表也是一样。
不同类型的页加入
活动lru链表是存放最近被访问的页框,而进程刚申请的一个新页,按理来说最近肯定是被访问过了,应该加入到活动lru链表中,但是情况并不是这样,之前也说过,lru链表是专为内存回收服务的,系统希望在内存回收过程中不同类型的页应该有不同的回收优先级,有些类型的页,系统希望优先回收,而有些类型的页,系统希望慢点回收。而我们知道,内核的内存回收是从非活动lru链表末尾开始向前扫描其中的每一个页,它并不会去扫描活动lru链表,只有当非活动lru链表中的页数量不满足要求时,会从活动lru链表中移动一些页到非活动lru链表中,也就是,加入到非活动lru链表的页,是更有可能优先被内核进行回收的。因此,由于不同类型的页在内存回收中有不同的优先级,导致不同类型的新页加入到lru链表时会不同,如下就是最近总结出来的:
- 进程堆、栈、数据段中使用的新匿名页:加入到对应zone的 活动匿名页lru链表
- shmem共享内存使用的新页:加入到对应zone的 非活动匿名页lru链表
- 私有匿名mmap共享内存使用的新页:加入到对应zone的 非活动匿名页lru链表
- 新的映射磁盘文件数据的文件页:加入到对应zone的 非活动文件页lru链表
- 使用文件映射mmap共享内存使用的新页:加入到对于zone的 非活动文件页lru链表
由于能力有限,没能总结出直接加入到活动文件页lru链表中的新页,但是这种页是存在的。
需要注意,这些页并不是在创建的时候就会生成,需要考虑写时复制。
转自: