页缓存
预读与页缓存
磁盘预读
当一个数据被用到时,其附近的数据也通常会马上被使用。程序运行期间所需要的数据通常比较集中。由于磁盘顺序读取的效率很高(不需要寻道时间,只需很少的旋转时间),因此对于具有局部性的程序来说,预读可以提高 IO 效率。
预读的长度一般为页(Page)的整倍数。页是计算机管理存储器的逻辑块,硬件及操作系统往往将主存和磁盘存储区分割为连续的大小相等的块,每个存储块称为一页(在许多操作系统中,页得大小通常为 4k),主存和磁盘以页为单位交换数据。当程序要读取的数据不在主存中时,会触发一个缺页异常,此时系统会向磁盘发出读盘信号,磁盘会找到数据的起始位置并向后连续读取一页或几页载入内存中,然后异常返回,程序继续运行。
页缓存
页缓存是 Linux 内核一种重要的磁盘高速缓存,它通过软件机制实现。但页缓存和硬件缓存的原理基本相同,将容量大而低速设备中的部分数据存放到容量小而快速的设备中,这样速度快的设备将作为低速设备的缓存,当访问低速设备中的数据时,可以直接从缓存中获取数据而不需再访问低速设备,从而节省了整体的访问时间。譬如当我们使用 free -h
命令查看系统的内存使用情况时,会发现已使用的内存总数(used)与可用内存总数(free)相加并不等于内存总数(total),这正是因为 OS 发现系统的物理内存有大量剩余时,为了提高 IO 的性能,就会使用多余的内存当做文件缓存。
Linux 不会直接操作物理内存,而是建立一个虚拟地址(可以理解成跟物理内存相对应的映射),即在物理内存跟进程之间增加一个中间层。每个用户空间的进程都有自己的虚拟内存,每个进程都认为自己所有的物理内存,但虚拟内存只是逻辑上的内存,要想访问内存的数据,还得通过内存管理单元(MMU)查找页表,将虚拟内存映射成物理内存。如果映射的文件非常大,程序访问局部映射不到物理内存的虚拟内存时,产生缺页(Page Fault)中断,OS 需要读写磁盘文件的真实数据再加载到内存。
Linux 底层提供了 mmap 将一个程序指定的文件映射进虚拟内存(Virtual Memory),对文件的读写就变成了对内存的读写,能充分利用 Page Cache 的特性;不过,如果对文件进行随机读写,会使虚拟内存产生很多缺页中断。在大多数情况下,内核在读写磁盘时都会使用页缓存:
-
内核在读文件时,首先在已有的页缓存中查找所读取的数据是否已经存在。如果该页缓存不存在,则一个新的页将被添加到高速缓存中,然后用从磁盘读取的数据填充它。如果当前物理内存足够空闲,那么该页将长期保留在高速缓存中,使得其他进程再使用该页中的数据时不再访问磁盘。
-
写操作与读操作时类似,直接在页缓存中修改数据,但是页缓存中修改的数据(该页此时被称为 Dirty Page)并不是马上就被写入磁盘,而是延迟几秒钟,以防止进程对该页缓存中的数据再次修改。
Direct IO 与 缓存 IO
Direct I/O 绕过 Page Cache,而缓存 I/O 都是写到 Page Cache 里就表示写请求完成,然后由文件系统的刷脏页机制把数据刷到磁盘。因此,使用缓存 I/O,掉电时有可能 Page Cache 里的脏数据未刷到磁盘上,造成数据丢失。缓存 I/O 机制中,DMA 方式可以将数据直接从磁盘读到 Page Cache 中,或者将数据从 Page Cache 直接写回到磁盘上,而不能直接在应用程序地址空间和磁盘之间进行数据传输,这样的话,数据在传输过程中需要在应用程序地址空间和 Page Cache 之间进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
而 Direct I/O 的优点就是通过减少操作系统内核缓冲区和应用程序地址空间的数据拷贝次数,降低了对文件读取和写入时所带来的 CPU 的使用以及内存带宽的占用,但是 Direct I/O 的读操作不能从 Page Cache 中获取数据,会直接从磁盘上读取,带来性能上的损失。一般 Direct I/O 与异步 I/O 结合起来使用提高性能,Direct I/O 要求用户态的缓冲区对齐,Direct I/O 一般用于需要自己管理缓存的应用如数据库系统。
页缓存的设计
页缓存至少需要满足以下两种需求。首先,它必须可以快速定位含有给定数据的特定页。其次,由于页高速缓存中的数据来源不同,比如普通文件、块设备等,内核必须根据不同的数据来源来选择对页缓存的适当操作。内核通过抽象出 address_space 数据结构来满足上述两种设计需求。
address_space 结构
address_space 结构是页高速缓存机制中的核心数据结构,该结构并不是对某一个页高速缓存进行描述,而是以页高速缓存的所有者(owner)为单位,对其所拥有的缓存进行抽象描述。页高速缓存中每个页包含的数据肯定属于某个文件,该文件对应的 inode 对象就称为页高速缓存的所有者。
页缓存与文件系统和内存管理都有联系。每个 inode 结构中都嵌套一个 address_space 结构,即 inode 字段中的 i_data;同时 inode 中还有 i_maping 字段指向所嵌套 address_spaces 结构。而 address_space 结构通过 host 字段反指向页高速缓存的所有者。页缓存的本质就是一个物理页框,因此每个页描述符中通过 mmaping 和 index 两个字段与高速缓存进行关联。mmaping 指向页缓存所有者中的 address_space 对象。index 表示以页大小为单位的偏移量,该偏移量表示页框内数据在磁盘文件中的偏移量。
address_space 结构中的 i_mmap 字段指向一个 radix 优先搜索树。该树将一个文件所有者中的所有页缓存组织在一起,这样可以快速搜索到指定的页缓存。内核中关于 radix 树有一套标准的使用方法,它不与特定的数据联系(与内核双联表类似),这样使得使用范围更加灵活。具体操作如下:
- radix_tree_lookup(): 在 radix 树中对指定节点进行查找;
- radix_tree_insert(): 在 radix 树中插入新节点;
- radix_tree_delete(): 在 radix 树中删除指定节点;
此外,该结构中的 a_ops 字段指向 address_space_operations 结构,该结构是一个钩子函数集,它表明了对所有者的页进行操作的标准方法。比如 writepage 钩子函数表示将页中的数据写入到磁盘中,readpage 表示从磁盘文件中读数据到页中。通常,这些钩子函数将页缓存的所有者(inode)和访问物理设备的低级驱动程序关联起来。该函数集使得内核在上层使用统一的接口与页缓存进行交互,而底层则根据页缓存中数据的来源具体实现。通过上面的描述,可以看到 address_space 结构中的优先搜索树和钩子函数集解决了页高速缓存的两个主要设计需求。
内核对页缓存的操作函数
内核对页缓存的基本操作包含了在一个页缓存所形成的 radix 树中查找,增加和删除一个页缓存。基于 radix 的基本操作函数,页高速缓存的处理函数如下:
- page_cache_alloc():分配一个新的页缓存;
- find_get_page():在页高速缓存中查找指定页;
- add_to_page_cache():把一个新页添加到页高速缓存;
- remove_from_page_cache():将指定页从页高速缓存中移除;
- read_cache_page():确保指定页在页高速缓存中包含最新的数据;