第十六章--访问文件-阿里云开发者社区

开发者社区> 开发与运维> 正文

第十六章--访问文件

简介:         本章所涵盖的主题即应用于磁盘文件系统的普通文件,也应用于块设备文件;将这两种文件系统都简单地统称为“文件”。         访问文件的模式有多种。我们在本章考虑如下几种情况:         规范模式:         规范模式下文件打开后,标志O_SYNC与O_DIRECT清0,而且它的内容是由系统调用read()和write()来存取。
        本章所涵盖的主题即应用于磁盘文件系统的普通文件,也应用于块设备文件;将这两种文件系统都简单地统称为“文件”。
        访问文件的模式有多种。我们在本章考虑如下几种情况:
        规范模式:
        规范模式下文件打开后,标志O_SYNC与O_DIRECT清0,而且它的内容是由系统调用read()和write()来存取。系统调用read()将阻塞调用进程,直到数据被拷贝进用户态地址空间(内核允许返回的字节数少于要求的字节数)。但系统调用write()不同,它在数据被拷贝到页高速缓存(延迟写)后就马上结束。
        同步模式:
        同步模式下文件打开后,标志O_SYNC置1或稍后由系统调用fcntl()对其置1。这个标志只影响写操作(读操作总是会阻塞),它将阻塞调用进程,直到数据被有效地写入磁盘。
        内存映射模式:
        内存映射模式下文件打开后,应用程序发出系统调用mmap()将文件映射到内存中。因此,文件就成为RAM中的一个字节数组,应用程序就可以直接访问数组元素,而不需要系统调用read()、write()或lseek()。
        直接I/O模式:
        直接I/O模式下文件打开后,标志O_DIRECT置1。任何读写操作都将数据在用户态地址空间与磁盘间直接传送而不通过页高速缓存。
        异步模式:
        异步模式下,文件的访问可以有两种方法,即通过一组POSIX API或Linux特有的系统调用来实现。所谓异步模式就是数据传输请求并不阻塞调用进程,而是在后台执行,同时应用程序继续它的正常运行。
一、读写文件
        读文件是基于页的,内核总是一次传送几个完整的数据页。如果进程发出read()系统调用来读取一些字节,而这些数据还不在RAM中,那么,内核就要分配一个新页框,并使用文件的适当部分来填充这个页,把该页加入页高速缓存,最后把所请求的字节拷贝到进程地址空间中。对于大部分文件系统来说,从文件中读取一个数据页就等同于在磁盘上查找所请求的数据存放在哪些块上。事实上,大多数磁盘文件系统的read方法是由名为generic_file_read()的通用函数实现的。
        对基于磁盘的文件来说,写操作的处理相当复杂,因为文件大小可以改变,因此内核可能会分配磁盘上的一些物理块。当然,这个过程到底如何实现要取决于文件系统的类型。不过,很多磁盘文件系统是通过通用函数generic_file_write()实现它们的write方法的。这样的文件系统如Ext2、System V/Coherent/Xenix及Minix。另一方面,还有几个文件系统(如日志文件系统和网络文件系统)通过自定义的函数实现它们的write方法。
1.1、从文件中读取数据

1.1.1、普通文件的readpage方法

1.1.2、块设备文件的readpage方法

1.2、文件的预读
        预读(read-ahead)是一种技术,这种技术在于在实际请求前读普通文件或块设备文件的几个相邻的数据页。在大多数情况下,预读能极大地提高磁盘的性能,因为预读使磁盘控制器处理较少的命令,其中的每条命令都涉及一大组相邻的扇区。此外,预读还能提高系统的响应能力。顺序读取文件的进程通常不需要等待请求的数据,因为请求的数据已经在RAM中了。
        但是,预读对于随机访问的文件是没有用的;在这种情况下,预读实际上是有害的,因为它用无用的信息浪费了页高速缓存的空间。因此,当内核确定出最近所进行的I/O访问与前一次I/O访问不是顺序的时候就减少或停止预读。
        文件的预读需要更复杂的算法,这是由于以下几个原因:
        * 由于数据是逐页进行读取的,因此预读算法不必考虑页内偏移量,只要考虑所访问的页在文件内部的位置就可以了。
        * 只要进程持续地顺序访问一个文件,预读就会逐渐增加。
        * 当前的访问与上一次访问不是顺序的时(随机访问),预读就会逐渐减少乃至禁止。
        * 当一个进程重复地访问同一页(即只使用文件的很小一部分)时,或者当几乎所有的页都已在页高速缓存内时,预读就必须停止。
        * 低级I/O设备驱动程序必须在合适的时候激活,这样当将来进程需要时,页已传送完毕。
        当访问给定文件时,预读算法使用两个页面集,各自对应文件的一个连续区域。这两个页面集分别叫做当前窗(current window)和预读窗(ahead window)。
        当前窗内的页是进程请求的页和内核预读的页,且位于页高速缓存内(当前窗内的页不必是最新的,因为I/O数据传输仍可能在运行中)。当前窗包含进程顺序访问的最后一页,且可能有内核预读但进程未申请的页。
        预读窗内的页紧接着当前窗内的页,它们是内核正在预读的页。预读窗内的页都不是进程请求的,但内核假定进程会迟早请求。
        当内核认为是顺序访问而且第一页在当前窗内时,它就检查是否建立了预读窗。如果没有,内核创建一个预读窗并触发相应页的读操作。理想情况下,进程继续从当前窗请求页,同时预读窗的页则正在传送。当进程请求的页在预读窗,那么预读窗就成为当前窗。
        何时执行预读算法?这有下列几种情形:
        * 当内核用用户态请求来读文件数据的页时。这一事件触发page_cache_readahead()函数的调用。
        * 当内核为文件内存映射分配一页时。
        * 当用户态应用执行readahead()系统调用时,它会对某个文件描述符显示触发某预读活动。
        * 当用户态应用使用POSIX_FADV_NOREUSE或POSIX_FADV_WILLNEED命令执行posix_fadvise()系统调用时,它会通知内核,某个范围的文件页不久将要被访问。
        * 当用户态应用使用MADV_WILLNEED命令执行madvise()系统调用时,它会通知内核,某个文件内存映射区域中的给定范围的文件页不久将要被访问。
1.2.1、page_cache_readahead()函数
        page_cache_readahead()函数处理没有被特殊系统调用显示触发的所有预读操作。它填写当前窗和预读窗,根据预读命中数更新当前窗和预读窗的大小,也就是根据过去对文件访问预读策略的成功程度来调整。
1.2.2、handle_ra_miss()函数

1.3、写入文件
        write()系统调用涉及把数据从调用进程的用户态地址空间中移动到内核数据结构中,然后再移动到磁盘上。文件对象的write方法允许每种文件类型都定义一个专用的写操作。在Linux2.6中,每个磁盘文件系统的write方法都是一个过程,该过程主要标识写操作所涉及的磁盘块,把数据从用户态地址空间拷贝到页高速缓存的某些页中,然后把这些页中的缓冲区标记成脏。

1.3.1、普通文件的prepare_write和commit_write方法

1.3.2、块设备文件的prepare_write和commit_write方法

1.4、将脏页写到磁盘
        系统调用write()的作用就是修改页高速缓存内一些页的内容,如果页高速缓存内没有所要的页则分配并追加这些页。某些情况下(例如文件带O_SYNC标志打开),I/O数据传输立即启动。但是通常I/O数据传输是延迟进行的。

二、内存映射
        一个线性区可以和磁盘文件系统的普通文件的某一部分或者块设备文件相关联。这就意味着内核把对区线性中页内某个字节的访问转换成对文件中相应字节的操作。这种技术称为内存映射(memory mapping)。
        有两种类型的内存映射:
        共享型:在线性区页上的任何写操作都会修改磁盘上的文件;而且,如果进程对共享映射中的一个页进行写,那么这种修改对于其他映射了这同一文件的所有进程来说都是可见的。
        私有型:当进程创建的映射只是为读文件,而不是写文件时才会使用此种映射。出于这种目的,私有映射的效率要比共享映射的效率更高。但是对私有映射页的任何写操作都会使内核停止映射该文件中的页。因此,写操作既不会改变磁盘上的文件,对访问相同文件的其他进程也不可见。但是私有内存映射中还没有被进程改变的页会因为其他进程进行的文件更新而更新。
        作为一条通用规则,如果一个内存映射是共享的,相应的线性区就设置了VM_SHARED标志;如果一个内存映射是私有的,那么相应的线性区就清除了VM_SHARED标志。
2.1、内存映射的数据结构
        内存映射可以用下列数据结构的组合来表示:
        * 与所映射的文件相关的索引节点对象。
        * 所映射文件的address_space对象。
        * 不同进程对一个文件进行不同映射所使用的文件对象。
        * 对文件进行每一不同映射所使用的vm_area_struct描述符。
        * 对文件进行映射的线性区所分配的每个页框所对应的页描述符。
        共享内存映射的页通常都包含在页高速缓存中;私有内存映射的页只要还没有被修改,也都包含在页高速缓存中。当进程试图修改一个私有内存映射的页时,内核就把该页框进行复制,并在进程页表中用复制的页来替换原来的页框,这是第八章中介绍的写时复制机制的应用之一。虽然原来的页框还仍然在页高速缓存中,但不再属于这个内存映射,这是由于被复制的页框替换了原来的页框。依次类推,这个复制的页框不会被插入到页高速缓存中,因为其中所包含的数据不再是磁盘上表示那个文件的有效数据。
        事实上,一个新建立的内存映射就是一个不包含任何页的线性区。当进程引用线性区中的一个地址时,缺页异常发生,缺页异常中断处理程序检查线性区的nopage方法是否被定义。如果没有定义nopage,则说明线性区不映射磁盘上的文件;否则,进行映射,这个方法通过访问块设备处理读取的页。几乎所有磁盘文件系统和块设备文件都通过filemap_nopage()函数实现nopage方法。
2.2、创建内存映射
        要创建一个新的内存映射,进程就要发出一个mmap()系统调用,并向该函数传递以下参数:
        * 文件描述符,标识要映射的文件。
        * 文件内的偏移量,指定要映射的文件部分的第一个字符。
        * 要映射的文件部分的长度。
        * 一组标志。进程必须显示地设置MAP_SHARED标志或MAP_PRIVATE标志来指定所请求的内存映射的种类。
        * 一组权限,指定对线性区进行访问的一种或者多种权限:读访问(PROT_READ)、写访问(PROT_WRITE)或执行访问(PROT_EXEC)。
        * 一个可选的线性地址,内核把该地址作为新线性区应该从哪里开始的一个线索。如果指定了MAP_FIXED标志,且内核不能从指定的线性地址开始分配新线性区,那么这个系统调用失败。
2.3、撤销内存映射
        当进程准备撤销一个内存映射时,就调用munmap();该系统调用还可用于减少每种内存区的大小。给它传递的参数如下:
        * 要删除的线性地址区间中第一个单元的地址。
        * 要删除的线性地址区间的长度。
        该系统调用的sys_munmap()服务例程实际上是调用do_munmap()函数。注意,不需要将待撤销可写共享内存映射中的页刷新到磁盘。实际上,因为这些页仍然在页高速缓存内,因此继续起磁盘高速缓存的作用。
2.4、内存映射的请求调页
        处于效率的原因,内存映射创建之后并没有立即把页框分配给它,而是尽可能向后推迟到不能再推迟----也就是说,当进程试图对其中的一页进行寻址时,就产生一个“缺页”异常。

2.5、把内存映射的脏页刷新到磁盘
        进程可以使用msync()系统调用把属于共享内存映射的脏页刷新到磁盘。这个系统调用所接收的参数为:一个线性地址区间的起始地址、区间的长度以及具有下列含义的一组标志。
        MS_SYNC:要求这个系统调用挂起进程,直到I/O操作完成为止。在这种方式中,调用进程就可以假设当系统调用完成时,这个内存映射中的所有页都已经被刷新到磁盘。
        MS_ASYNC(对MS_SYNC的补充):要求系统调用立即返回,而不用挂起调用进程。
        MS_INVALIDATE:要求系统调用使同一文件的其他内存映射无效(没有真正实现,因为在Linux中无用)。
2.6、非线性内存映射
        为了实现非线性映射,内核使用了另外一些数据结构。首先,线性区描述符的VM_NONLINEAR标志用于表示线性区存在一个非线性映射。给定文件的所有非线性映射线性区描述符都存放在一个双向循环链表,该链表根植于address_space对象的i_mmap_nonlinear字段。
三、直接I/O传送
        Linux提供了绕过页高速缓存的简单方法:直接I/O传送。在每次I/O直接传送中,内核对磁盘控制器进行编程,以便在自缓存的应用程序的用户态地址空间中的页与磁盘之间直接传送数据。
        我们知道,任何数据传送都是异步进行的。当数据传送正在进行时,内核可能切换当前进程,CPU可能返回到用户态,产生数据传送的进程的页可能被交换出去,等等。这对于普通I/O数据传送没有什么影响,因为它们涉及磁盘高速缓存中的页,磁盘高速缓存由内核拥有,不能被换出去,并且对内核态的所有进程都是可见的。
        另一方面,直接I/O传送应当在给定进程的用户态地址空间的页内移动数据。内核必须当心这些页是由内核态的任一进程访问的,当数据传送正在进行时不能把它们交换出去。
四、异步I/O
        “异步”实际上就是:当用户态进程调用库函数读写文件时,一旦读写操作进入队列函数就结束,甚至有可能真正的I/O数据传输还没有开始。这样调用进程可以在数据正在传输时继续自己的运行。
4.1、Linux2.6中的异步I/O

4.1.1、异步I/O环境
        基本上,一个异步I/O环境(简称AIO环境)就是一组数据结构,这个数据结构用于跟踪进程请求的异步I/O操作的运行情况。每个AIO环境与一个kioctx对象关联,它存放了与该环境有关的所有信息。一个应用可以创建多个AIO环境。一个给定进程的所有的kioctx描述符存放在一个单向链表中,该链表位于内存描述符的ioctx_list字段。
        AIO环是用户态进程中地址空间的内存缓冲区,它也可以由内核态的所有进程访问。kioctx对象中的ring_info.mmap_base和ring_info_mmap_size字段分别存放AIO环的用户态起始地址和长度。ring_info.ring_pages字段则存放有一个数组指针,该数组存放所有含AIO环的页框的描述符。
        AIO环实际上是一个环形缓冲区,内核用它来写正运行的异步I/O操作的完成报告。AIO环的第一个字节有一个首部(struct aio_ring 数据结构),后面的所有字节是io_event数据结构,每个都表示一个已完成的异步I/O操作。因为AIO环的页映射至进程的用户态地址空间,应用可以直接检查正运行的异步I/O操作的情况,从而避免使用相对较慢的系统调用。
4.1.2、提交异步I/O操作

版权声明:本文首发在云栖社区,遵循云栖社区版权声明:本文内容由互联网用户自发贡献,版权归用户作者所有,云栖社区不为本文内容承担相关法律责任。云栖社区已升级为阿里云开发者社区。如果您发现本文中有涉嫌抄袭的内容,欢迎发送邮件至:developer2020@service.aliyun.com 进行举报,并提供相关证据,一经查实,阿里云开发者社区将协助删除涉嫌侵权内容。

分享:
开发与运维
使用钉钉扫一扫加入圈子
+ 订阅

集结各类场景实战经验,助你开发运维畅行无忧

其他文章