本文所述是针对 linux 引入了虚拟内存管理机制以后所涉及的知识点。linux 中页缓存的本质就是对于磁盘中的部分数据在内存中保留一定的副本,使得应用程序能够快速的读取到磁盘中相应的数据,并实现不同进程之间的数据共享。
因此,linux 中页缓存的引入主要是为了解决两类重要的问题:
- 磁盘读写速度较慢(ms 级别);
- 实现不同进程之间或者同一进程的前后不同部分之间对于数据的共享;
如果没有进程之间的共享机制,那么对于系统中所启动的所有进程,在打开文件的时候都要将需要的数据从磁盘加载进物理内存空间,这样不仅导致加载速度变慢,而且造成了物理内存的浪费。为了解决以上问题,linux 操作系统使用了缓存机制。
在虚拟内存机制出现以前,操作系统使用块缓存机制,但是在虚拟内存出现以后,操作系统管理 IO 的粒度更大,因此采用了页缓存机制。此后,和后备存储的数据交互普遍以页为单位。
页缓存是基于页的、面向文件的一种缓存机制。
以上只是对页缓存的重要性做了介绍。但是,还有三个问题还没有解释:
- 页缓存究竟是如何实现,其和文件系统是如何关联的?
- 页缓存、内存以及文件 IO 之间的关系是怎样的?
- 页缓存中的数据如何实现和后备存储之间的同步?
一、页缓存的实现
既然页缓存是以页为单位进行数据管理的,那么必须在内核中标识该物理页。其实每个真正存放数据的物理页帧都对应一个管理结构体,称之为 struct page
,其结构体如下。
struct page {
unsigned long flags;
atomic_t _count;
atomic_t _mapcount;
unsigned long private;
struct address_space *mapping;
pgoff_t index;
struct list_head lru;
void* virtual;
};
成员 | 描述 |
---|---|
flags | 描述 page 当前的状态和其他信息,如当前的 page 是否是脏页 PG_dirty;是否是最新的已经同步到后备存储的页 PG_uptodate; 是否处于 lru 链表上等 |
_count | 引用计数,标识内核中引用该 page 的次数。如果要操作该 page,引用计数会 +1,操作完成之后 -1。当该值为 0 时,表示没有引用该 page 的位置,所以该 page 可以被解除映射,这在内存回收的时候是有用的。 |
_mapcount | 页表被映射的次数。也就是说 page 同时被多少个进程所共享,初始值为 -1,如果只被一个进程的页表映射了,该值为 0。 |
private | 私有数据指针 |
mapping | 它有三种含义: 1. 如果 mapping = 0,说明该 page 属于交换缓存(swap cache); 当需要地址空间时会指定交换分区的地址空间 swapper_space; 2. 如果 mapping != 0, bit[0] = 0, 说明该 page 属于页缓存或者文件映射,mapping指向文件的地址空间address_space; 3. 如果mapping != 0, bit[0] != 0 说明该 page 为匿名映射,mapping 指向 struct anon_vma 对象; |
index | 在映射的虚拟空间(vma_area)内的偏移;一个文件可能只是映射了一部分,假设映射了 1M 的空间,那么 index 指的是 1M 空间内的偏移,而不是在整个文件内的偏移 |
lru | 当 page 被用户态使用或者是当做页缓存使用的时候,将该 page 连入 zone 中的 lru 链表,供内存回收使用 |
注意区分 _count 和 _mapcount。_mapcount 表示的是被映射的次数,而 _count 表示的是被使用的次数;被映射了不一定被使用,但是被使用之前肯定要先被映射。
页缓存就是将一个文件在内存中的所有物理页所组成的一种树形结构,我们称之为基数树,用于管理属于同一个文件在内存中的缓存内容。
如上所述,一个文件在内存中对应的所有物理页组成了一棵基数树。而一个文件在内存中具有唯一的 inode 结构标识,inode 结构中有该文件所属的设备及其标识符,因而,根据一个 inode 能够确定其对应的后备设备。
为了将文件在物理内存中的页缓存和文件及其后备设备关联起来,linux 内核引入了 address_space 结构体。可以说 address_space 结构体是将页缓存和文件系统关联起来的桥梁,其组成如下:
struct address_space {
struct inode* host; /*指向与该address_space相关联的inode节点*/
struct radix_tree_root page_tree; /*所有页形成的基数树根节点*/
spinlock_t tree_lock; /*保护page_tree的自旋锁*/
unsigned int i_map_writable; /*VM_SHARED的计数*/
struct prio_tree_root i_map;
struct list_head i_map_nonlinear;
spinlock_t i_map_lock; /*保护i_map的自旋锁*/
atomic_t truncate_count; /*截断计数*/
unsigned long nrpages; /*页总数*/
pgoff_t writeback_index; /*回写的起始位置*/
struct address_space_operation* a_ops; /*操作表*/
unsigned long flags; /*gfp_mask掩码与错误标识*/
struct backing_dev_info* backing_dev_info; /*预读信息*/
spinlock_t private_lock; /*私有address_space锁*/
struct list_head private_list; /*私有address_space链表*/
struct address_space* assoc_mapping; /*相关的缓冲*/
}
成员 | 描述 |
---|---|
host | 指向与该 address_space 相关联的 inode 节点,inode 节点与 address_space 之间是一一对应关系 |
struct radix_tree_root | 指向的 host 文件在该内存中映射的所有物理页形成的基数树的根节点,参考博客 |
struct prio_tree_root | 与该地址空间相关联的所有进程的虚拟地址区间 vm_area_struct 所对应的整个进程地址空间 mm_struct 形成的优先查找树的根节点; vm_area_struct 中如果有后备存储,则存在 prio_tree_node 结构体,通过该 prio_tree_node 和 prio_tree_root 结构体,构成了所有与该 address_space 相关联的进程的一棵优先查找树,便于查找所有与该 address_space 相关联的进程 |
下面列出 struct prio_tree_root 和 struct prio_tree_node的结构体。
struct prio_tree_root {
struct prio_tree_node* prio_tree_root;
unsigned short index_bits;
};
struct prio_tree_node {
struct prio_tree_node* left;
struct prio_tree_node* right;
struct prio_tree_node* parent;
unsigned long start;
unsigned long last;
};
为了便于形成页缓存、文件和进程之间关系的清晰思路,如图。
从上可以看出,address_space 成为构建页缓存和文件、页缓存和共享该文件的所有进程之间的桥梁。
每个进程的地址空间使用 mm_struct
结构体标识,该结构体中包含一系列的由 vm_area_struct
结构体组成的连续地址空间链表。每个 vm_area_struct 中存在 struct file* vm_file
用于指向该连续地址空间中所打开的文件,而 vm_file 通过 struct file
中的 struct path
与 struct dentry
相关联。 struct dentry 中通过 inode 指针指向 inode,inode 与 address_space 一一对应,至此形成了页缓存与文件系统之间的关联。
为了便于查找与某个文件相关联的所有进程,address_space 中的 prio_tree_root 指向了所有与该页缓存相关联的进程所形成的优先查找树的根节点。
这里需要说明的一点是:linux 中的文件系统,内核为每个进程在其地址空间中都维护了结构体 struct* fd_array[]
用于维护该进程地址空间中打开的文件的指针;同时内核为所有被打开的文件还维护了系统级的一个文件描述符表用以记录该系统打开的所有文件,供所有进程之间共享;每个被打开的文件都由一个对应的inode结构体表示,由系统级的文件描述符表指向。所以,进程通过自己地址空间中的打开文件描述符表可以找到系统级的文件描述符表,进而找到文件。
二、页缓存、内存、文件 IO 之间的关系
关于文件 IO 我们常说的两句话“普通文件 IO 需要复制两次,内存映射文件 mmap 只需要复制一次”。
下面对普通文件 IO 做详细的解释。文章对页缓存和文件 IO 做了详细的介绍,不过都是英文的。
2.1 读
假设系统中现在存在一个名为 render 的进程,该进程打开了文件 scene.dat,并且每次读取其中的 512B(一个扇区的大小),将读取的文件数据放入到堆分配的块中(每个进程自己的地址空间对应的物理内存)。先以普通 IO 为例介绍一下读取数据的过程,如图(来源于该文章)。
进程发起读请求的过程如下:
- 进程调用库函数 read() 向内核发起读文件的请求;
- 内核通过检查进程的文件描述符定位到虚拟文件系统已经打开的文件列表项,调用该文件系统对 VFS 的 read() 调用提供的接口;
- 通过文件表项链接到目录项模块,根据传入的文件路径在目录项中检索,找到该文件的 inode;
- inode中,通过文件内容偏移量计算出要读取的页;
- 通过该 inode 的 i_mapping 指针找到对应的 address_space 页缓存树(基数树),查找对应的页缓存节点;
- 如果页缓存节点命中,那么直接返回文件内容;
- 如果页缓存缺失,那么产生一个缺页异常,首先创建一个新的空的物理页框,通过该 inode 找到文件中该页的磁盘地址,读取相应的页填充该页缓存(DMA 的方式将数据读取到页缓存),更新页表项;重新进行第 5 步的查找页缓存的过程;
- 文件内容读取成功;
也就是说,所有的文件内容的读取(无论一开始是命中页缓存还是没有命中页缓存)最终都是直接来源于页缓存。
当将数据从磁盘复制到页缓存之后,还要将页缓存的数据通过 CPU 复制到 read 调用提供的缓冲区中,这就是普通文件 IO 需要的两次复制数据复制过程。其中第一次是通过 DMA 的方式将数据从磁盘复制到页缓存中,本次过程只需要 CPU 在一开始的时候让出总线、结束之后处理 DMA 中断即可,中间不需要 CPU 的直接干预,CPU 可以去做别的事情;第二次是将数据从页缓存复制到进程自己的的地址空间对应的物理内存中,这个过程中需要 CPU 的全程干预,浪费 CPU 的时间和额外的物理内存空间。
假如读取了 12KB 的数据之后,那么 render 进程的堆地址空间和相关的地址空间如图所示。
看起来该过程很简单,但是这其中存在着很多的知识点
首先,render 使用了常规的 read() 系统调用读取了 12KB 的数据,现在 scene.dat 中三个大小为 4KB 的页也存在于页缓存中,就像先前所说的所有的文件 IO 都是通过页缓存进行的。在 X86 架构的 linux 体系中,内核以 4KB 大小的页为单位组织文件中的数据,所以即使你从一个文件中仅仅读取几个字节的数据,那么包含这些字节的整个页的数据都会从硬盘读入页缓存中。这对于提高硬盘的吞吐量很有帮助,并且用户通常每次读取的数据不仅仅是只有几个字节而已。页缓存记录了每个 4KB 中的页在文件中的位置,如图中的 #0,#1 等。
然而,在一次文件读取的过程中,必须将文件的内容从页缓存拷贝到用户的空间。这个过程和缺页异常(通过 DMA 调入需要的页)不一样,这个拷贝过程需要通过 CPU 进行,因此浪费了 CPU 的时间。另一个弊端就是浪费了物理内存,因为需要为同样的数据在内存中维护两个副本,如上图 render 进程的 heap 所对应的堆中的数据和页缓存中的数据存在重复,并且如果系统中有多个这样的进程的话,那么需要为每个进程维护同样的一份数据副本,严重浪费了 CPU 的时间和物理内存空间。
好在,通过内存映射 IO---mmap,进程不但可以直接操作文件对应的物理内存,减少从内核空间到用户空间的数据复制过程,同时可以和别的进程共享页缓存中的数据,达到节约内存的作用。
当映射一个文件到内存中的时候,内核将虚拟地址直接映射到页缓存中。当映射一个文件的时候,如果文件的内容不在物理内存中,操作系统不会将所映射的文件部分的全部内容直接拷贝到物理内存中,而是在使用虚拟地址访问物理内存的时候通过缺页异常将所需要的数据调入内存中。如果文件本身已经存在于页缓存中,则不再通过磁盘 IO 调入内存。如果采用共享映射的方式,那么数据在内存中的布局如图所示。
2.2 写
由于页缓存的架构,当一个进程调用 write 系统调用的时候,对于文件的更新仅仅是被写到了文件的页缓存中,相应的页被标记为 dirty。具体过程如下:
- 前面 5 步和读文件是一致的,在 address_space 中查询对应页的页缓存是否存在;
- 如果页缓存命中,直接把文件内容修改写在页缓存的页中。写文件就结束了。这时候文件修改位于页缓存,并没有写回到磁盘文件中去。
- 如果页缓存缺失,那么产生一个页缺失异常,创建一个页缓存页,同时通过 inode 找到该文件页的磁盘地址,读取相应的页填充页缓存。此时缓存页命中,进行第 2 步。
普通的 IO 操作需要将写的数据从自己的进程地址空间复制到页缓存中,完成对页缓存的写入;但是 mmap 通过虚拟地址(指针)可以直接完成对页缓存的写入,减少了从用户空间到页缓存的复制。
由于写操作只是写到了页缓存中,因此进程并没有被阻塞到磁盘 IO 发生,因此当计算机崩溃的时候,写操作所引起的改变可能并没有发生在磁盘上。所以,对于一些要求严格的写操作,比如数据库系统,就需要调用 fsync 等操作及时将数据同步到磁盘上(虽然这中间也可能存在磁盘的驱动程序崩溃的情况)。读操作与写不同,一般会阻塞到进程读取到数据(除非调用非阻塞 IO,即使使用 IO 多路复用技术也是将进程阻塞在多个监听描述符上,本质上还是阻塞的)。为了减轻读操作的这种延迟,linux 操作系统的内核使用了“预读”技术,也就是当从磁盘中读取你所需要的数据的时候,内核将会多读取一些页到页缓存中。
普通文件 IO 中所有的文件内容的读取(无论一开始是命中页缓存还是没有命中页缓存)最终都是直接来源于页缓存。当将数据通过缺页中断从磁盘复制到页缓存之后,还要将页缓冲的数据通过 CPU 复制到 read 调用提供的缓冲区中。这样,必须通过两次数据拷贝过程,才能完成用户进程对文件内容的获取任务。写操作也是一样的,待写入的 buffer 在用户空间,必须将其先拷贝到内核空间对应的主存中,再写回到磁盘中,也是需要两次数据拷贝。mmap 的使用减少了数据从用户空间到页缓存的复制过程,提高了 IO 的效率,尤其是对于大文件而言;对于比较小的文件而言,由于 mmap 执行了更多的内核操作,因此其效率可能比普通的文件 IO 更差。
在专门介绍 mmap 的博客中,我们说文件映射分为私有映射(private)和共享映射(shared)两种,二者之间的区别就是一个进程对文件所做的改变能否被其他的进程所看到,且能否同步到后备的存储介质中。那么,如果一个进程仅仅是读取文件中的内容的话,那么共享映射和私有映射对应的物理内存布局如图5所示。但是,如果采用私有映射的方式,且一个进程对文件内容作出了改变,那么会发生怎样的情况呢?内核采用了写时复制技术完成私有映射下对文件内容的改动,下面举例说明。
假设系统中存在两个进程分别为 render 和 render3d,它们都私有映射同一个文件 scene.dat 到内存中,然后 render 进程对映射的文件做出了写操作,如图所示。
图上的“只读”标志不是说映射的内容是只读的,这仅仅是内核为了节省物理内存而采用的对于物理内存的一种“欺骗手段”而已。如果两个进程只是读取文件中的内容,不做任何的改动,那么文件只在物理内存中保留一份;但是如果有一个进程,如 render,要对文件中的内容做出改动,那么会触发缺页中断,内核采用写时复制技术,为要改动的内容对应的页重新分配一个物理页框,将并将被改动的内容对应的物理页框中的数据复制到新分配的物理页框中,再进行改动。此时新分配的物理页框对于 render 而言是它自己“私有的”,别的进程是看不到的,也不会被同步到后备的存储中。但是如果是共享映射,所有的进程都是共享同一块页缓存的,此时被映射的文件的数据在内存中只保留一份。任何一个进程对映射区进行读或者写,都不会导致对页缓冲数据的复制。
mmap 的系统调用函数原型为
void* mmap(void* addr, size_t len, int prot, int flag, int fd, off_t off)
其中,flag 指定了是私有映射还是共享映射,私有映射的写会引发缺页中断,然后复制对应的物理页框到新分配的页框中。prot 指定了被映射的文件是可读、可写、可执行还是不可访问。如果 prot 指定的是可读,但是却对映射文件执行写操作,则此时却缺页中断会引起段错误,而不是进行写时复制。
那么此时存在另一个问题就是,当最后一个 render 进程退出之后,存储 scene.dat 的页缓存是不是会被马上释放掉?当然不是!在一个进程中打开一个文件使用完之后该进程退出,然后在另一个进程中使用该文件这种情况通常是很常见的,页缓存的管理中必须考虑到这种情况。况且从页缓存中读取数据的时间是 ns 级别,但是从硬盘中读取数据的时间是 ms 级别,因此如果能够在使用数据的时候命中页缓存,那么对于系统的性能将非常有帮助。那么,问题来了,什么时候该文件对应的页缓存要被换出内存呢?就是系统中的内存紧张,必须要换出一部分物理页到硬盘中或者交换区中,以腾出更多的空间给即将要使用的数据的时候。所以只要系统中存在空闲的内存,那么页缓存就不会被换出,直到到达页缓存的上限为止。是否换出某一页缓存不是由某一个进程决定的,而是由操作系统在整个系统空间中的资源分配决定的。毕竟,从页缓存中读取数据要比从硬盘上读取数据要快的多了。
内存映射的一个典型应用就是动态共享库的加载。图8展示了两个同一份程序的两个实例使用动态共享库时,进程的虚拟地址空间及对应的物理内存空间的布局。
三、页缓存中数据如何实现和后备存储之间的同步?
普通文件 IO,都是将数据直接写在页缓存上,那么页缓存中的数据何时写回后备存储?怎么写回?
3.1 何时写回
- 空闲内存的值低于一个指定的阈值的时候,内核必须将脏页写回到后备存储以释放内存。因为只有干净的内存页才可以回收。当脏页被写回之后就变为 PG_uptodate 标志,变为干净的页,内核就可以将其所占的内存回收;
- 当脏页在内存中驻留的时间超过一个指定的阈值之后,内核必须将该脏页写回到后备存储,以确定脏页不会在内存中无限期的停留;
- 当用户进程显式的调用 fsync、fdatasync 或者 sync 的时候,内核按照要求执行回写操作。
3.2 由谁写回
为了能够不阻塞写操作,并且将脏页及时的写回后备存储。linux 在当前的内核版本中使用了 flusher 线程负责将脏页回写。
为了满足第一个何时回写的条件,内核在可用内存低于一个阈值的时候唤醒一个或者多个 flusher 线程,将脏页回写;
为了满足第二个条件,内核将通过定时器定时唤醒flusher线程,将所有驻留时间超时的脏页回写。