linux内存管理-反向映射

反向映射的需求

正向映射是通过虚拟地址根据页表找到物理内存,反向映射就是通过物理地址找到哪些虚拟地址使用它。
什么时候需要进行反向映射呢?在页面回收的时候,在还没有修改完所有引用该页帧的页表项之前是不可以将页帧swap到硬盘上。没有修改页表项但是物理页已经swap out了并且再次分配给其他申请者了,此时再次访问那访问的可能是其他上下文的数据,如果仅仅是脏数据也还好,但是如果访问到的是内核的数据或者其他进程的数据,这个问题就有点严重了。所以在回收页面的时候必须更改页表项,再次访问的时候能够识别这种场景,按需重新加载数据。

下面根据LWN上相关的news来看一下反向映射的发展历史

2.4内核上的反向映射

Virtual Memory I: the problem
https://lwn.net/Articles/75198/
32bit平台上它的寻址决定了地址范围0-4G,虽然可以扩展指令来寻址更多地址空间,但是那样搞会极其影响性能和兼容性,所以基本没人这样搞。linux中有地址空间隔离的功能,也就是虚拟地址和物理地址,都是4G大小。虚拟地址空间一般是进行3:1划分,其中0-3G给用户空间使用,3-4G给内核空间使用,所有用户进程共享同一个内核空间,这样在内核中同一个进程不同的上下文不必切换页表,也就是lazy TLB,这样会减少很多的TLB miss。

开启MMU之后,内核需要创建相关的页表来访问物理内存。默认的虚拟地址空间划分3GB/1GB,其中一部分内核地址需要留给内核代码段,数据段,vmalloc使用,所以内核最多能直接访问的不超过1G。这也是为什么32平台上连1G内核都不能直接使用的原因。在1999年,linus说:32位linux永远不会,也不需要支持大于2GB的内存,没啥好商量的。

但是整个行业的趋向仍然是支持越来越大的内存,芯片厂商在硬件上增加了寻址扩展功能能够访问超过4GB的内存,但是内核中的软件限制仍然是没有改变的。不过很快Linus认识到大内存是趋势,所以在2.3内核上允许了大内存支持,不过这样搞有额外的消耗和限制。

在32bit系统上,物理内存被分为high和low(低端)两部分。Low(低端)内存是可以直接映射到内核地址空间,并且是永久映射的,在任意时间都可以直接通过内核空间指针访问。而高端内存没有直接的映射,在需要使用的时候需要显式的设置映射关系到内核地址空间,不再使用的时候解除映射。这个操作代价比较高,而且在同一时间使用的高端内存页是有限制的。

内核自己的数据结构必须放在低端内存中,非永久映射的对象都不能挂到链表上,因为它的虚拟地址是多变的。高端内存经常用来给进程或者一些内核的task使用,例如IO操作中使用的buffer,内核自身使用的必须是位于低端内存。

一些32bit平台上可以寻址64GB的物理内存,但是linux内核处理这种场景仍然比较低效,之后的问题是在大内存系统上非常容易消耗光低端内存:内存变大,它需要更多的内核数据管理结构,尤其是struct page数组,每一个物理页帧都需要一个page元数据,这玩意很容占几百M的低端内存。

在2.4版本中,内核通过扫描所有的进程页表项,每次扫描一个进程找到引用该页的页表项,如果找到了所有的项就可以将页帧刷入到磁盘中。

有一些用户想要在32bit系统上支持32GB或者更多的内存,所以Linux企业发行版都会在这方面做出尝试。一个是大佬Ingo Molnar写的 4G/4G patch ,这个patch分开了内核和用户地址空间,允许用户进程访问4G的虚拟地址空间,内核可以直接使用的低端内存扩展到了4G。不过这样有个缺点,内核和用户进程各自都有自己的页表,所以每次系统调用进入和退出都需要flush TLB,性能下降的很厉害,达到了30%。这样可以让一些大内存机器work,Red Hat就发布了这种企业版。
它虽然扩展了内核对大内存的兼容性,但是并不是很流行,被认为是一个ugly的解决方案,没人接受这种性能下降。所以需要其他方案来支持大内存,一直到2.6开发阶段,内核开发者还是不愿意为了一部分土豪用户的需求来强制要求32bit系统做他本来做不了的事情,Linus对此一锤定音,要想大内存等64bit系统吧。

第一代反向映射:直接反向映射

在2.5版本中第一次添加反向映射功能,用来解决找到所有引用某个物理页帧的所有页表项问题,最典型的就是swap out需要修改对应的所有页表内容,在还没有修改完所有引用该页帧的页表项之前是不可以将页帧swap到硬盘上。

创建了一个新的数据结构来简化这个过程,它相当直接,为系统中每个页帧(struct page)创建一个反向映射项数组链表,包括匿名页和文件映射页,里面包含所有引用该页的页表映射项地址。

struct page {
    union {
        struct pte_chain *chain;/* Reverse pte mapping pointer */
        pte_addr_t direct;   //不共享的页面直接在这保存pte地址,不需要遍历pte_chain                                                                                                                  
    } pte;
}
/*  
 * next_and_idx encodes both the address of the next pte_chain and the
 * offset of the highest-index used pte in ptes[].
 */
这个结构占据cache line大小,地址是cache line对齐的,所以低位可以用来标识ptes[]数组中的起始index,节省点循环时间
struct pte_chain {
    unsigned long next_and_idx;                                                                                                            
    pte_addr_t ptes[NRPTE];
} ____cacheline_aligned;

不共享的页面可以使用direct直接指向pte entry就OK了。页面共享时,chain则会指向一个struct pte_chain的链表,只需要遍历pte_chain解除映射就OK了。

不过在整个过程中还做了一项不起眼但是非常重要的工作,刷TLB。当页表改变之后如果正好是当前进程正在使用这个页表,这时候需要flush TLB,无效这个页表项,但是清空所有的TLB代价比较大,所以它检查了一下修改的页表项是否是当前正在使用的。

下图展示了从pte到对应的vma的过程:
pte_chain

  1. pte_chain中存储的引用该页的pte地址
  2. 根据pte地址进行页对齐,找到对应的页帧号和page
  3. page的mapping指向对应进程mm_struct,index代表了虚拟地址(页对齐)的偏移,寻找vma
  4. 比较当前进程的mm_struct和正在修改的mm_struct,如果是同一个则需要flush对应的tlb项,如果不是就什么都不要做

文件页的反向映射

https://lwn.net/Articles/23732/

第一代直接反向映射的方式很容易理解,但是它引入了一些其他的问题,反向映射项占据了太多的内存,并且需要额外的消耗来维护这些关系。那些需要大量页申请、释放、拷贝的操作会被减慢,在fork()系统调用的时候,必须为进程地址空间中每个页帧添加反向映射项链表,这个速度太慢了。
Dave McCracken提出了一个新的patch来解决这个问题,称之为"object-based reverse mapping"(基于对象的反向映射),这里的对象是“文件”,解决了文件映射页的反向映射关系查找。

用户空间使用的页有两种基本类型:匿名页和文件页,其中文件映射页和文件系统中某个文件相关联,一般包括进程的代码段、通过mmap映射的文件页,这部分页可以不需要通过直接反向映射表项就能找到所有的页表项。
file-backed page rmap
每个页帧对应的struct page结构,它有一个成员mapping,当页是通过文件映射的时候,它指向address_space,包含有文件对应的inode信息和其他的管理数据信息,其中的两个双向链表 (i_mmap和i_mmap_shared) 包含了映射该文件的所有的vm_area_struct,后者是共享映射方式的页,例如mmap(MAP_SHARED)操作建立的页。vm_area_struct描述了一个区间的进程地址空间的信息,通过/proc/pid/maps可以看到进程的所有的VMA。而通过VMA可以找到特定的页映射到进程的地址空间,这样就可以找到对应的页表项。

page reverse mapping

  1. 文件映射页的page->mapping指向address_space,其中的两个链表i_mmap和i_mmap_shared挂有映射该页的vma对象
  2. page->index代表在文件中的偏移,而vma->vm_pgoff是映射文件时的offset,即这块vma区间的起始地址对应的文件偏移,通过mmap(...int fd, off_t offset)指定了文件的偏移,根据page->index计算出物理页在这个进程中的虚拟地址
inline unsigned long
vma_address(struct page *page, struct vm_area_struct *vma)
{
    pgoff_t pgoff = page->index << (PAGE_CACHE_SHIFT - PAGE_SHIFT);
    unsigned long address;
    address = vma->vm_start + ((pgoff - vma->vm_pgoff) << PAGE_SHIFT);
    return address;
}
  1. 根据虚拟地址address找到对应的pte项

文件页的反向映射就通过这条路径来处理了,这条路径比直接的页表项指针要长,但是它肯定内存消耗会小很多,不需要维护额外的pte_chain信息。但是匿名页没有对应的address_space结构,所以object-based(基于文件对象)的反向映射处理不了匿名页,所以这段事件是两种方式共存:直接反向映射来处理匿名页,基于对象的反向映射处理文件页

匿名页的反向映射

https://lwn.net/Articles/77106/

后来Andrea Arcangeli提交了另外的patch来处理匿名页的反向映射问题,原来因为匿名页没有文件对象,所以复用不了address_space的那套东西嘛,所以他发明了一个新的对象来代替address_space的作用,通过复用struct page->mapping来指向一个anon_vma,它上面挂了所有可能共享该区域的VMA。

一个进程通过malloc来申请内存,随后分配物理页,也就是匿名页,在创建第一个区域的时候从来都不会是共享的,所以对于一个新的匿名页不需要反向映射的链,此时它是进程私有的。Andrea的patch通过在struct page中添加一个union来共用mapping指针,称之为vma,指向一个单独的VMA结构,如果一个进程中在同一个VMA中有几个私有的匿名页,他们的关系就像这样(我没见过这个版本的代码哈):
anon_vma
通过这个结构,内核可以通过vma结构体找到给定页帧对应的页表。

当进程开始fork之后,事情会变得复杂,一旦fork之后,父子进程都有页表指向相同的匿名页,单个的VMA指针不再能满足需求。所以Andread创建了一个新的anon_vma结构来管理VMA的链表关系。struct page的union的第三个对象就指向了anon_vma,里面主要是一个双链表,所有可能包含该页帧的vma都在上面,现在看起来是下面这样:
anon_vma如果内核需要unmap这样的页帧,它需要遍历anon_vma的链表,测试它找到的每一个vma。一旦所有的页表都已经unmap之后,页帧就可以释放了。

这种方案也需要一些额外的内存消耗:VMA结构需要增加一个新的list_head对象,当页开始共享的时候需要分配一个新的anon_vma结构。一个VMA可以跨越几千个页帧,所以相对于每个page中的反向映射,VMA中的消耗也不算什么了。
anon_vma数据结构
这种方式和文件页的反向映射非常相似,page->index表示的虚拟地址(页对齐),vma->vm_pgoff一般是0,不同的只有address_space换成了anon_vma,怎么查找到页表项的过程就不再重复了。

这种方法会增加很多的计算量。释放一个页帧需要扫描多个VMA,这些VMA可能包含也可能不包含对页帧的引用。这些计算量会随着一块内存区域被更多进程共享而增加,这些问题在还没有合并的时候就已经提出了,但是并不是如何严重,所以everything is OK。
anon_vma的问题

  1. 初始状态,进程1通过malloc申请了一片区域,此时有VMA和一个anon_vma,世界如此美好哈
  2. 进程开始fork出了很多进程,此时COW机制的存在,所有进程都有自己的vma但是共享同一片物理内存,在父进程的anon_vma链接有所有子进程的vma。
    这里他假设的是anon_vma和address_space是等同的,同一个文件的映射页归根结底都需要访问同一个inode和address_space,它这里将父进程初始的vma区域看做一个文件,子进程相同的vma映射的也是这个文件,即vma对应的物理页是相同的。很可惜不是,在进行写操作的时候就开始分裂了,大家只是虚拟地址区间看起来是一样的,其实已经分道扬镳了。
  3. 对于vma可以进行扩张、分裂,图3中是经过一系列vma的变形之后,现在父进程和子进程的vma和初始的vma已经没有交集了,但是他们还挂在父进程的anon_vma中。swap out父进程或子进程vma对应的物理页时仍然会遍历两个进程的vma,这完全是冗余的。

匿名页反向映射的改进

The case of the overly anonymous anon_vma

随着技术的发展,匿名页反向映射的缺点暴露的越来越大,Rik van Riel对于匿名页的反向映射提出新的改进patch,在2.6.34内核版本中合并改进后的patch。他描述了这样一种场景中老版本的糟糕表现:

In a workload with 1000 child processes and a VMA with 1000 anonymous pages per process that get COWed, this leads to a system with a million anonymous pages in the same anon_vma, each of which is mapped in just one of the 1000 processes. However, the current rmap code needs to walk them all, leading to O(N) scanning complexity for each page. 

老版本的匿名页反向映射,通过组织所有的匿名页到同一个父进程的同一个anon_vma结构中,在需要知道反向映射一个页的信息时需要每次都遍历这个链表,这样就会有一个问题,持有同样一个锁取扫描大量的VMA,特别是有些物理页没有被其中的一些VMA引用。所以新版的改进主要是AV和VMA的关系维护,减少冗余VMA的扫描。

AVanon_vmaAVCanon_vma_chain结构,VMAvm_area_struct,下面的简写不再说明。

Rik的方法是为每个进程创建一个AV结构,把他们链接到一起而不是VMA对象,这样当COW之后分裂页的时候将page->mapping指向自己进程的AV,而不是所有子进程新的页再共享父进程的anon_vma。这个链接关系通过一个AVC的对象来完成,它充当anon_vma和vma的连接结构,里面两个链表能把人绕晕了:

    struct anon_vma_chain {
	struct vm_area_struct *vma;
	struct anon_vma *anon_vma;
	struct list_head same_vma;		//以vma为链表头,链表上所有的avc->anon_vma都共享同一个vma,当进程解除映射即删除vma时,需要遍历vma上所有的AVC,进而找到AVC->anon_vma对齐进行解引用操作,最后anon_vma里的链表为空时释放AV对象
	struct list_head same_anon_vma;	//以anon_vma为主,该链表上的所有anon_vma_chain->vma共享同一个anon_vma,在查找页反向映射关系的时候遍历anon_vma->head上所有的AVC,进而找到对应的vma和页表项
    };

每个anon_vma_chain维护两个链表:same_vma和same_anon_vma,下面简要说明一下fork时和之后的页面分裂这几个对象的关系。

  1. 初始化状态,通过execv启动了一个全新的进程,程序通过malloc申请了一片内存,内核中为其分配一个匿名的VMA区域。蓝色的线代表same_anon_vma链表,红色的箭头代表same_vma链表,黑色的箭头代表指针
    在这里插入图片描述
  2. 当进程fork时, 为子进程分配新的vma,这个是fork必须的,每个进程都需要有自己的虚拟地址管理结构,只是和父子进程的VMA的vm_start,vm_end,vm_flags,vm_page_prot等属性基本完全一致;之后会关联父子进程的AVC,AV,VMA对象,主要是anon_vma_fork中完成
do_fork -> copy_mm ->dup_mm->dup_mmap->anon_vma_fork

在这里插入图片描述

创建一个新的AVC,连接父进程的AV和子进程的VMA,分别有指针指向父进程的AV和子进程的VMA,并且挂到父进程的AV中。此时如果有对应的匿名页,它的page->mapping是指向父进程的AV,通过遍历父进程AV中的链表,每个对象都是AVC,通过其中的指针vma找到所有引用它的VMA,之后的过程和老版的匿名页的反向映射过程查找就完全一致了。

注意,新的AVC已经被添加到蓝色的链表中,代表所有的VMA都引用同一个anon_vma结构。
在这里插入图片描述子进程的VMA需要它自己的AV,这样当为子进程分配新的页时,新的页page->mapping将指向它自己的AV,这样在寻找page时就不再需要父进程的AV,也不会遍历父进程中的VMA,子进程的AV的root指向父进程的AV。
为子进程分配新的AVC,这样当子进程fork时,子进程的子进程(简称孙子进程)的AVC就可以管理孙子进程的共享映射关系。现在有两个AVC,一个是充当父子进程间AV和VMA的桥梁,另一个是管理本进程中AV和VMA的关系。
在这里插入图片描述
当子进程再fork时,看起来更加复杂了,不过它还是为了解决两种主要的场景:遍历反向映射关系根据AV遍历所有的VMA,解除VMA映射时更新对应的AV
在这里插入图片描述
上面描述的过程只有虚拟空间和VMA的关系,它又是如何解决老版的反向映射的问题呢?即如何减少VMA的冗余扫描

通过malloc申请了虚拟地址,但是不一定有对应的物理内存分配,这时候分为两种情况:
1、父进程中没有匿名页
当父进程中也没有分配页的时候,写操作会为其真正分配物理页,这时候很简单,do_anonymous_page->page_add_new_anon_rmap->__page_set_anon_rmap,新的页page->mapping指向父进程中的AV。此时扫描该页反向映射关系仍然需要扫描它子进程,孙子进程的VMA,这点上面仍然没有任何的改进。

2、父进程中已经分配匿名页,在fork时COW使得他们共享只读的匿名页,当一方进行写操作的时候触发异常进行页面分裂
父进程中已经分配匿名页,当fork时,一个vma中所有的匿名页都指向父进程的anon_vma对象。当父进程对页数据进行写操作时触发COW,新的页会指向父进程的AV,和上面的情况相同。
当子进程最早对页数据进行写操作时触发COW,新的页会指向子进程的AV,此时这个页的反向映射关系只包含它和它衍生的子进程,孙子进程,不包含兄弟进程和父进程。

再来回头看一下Rik提出的问题

In a workload with 1000 child processes and a VMA with 1000 anonymous pages per process that get COWed,
 this leads to a system with a million anonymous pages in the same anon_vma, each of which is mapped in just 
 one of the 1000 processes. However, the current rmap code needs to walk them all, leading to O(N) scanning 
 complexity for each page. 

目前改进的方案解决的问题是:一个进程fork了999个进程,其中的一个VMA包含1000个匿名页,按照老版的,每个页的反向映射都需要遍历1000个VMA(父进程和它的子进程们),总共需要100万次的遍历,而且他们需要相同的AV锁,造成的锁竞争问题。

改进的反向映射patch下,页的反向映射状况

  1. 父子进程共享只读匿名页的情况仍然需要扫描所有进程的VMA,这种情况下是没有问题的;
  2. 但是父进程新分配的匿名页也需要扫描所有进程的VMA,这种情况下就有点没有必要的,很明显新的匿名页子进程不再共享;
  3. 子进程新分配的匿名页,它的反向映射关系不再需要扫描父进程和子进程的VMA,不过存在的问题和2相同。

从上面看,改进的patch确实解决了百万匿名页反向映射遍历的问题,不过还存在一些冗余的扫描。问题在于AV是和VMA对等的,它代表的是一段空间的共享映射问题,也就是多个page共享一个AV,这个前提如果不改变,这个问题可能就解决不了。相对于每个页级别中pte_chain的精确反向映射关系,基于对象的反向映射节省了内存,不过牺牲了精度。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章