好文推荐:深入理解Intel CPU架构【值得收藏!】
【纯干货】Linux内存管理(最彻底的一篇)
路由协议 - RIP 协议
Linux内核整体架构介绍
谈谈linux内核学习:虚拟文件系统(VFS)
Linux 内核入门——可能和不太可能
页面缓存的实现:
由于页面缓存以页面为单位管理数据,因此必须在内核中识别物理页面。实际上,每一个实际存储数据的物理页框都对应一个名为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:描述页面当前状态等信息,如当前页面是否为脏页PG_dirty;是否是已经同步到backing store的最新页面PG_uptodate;是否在lru链表上等;
_count:引用计数,标识页面在内核中被引用的次数。如果要对页面进行操作,则引用计数为+1,操作完成后为-1。当该值为0时,表示没有对该页所在位置的引用,所以该页可以unmapped,这在回收内存时很有用;
_mapcount:页表被映射的次数,也就是说该页同时被多少个进程共享。初始值为-1。如果只被一个进程的页表映射,则值为0。
_mapping 具有三层含义:
一个。如果mapping = 0,表示该页属于swap cache;当需要地址空间时,指定swap分区的地址空间swapper_space;
b. 如果mapping != 0,bit[0] = 0,表示该页属于page cache或文件映射,mapping指向文件的地址空间address_space;
C。如果mapping != 0,bit[0] !=0表示该页为匿名映射,映射指向struct anon_vma对象;
(注意_count和_mapcount的区别,_mapcount代表被映射的次数,_count代表被使用的次数;映射了不一定用到,但必须先映射过再用) .
index:映射虚拟空间(vma_area)中的偏移量;一个文件可能只映射了一部分,假设映射了1M空间,那么index指的是1M空间中的偏移量,而不是整个文件shift中的偏移量;
private : 私有数据指针;
lru:当页面在用户态使用或者作为页面缓存时,页面连接到zone中的lru链表进行内存回收;
页缓存是一个树状结构,由内存中一个文件的所有物理页组成。我们称之为基数树,用于管理内存中属于同一个文件的缓存内容。
上面说了,内存中一个文件对应的所有物理页组成一棵基数树。一个文件在内存中有一个唯一的inode结构标识,inode结构中有文件所属的设备及其标识。因此,根据一个inode,可以确定其对应的备份设备。为了将文件在物理内存中的page cache与文件及其备份设备相关联,linux内核引入了address_space结构。可以说address_space结构是将page cache和文件系统联系起来的桥梁,它的组成如下:
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;/*相关的缓冲*/
}
下面解释address_space成员中的变量。
host:指向与address_space关联的inode节点,inode节点与address_space一一对应;
struct radix_tree_root:host文件的所有物理页映射到内存中形成的radix tree的根节点,参考博客。
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;
};
为了便于对页面缓存、文件和进程之间的关系形成一个清晰的思路,文章画了一张图,如图2所示。
从上面的解释可以看出,address_space成为了构建page cache和文件、page cache和共享文件的所有进程之间的桥梁。
每个进程的地址空间由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一一对应,从而形成page cache和文件系统的关联;为了找到与某个文件相关联的所有进程,address_space中的prio_tree_root指向所有与这个page cache相关联的进程所形成的优先级查找树的根节点。要详细了解这种关系,
Linux中的文件系统这里需要说明的一点是,内核为每个进程在自己的地址空间中维护了一个结构体struct* fd_array[],用来维护进程地址空间中打开的文件的指针;打开的文件还维护了一个系统级的文件描述符表,用于记录系统打开的所有文件缓存文件在哪里,供所有进程共享;每个打开的文件都由一个对应的inode结构表示,这个结构由系统决定的级别的文件描述符表指向。因此,进程可以通过自己地址空间中打开的文件描述符表找到系统级的文件描述符表,进而找到文件。
page cache、内存、文件IO的关系
关于文件IO我们常说的两句话“普通文件IO需要复制两次,而内存映射文件mmap只需要复制一次”。下面,我们对普通文件IO进行详细的讲解。文章对page cache和file IO做了详细的介绍,不过都是英文的。本文在对上述内容的理解和翻译的基础上,加上自己对page cache的理解。读者可以选择直接进入相应的英文原版说明。
读
为了深入理解page cache和文件IO操作的关系,假设系统中有一个名为render的进程,打开文件scene.dat,读取512B(一个扇区的大小),将读取的文件数据放入堆分配的块中(每个进程的地址空间对应的物理内存)。首先以普通IO为例介绍读取数据的过程。第一次读取的过程大致如图4所示
一个进程发起读请求的过程如下:
1、进程调用库函数read()向内核发起文件读取请求;
2、内核通过查看进程的文件描述符定位到虚拟文件系统已经打开的文件列表项,调用文件系统提供的接口给VFS read()调用;
3、通过文件表项链接到目录项模块,根据传入的文件路径在目录项中查找,找到文件的inode;
4、在inode中,通过文件内容的偏移量来计算要读取的page;
5、通过inode的i_mapping指针找到对应的address_space page cache tree --- radix tree,找到对应的page cache节点;
(1)如果页面缓存节点命中,则直接返回文件内容;
(2)如果page cache缺失,产生page fault异常,先新建一个空的物理页框,通过inode在文件中找到page的磁盘地址,读取对应的page填充page cache (DMA的读取数据到页缓存),更新页表项;重复步骤 5 中查找页面缓存的过程;
6、文件内容读取成功;
换句话说,所有文件内容读取(无论它们最初命中页面缓存还是未命中页面缓存)最终都直接来自页面缓存。数据从磁盘复制到page cache后,还必须通过CPU将page cache中的数据复制到read调用提供的buffer中。这就是普通文件IO需要的两份数据拷贝过程。第一次是通过DMA将数据从磁盘拷贝到page cache中。这个过程只需要CPU在开始时放弃总线,结束后处理DMA中断即可。中间不需要CPU直接干预。CPU 可以做其他事情;第二次是将page cache中的数据拷贝到进程自身地址空间对应的物理内存中。
如果读取12KB的数据,则渲染进程的堆地址空间和相关地址空间如图5所示。
图5 读取12KB数据后进程render的地址空间和page cache示意图
看似过程很简单,其实里面的知识点很多。首先,render 使用常规的 read() 系统调用读取 12KB 的数据,现在场景中有 3 个大小为 4KB 的页面。通过页面缓存。在X86架构的Linux系统中,内核以4KB页为单位组织文件中的数据,所以即使你只从一个文件中读取几个字节的数据,包含这些字节的整个页的数据也会被读取从磁盘到页面缓存。这对于提高硬盘的吞吐量非常有帮助,用户通常一次读取的数据不止几个字节。page cache记录了每个4KB的page在文件中的位置,如图中的#0、#1等。
但是,在文件读取期间,文件的内容必须从页面缓存复制到用户空间。这个过程不同于page fault exception(通过DMA传输需要的page)。这个拷贝过程需要CPU来完成,浪费了CPU时间。另一个缺点是物理内存的浪费,因为同一份数据需要在内存中维护两份,如图6所示。堆中的数据对应于render进程的堆和page cache中的数据是重复的,如果系统中有多个这样的进程,则需要为每个进程维护同一份数据,严重浪费CPU时间和物理内存空间。
幸运的是,通过内存映射IO---mmap,进程不仅可以直接操作文件对应的物理内存,减少从内核空间到用户空间的数据拷贝过程,还可以与其他进程共享page cache中的数据达到节省记忆的作用。mmap的实现可以参考博客。
将文件映射到内存时,内核将虚拟地址直接映射到页面缓存中。博客4中介绍过,映射文件时,如果文件内容不在物理内存中,操作系统不会直接将映射文件部分的全部内容复制到物理内存中,而是使用虚拟地址访问。内存被使用时,需要的数据通过缺页异常传送到内存中。如果文件本身已经存在于page cache中,则不再通过磁盘IO调入内存。如果采用共享映射,数据在内存中的布局如图6所示。
写
由于page cache的架构,当进程调用write系统调用时,对文件的更新只会写入文件的page cache,相应的page会被标记为dirty。具体过程如下:
前5步与读取文件一致。在address_space中查询对应页的page cache是??否存在:
6.如果page cache命中,直接修改文件内容写入page cache的page。写入文件结束。此时文件修改位于页面缓存中,并没有写回磁盘文件。
7.如果page cache缺失,产生page missing异常,创建page cache page,同时通过inode找到文件page的磁盘地址,读取对应page填充页面缓存。此时缓存页面命中,继续执行第6步。
普通IO操作需要将写入的数据从自己的进程地址空间复制到page cache中,完成对page cache的写入;而mmap可以直接通过虚拟地址(指针)完成对page cache的写入,减少了从用户空间Copy到page cache的需要。
由于写操作只是写入页缓存,直到磁盘IO发生时进程才被阻塞,所以当计算机死机时,写操作引起的变化可能还没有发生在磁盘上。因此,对于一些要求很高的写操作,比如数据库系统,需要调用fsync等操作,及时将数据同步到磁盘(虽然中间可能会出现磁盘驱动崩溃的情况)。读操作不同于写操作,一般都是阻塞直到进程读取数据(除非调用非阻塞IO,即使使用IO多路复用技术,进程也会阻塞在多个监听描述符上,本质上是阻塞). 为了减轻读操作的这种延迟,Linux操作系统的内核使用了“预读”
所有在普通文件IO中读取的文件内容(无论一开始是否命中了page cache还是没有命中page cache)最终都是直接来自于page cache。通过缺页中断将数据从磁盘复制到page cache后,page buffer的数据也通过CPU复制到read调用提供的buffer中。这样,用户进程获取文件内容的任务就必须通过两个数据拷贝进程来完成。写操作也是一样的。要写入的缓冲区在用户空间。必须先拷贝到内核空间对应的主存中,然后再写回磁盘,同样需要两次数据拷贝。mmap的使用减少了数据从用户空间拷贝到page cache的过程,提高了IO的效率,尤其是对于大文件;
在mmap专用的博客中,我们说过文件映射分为私有映射(private)和共享映射(shared)两种。两者的区别在于一个进程对文件所做的更改是否可以被其他进程更改。看,能不能同步到备份存储介质。那么,如果一个进程只读取文件中的内容,那么共享映射和私有映射对应的物理内存布局如图5所示。但是如果使用私有映射,进程对内容进行了更改,会发生什么情况文件的?内核使用copy-on-write技术完成私有映射下文件内容的修改。下面举例说明。
假设系统中有两个进程render和render3d,它们都私下将同一个文件scene.dat映射到内存中,然后render进程写入映射文件,如图7所示。
图6中的“只读”标志并不意味着映射的内容是只读的,它只是内核为了节省物理内存而采用的对物理内存的“欺骗”。如果两个进程只是读取文件的内容而不做任何更改,那么物理内存中只保留一份文件;但是如果有一个进程,比如render,想要对文件的内容进行更改缓存文件在哪里,那么它就会在触发缺页中断时,内核使用copy-on-write技术重新分配一个物理页框给将要更改的内容对应的页面,将更改内容对应的物理页框中的数据复制到新分配的物理页框中,然后进行更改。此时新分配的物理页框是“私有”的 渲染,其他进程看不到,也不会同步到后备存储。但如果是共享映射,则所有进程共享同一个page cache,此时内存中只保留一份映射文件的数据。读取或写入映射区域的任何进程都不会导致页面缓冲区数据的副本。
mmap的系统调用函数原型为void* mmap(void* addr, size_t len, int prot, int flag, int fd, off_t off)。其中flag指定是私有映射还是共享映射,私有映射的写入会引起缺页中断,然后将对应的物理页框复制到新分配的页框。prot 指定映射文件是可读、可写、可执行还是不可访问。如果 prot 指定可读性,但写入映射文件,页面错误中断将导致段错误而不是写时复制。
那么这时候还有一个问题:当最后一个render进程退出时,存放scene.dat的page cache会不会马上释放?当然不是!很常见的情况是在一个进程中打开一个文件,使用完后退出该进程,然后在另一个进程中使用该文件。在页面缓存的管理中必须考虑到这种情况。而且从page cache读取数据的时间是ns级别的,但是从硬盘读取数据的时间是ms级别的,所以如果在使用数据的时候能够命中page cache,对性能会有很大的帮助系统。那么,问题来了,文件对应的page cache什么时候会被换出内存呢?即系统内存紧张,并且一些物理页面必须换出到硬盘或交换区,以便在提供即将使用的数据时释放更多空间。所以只要系统中有空闲内存,页面缓存就不会被换出,直到达到页面缓存的上限。是否换出某个page cache,并不是由某个进程决定的,而是由操作系统在整个系统空间的资源分配决定的。毕竟从page cache中读取数据要比从硬盘中读取数据快很多。而是通过操作系统在整个系统空间中的资源分配。毕竟从page cache中读取数据要比从硬盘中读取数据快很多。而是通过操作系统在整个系统空间中的资源分配。毕竟从page cache中读取数据要比从硬盘中读取数据快很多。
内存映射的一个典型应用是加载动态共享库。图8显示了同一程序的两个实例使用动态共享库时进程的虚拟地址空间和对应的物理内存空间的布局。
页面缓存中的数据如何与后备存储同步?
普通的文件IO是直接把数据写到page cache中,那么page cache中的数据什么时候会写回backing storage呢?怎么写回去?
什么时候回信
1. 当free memory的值低于指定阈值时,内核必须将脏页写回backing store以释放内存。因为只有干净的内存页才能被回收。当脏页被写回后,成为PG_uptodate标志,成为干净页,内核可以回收其占用的内存;
2、当脏页在内存中驻留时间超过指定的阈值时,内核必须将脏页写回backing store,以保证脏页不会无限期地停留在内存中;
3、当用户进程显式调用fsync、fdatasync或sync时,内核根据需要进行回写操作。
谁回信了
为了不阻塞写操作,及时将脏页写回backing store。Linux在当前内核版本中使用flusher线程负责写回脏页。
为了满足第一个条件何时回写,内核在可用内存低于阈值时唤醒一个或多个flusher线程,并回写脏页;
为了满足第二个条件,内核会通过定时器定时唤醒flusher线程,将停留时间已过的脏页全部写回。
以上就是我对页面缓存和文件IO的理解,欢迎大家吐槽。
- - 内核技术中文网-打造国内最权威的内核技术交流分享论坛
原文地址:一篇了解linux中page cache和文件IO的文章