一 页高速缓存(页缓存)?
1.1 什么是页高速缓存(page cache)? 为什么需要页高速缓存?
1.1.1 什么是页高速缓存?
页高速缓存,也就是我们经常说的page cache,它是Linux操作系统实现的针对磁盘的一种缓存,通过把磁盘的数据缓存到物理内存,把对磁盘的访问转化为对物理内存的访问。页缓存可以减少内核对磁盘的I/O操作,提升I/O性能。
1.1.2 为什么需要页高速缓存?
1.1.2.1 内存和磁盘的速度差异
因为读取内存的速度远快于读取磁盘的速度,所以从内存访问数据比从磁盘读数据快
1.1.2.2 局部性原理
现在访问的数据,很有可能在短期内还会被继续访问;或者一个存储位置被访问之后,他相邻的存储位置很有可能被访问。所以有可能频繁访问,所以适合缓存起来
1.2 如何根据文件描述符找到找到文件的inode信息?
当用户进程访问打开的程序的时候,需要获取文件的起始物理位置,如何找到这个物理位置呢?
我们知道操作系统读取磁盘数据的时候,并不会一个扇区一个扇区的读取,效率太低,而是一次性读取连续的多个扇区,由多个扇区组成的东西我们叫做块,是文件读写的最小单位,一般来说一个块大小是4K,即8个扇区。数据是存储在块中,但是文件相关的元数据信息存储在哪儿呢?比如文件名称、文件大小、创建时间、所属用户和用户组、数据块位置信息等等,这些数据就是存储在inode上,inode会存储在一个inode表中。所以内核可以根据inode找到对应的数据块。
第一:打开文件,返回文件描述符
第二:根据文件描述符在文件打开表中查找到inode号
第三:根据inode在系统inode表中找到文件inode信息
第四:根据inode信息找到物理位置
1.3 address_space是什么?
页高速缓存中缓存的页可能包含多个不连续的物理磁盘块,比如一个页8K大小,但是物理块4K,一个页可能包含2个物理块,但是同一个页上的物理块并不一定是顺序的。那如何知道要读取的文件内容有没有在页缓存中呢?Linux就是通过address_space来管理inode和page cache的。
一个文件只有一个address_space,也就是说inode和address_space是一一对应的,根据inode就可以找到address_space,然后每一个address_space可能会关联多个缓存页,所以inode根据address_space就可以找到当前文件对应着哪些页缓存,然后根据要读取文件的偏移量查找对应的磁盘块,找到了说明缓存命中;没有找到就需要从磁盘加载到页缓存中。
1.4 页高速缓存的工作流程
第一:进程发起读文件的请求
第二:内核从进程打开的文件信息找到对应的文件inode信息
第三:根据要读取文件内容的偏移量计算出要读取的页
第四:根据该文件的inode在address_space里查找有没有这个页
第五:如果缓存命中,则直接返回文件内容;如果页缓存没有命中,即触发缺页中断,内核请求调页。调页过程首先从交换缓存空间寻找要访问的页(有可能物理内存不够,被换出到磁盘),如果有载入内存;如果没有则创建一个新的页,然后读取磁盘上该页的内容填充到页缓存中创建的这个新页上
1.5 页缓存和内存的区别
第一:页缓存属于内存的一部分,因为页缓存会占用物理内存
第二:页缓存所有进程共享,和进程分配的内存空间不一样,给进程分配的内存空间一般都是进程私有
第三:当用户程序读取文件的时候,一般会声明一个缓冲区,会在内存分配空间,然后调用系统调用,进程会先从页缓存根据文件描述符和偏移量查找是否缓存命中,如果缓存命中,将数据拷贝到分配的内存中;如果没有命中则从交换分区查找,如果交换分区没有则只能读磁盘数据了。
第四: 因为内存限制,Linux可以使用交换分区,根据一定的算法把物理内存页换出到磁盘,因为页缓存也是物理页,所以页缓存可能有一部分在内存,有一部分在交换分区中。
二 传统I/O读写流程
2.1 传统I/O读流程
第一:用户程序发起读文件的请求,调用read库函数
第二:CPU切换到内核态,调用read或者pread系统调用
第三:根据文件描述符从进程打开的文件表中获取inode索引,然后根据inode索引从系统inode表中获取inode
第四: 根据inode找到与之对应address_space,从address_space获取和这个文件相关的页缓存
第五:如果缓存命中,或者没有命中但是在交换分区找到了则将数据拷贝到内存(读缓冲区)用户空间
第六:如果在页缓存没有命中,则需要内核需要进行磁盘读,读完之后需要更新address_space等信息,然后分配物理页,将数据填充到物理页上,最后才将数据拷贝到用户空间(读缓冲区)
2.2 传统I/O写流程
第一:用户程序调用write库函数
第二:CPU切换到用户到内核态,调用write或者pwrite系统调用
第三:根据打开的文件的文件描述符在文件打开表中找到inode索引,然后根据inode索引找到inode
第四:根据inode获取对应的address_space对象,然后从address_space中查询这个文件对应的页缓存
第五:找到要写入的页,即缓存命中,然后写入数据就返回
第六:如果缓存没有命中,找不到页,则创建一个物理页,然后写入数据
第七:如果用户进程调用了刷盘操作,即fsync、fdatasync、sync则会将数据刷入磁盘;如果没有调用刷盘操作,操作系统后台有单独的刷盘线程定期自动刷盘
三 文件内存映射(mmap)
3.1 什么是文件内存映射? 它存在的意义是什么?
3.1.1 什么是文件内存映射?
文件内存映射: 将磁盘文件部分或者全部映射到进程的虚拟地址空间(逻辑地址空间)一段连续的内存当中。
注意:
#1 文件内存映射是对文件大小有限制,不能太大,对于超过限制的就不能进行全部映射,只能映射部分文件
#2 映射到的虚拟地主空间的一段连续内存,不代表在物理内存空间上也是连续的
3.1.2 文件内存映射的意义
第一:传统I/O的读和写必须涉及到2次拷贝,即一次CPU拷贝和一次DMA拷贝;但是mmap对于CPU拷贝却不是必须的,除非你需要通过用户缓冲区缓存读写数据
第二:对于读取一个设备的数据,写入另一个设备只需要3次映射,传统I/O读写需要四次。比如需要读取磁盘某文件全部数据写入网络,那么首先对磁盘文件建立映射,然后DMA拷贝数据到页缓存,然后将页缓存的数据通过CPU拷贝到socket缓冲区,最后通过socket缓冲区通过DMA拷贝到网卡,然后写出数据到网络。如图所示:
3.2 文件内存映射工作原理
3.2.1 启动映射过程,在虚拟地址空间创建虚拟映射区域
#1 用户进程调用mmap库函数,指定映射的起始位置和映射大小以及映射模式等等
#2 在当前进程的虚拟地址空间寻找一段映射大小连续内存区域
#3 为这个虚拟地址空间连续内存区域分配一个vm_area_struct结构,对这个结构初始化
#4 将这个vm_area_struct结构插入到进程虚拟地址空间区域链表中
3.2.2 实现文件物理地址和虚拟地址的映射
#1 根据返回的文件描述符,从系统文件打开表获取inode索引,根据inode索引获取inode信息
#2 调用系统内核mmap系统调用,通过inode找到对应文件信息,定位到文件磁盘物理地址
#3 通过remap_pfn_range函数,实现文件物理地址和虚拟地址的映射,说白了也就是了内核缓冲区(页缓存)和虚拟地址映射
3.2.3 访问映射的区域
#1 访问虚拟内存映射区域,读取文件内容
#2 在页缓存中读取文件内容,由于只是建立了映射,页缓存还没有数据,则出发缺页中断;
#3 请求调页过程首先从交换分区查找,交换分区也没有则调用nopage函数把所缺的页载入主存的页缓存
#4 内核此时会进行磁盘I/O 根据要读取数据的起始位置和偏移量找到对应的文件,然后通过DMA拷贝到主存的内核缓冲区(页缓存),并且会更新文件inode对应的address__page对象的页信息
#5 因为页缓存已经有数据了,所以用户进程就可以直接看到数据了;如果是写的流程那么也可以直接写入数据到页缓存了
3.2.3 数据刷盘
对于写操作,会更新页缓存,产生脏页,这些脏页可以通过调用msync函数刷盘,或者释放映射内存区域munmap函数。那如果既不退出映射,也没有调用刷盘,难道就一直就在缓冲区吗,肯定不是的,操作系统后台与一个flusher刷盘的线程每隔一段时间就会自动刷盘。
3.3 内存映射相关的函数mmap、munmap和msync比较
3.3.1 mmap 文件映射内存
void *mmap(void *addr, size_t length, int prot , int flags ,int fd, off_t offset);
addr: 开始映射的地址,属于进程的逻辑地址
length: 从开始映射地址,映射多长,一般是一个页大小,4KB
prot:期望的内存保护标志,不能与文件打开的标志冲突,比如文件只可读,这里的就不能可写
#1 PROT_EXEC 页内容可以被执行
#2 PROT_READ 页内容可以被读取
#3 PROT_WRITE 页可以被写入
#4 PROT_NONE 页不可访问
flags: 指定映射对象的类型,映射选项和映射页是否可以共享
MAX_FIXED: 如果参数start所指的地址无法成功建立映射时,则放弃映射
MAP_SHARED: 与其他映射这个文件的进程共享映射内存,可能存在并发修改
MAP_PRIVATE: 对映射区域的写入操作会产生一个映射文件的复制,类似于写时复制,对此区域作的任何修改都不会写回原来的文件内容。
fd: 文件描述符
offset: 文件映射的偏移量,已经映射了多少
3.3.2 munmap 解除指定部分的映射
int munmap(void *addr, size_t length);
解除从addr地址开始的length长的部分的映射,当然解除了映射,内存中的改变也会刷入磁盘
3.3.3 msync 将内存中的改变刷入磁盘
int msync(void *addr, size_t length, int flags);
我们知道munmap,解除映射后,可以将内存映射的修改刷入磁盘,但是如果还没有解除映射呢,也想把内存映射中修改的东西刷入磁盘,就可以使用msync了
而且操作系统也会自动在后台同步
3.4 文件内存映射和传统I/O比较
#1比传统I/O少了一次CPU拷贝,其余的其实也没什么可比较的
#2 文件映射内存对文件大小有限制,所以映射的大小不一定可以和文件大小一致。
和传统I/O相比,少了一步内核空间到用户空间的数据CPU拷贝。我们知道传统I/O的读必须要将数据从内核拷贝到用户空间的用户缓冲区;传统I/O的写必须先写入用户缓冲区在拷贝到内核缓冲区。但是基于mmap的读,可以从内核缓冲区读取数据,然后再拷贝到一个内核缓冲区,但这不是必须的,我们可以获取单个字节,也可以将内核空间的数据直接拷贝到其他的内核缓冲区;我们也可以将数据写入缓冲区,然后再把缓冲区数据写入内核缓冲区,但这也不是必须的,我们可以直接写入单个字节也可以从内核缓冲区直接拷贝数据到其他内核缓冲区。因为我们操作的虚拟内存映射区域是和内核缓冲区(Page Cache)映射的,我们读的数据直接可以从Page Cache读 ,写的数据也直接可以写入到Page Cache, 但是传统的I/O读写是做不到这一点的。