16页缓存和块缓存

16页缓存和块缓存

在这里插入图片描述

从外部存储设备如硬盘读取数据,比从物理内存读取数据要慢得多,因此Linux使用了缓存机制将已经读取的数据保存在物理内存中,供后续访问使用

在这里插入图片描述

性能和效率是内核开发中两个非常重要的因素。内核不仅依赖于一个精巧的整体框架来规范各个部分之间交互,还需要一个功能广泛的缓冲和缓存框架来提高系统的速度

缓冲和缓存利用一部分系统物理内存,确保最重要、最常使用的块设备数据在操作时可直接从主内存获取,而无须从低速设备读取。物理内存还用于存储从块设备读取的数据,使得随后对该数据的访问可直接在物理内存进行,而无须从外部设备再次取用

数据并非在每次修改后都立即写回,而是在一定的时间间隔之后才进行回写,时间间隔的长度取决于多种因素,如空闲物理内存的容量、物理内存中数据的利用率,等等。单个的写请求会被收集起来,并打包进行,这在总体上花费的时间较少。因而,延迟写操作在总体上改进了系统性能

但缓存也有负面效应,内核必须审慎地采用,如下所述:

  1. 通常,物理内存的容量比块设备小得多,因而只能缓存仔细挑选的部分数据
  2. 用于缓存的内存区不能分配给“普通”的应用程序数据。这减少了实际上可用的物理内存容量
  3. 如果系统崩溃(例如,由于停电),缓存包含的数据可能没有写回到底层的块设备。这造成了不可恢复的数据丢失

缓存是页交换或调页操作的逆操作(换页的相关信息,在第18章讨论)。尽管缓存牺牲了物理内存(使得不需要在块设备上进行低速操作),而实现页交换时,则是用低速的块设备来代替物理内存。因而内核必须尽力同时考虑到这两种机制,确保一种方法带来的好处不会被另一种方法的不利之处抵消,这不是件容易事

此前各章讨论了内核提供的一些方法,用于缓存特定的结构。slab缓存是一个内存到内存的缓存,其目的不是加速对低速设备的操作,而是对现存资源进行更简单、更高效的使用。dentry缓存也用于减少对低速块设备的访问,但它无法推广到通用场合,因为它是专门用于处理单一数据类型的

内核为块设备提供了两种通用的缓存方案

  1. 页缓存(page cache)针对以页为单位的所有操作,并考虑了特定体系结构上的页长度。一个主要的例子是许多章讨论过的内存映射技术。因为其他类型的文件访问也是基于内核中的这一技术实现的,所以页缓存实际上负责了块设备的大部分缓存工作
  2. 块缓存(buffer cache)以块为操作单位。在进行I/O操作时,存取的单位是设备的各个块,而不是整个内存页。尽管页长度对所有文件系统都是相同的,但块长度取决于特定的文件系统或其设置。因而,块缓存必须能够处理不同长度的块

虽然块缓存曾经是对块设备进行I/O操作的传统方法,但现在在这个领域中,块缓存只用于支持规模很小的读取操作,这种场合下高级方法可能显得比较笨重。目前用于块传输的标准数据结构已经演变为struct bio,该结构在第6章讨论。用这种方式进行块传输更为高效,因为它可以合并同一请求中后续的块,加速处理的进行

但表示对单个块的I/O操作时,块缓存仍然是首选方法,即使底层I/O是通过bio进行的。特别是对经常按块读取元数据的系统,与其他更为强大的结构相比,块缓存对此任务的处理更为容易。总而言之,块缓存并未失去自我,其存在并不仅仅是因为兼容性的原因

在许多场合下,页缓存和块缓存是联合使用的。例如,一个缓存的页在写操作期间可以划分为不同的块缓存,这样可以在更细的粒度下,识别出页被修改的部分。好处在于,在将数据写回时,只需要回写被修改的部分,无须将整页都传输回底层的块设备

16.1 页缓存的结构 页缓存结构体为address_space

页缓存处理内存页,虚拟内存和物理内存根据页划分为较小的单位。这不仅使得内核易于操作较大的地址空间,还支持一系列功能,如调页、按需加载、内存映射等。页缓存的任务在于,获得一些物理内存页,以加速在块设备上按页为单位执行的操作。

当然,页缓存的运作方式对用户应用程序是透明的,应用无法了解到底是在与块设备之间交互,还是与内存中的数据交互,在两种情况下,read和write系统调用的结果是相同的。

很自然,对内核来说,情况多少有些不同。为支持对缓存页的使用,必须在代码中各个不同的位置加入“锚标”,与页缓存交互。无论目标页是否在缓存中,用户进程所要的操作总是必须执行。在缓存命中时,将快速地执行适当的操作(这也是缓存的目的所在)。倘若缓存失效,必须先从底层块设备读取所需的页,这花费的时间是比较长的。在页读入内存之后,页将被插入到缓存中,因而后续的访问可以快速进行

在页缓存中搜索一页所花费的时间必须最小化,以确保缓存失效的代价尽可能低廉,因为在缓存失效时,进行搜索的计算时间实际上被浪费了。因而,页缓存设计的一个关键的方面就是,对缓存的页进行高效的组织

16.1.1 管理和查找页缓存中的页的机制说明(用基数树组织页缓存)

从大量数据的集合(页缓存)中快速获取单个数据元素(页)的问题,并不是Linux内核特有的。它对信息技术的所有领域来说都是一个共同的问题,在发展过程中衍生出了许多精巧复杂的数据结构,并经受住了时间的考验。对此用途而言,树数据结构是非常流行的,Linux也采用了这种结构来管理页缓存中包含的页,称为基数树(radix tree)

如下(这里给出的结构是简化过的,因为内核利用了各结点中额外的标记,来保存该结点中组织的页的具体信息。但这不影响树的基本结构):
在这里插入图片描述

该结构并不对应普遍采用的二叉或三叉搜索树。基数树也是不平衡的,换句话说,在树的不同分支之间,可能有任意数目的高度差。树本身由两种不同的数据结构组成,还需要另一种数据结构来表示叶,其中包含了有用的数据。因为页缓存组织的是内存页,因而基数树的叶子是page结构的实例,该事实并不会影响到树的实现。(内核源代码没有定义具体的数据类型来表示基数树的叶子,而使用了void指针。这意味着基数树还可以用于其他目的,当然目前尚未有其他用途。)

树的根由一个简单的数据结构表示,其中包含了树的高度(所包含结点的最大层次数目)和一个指针,指向组成树的的第一个结点的数据结构

结点本质上是数组。为简明起见,结点在图中表示为4个元素,但在内核源代码中,它们实际上有2RADIX_TREE_MAP_SHIFT项。由于RADIX_TREE_MAP_SHIFT通常定义为6,这使得每个数组有64项,比上图中所示的多很多。小型系统会将RADIX_TREE_MAP_SHIFT设置为4,以节省宝贵的内存

树的各结点通过一个唯一的键来访问,键是一个整数。

树结点的增删涉及的工作量都很少,因此缓存管理操作所涉及的时间开销可以降低到最低限度。

如上图所示,树的结点具备两种搜索标记(search tag)。二者用于指定给定页当前是否是脏的(即页的内容与后备存储器中的数据是不同的),或该页是否正在向底层块设备回写。重要的是,标记不仅对叶结点设置,还一直向上传递到根结点。如果某个层次n + 1的结点设置了某个标记,那么其在层次n的父结点也会获得该标记

这使得内核可以判断,在某个范围内是否有一页或多页设置了某个标记位。上图中提供了一个例子:由于第一层最左侧的指针设置了脏标记位,内核就知道在与对应的二级结点相关联的页中,有一个或多个设置了脏标记位。另一方面,如果某个高层的结点没有设置某一标记,内核就可以确定,与该结点的子结点相关联的页不会设置该标记

回想第3章的内容,我们知道每一页表示为一个struct page实例,每个page实例都具备一组标志。其中也包括了脏标志和回写标志。因而,页缓存中的标记,可用于快速判断某个区域中是否有脏页或正在回写的页,而无须扫描该区域中所有的页。但它们并不是用来代替page中的标志位的

16.1.2 回写修改的数据机制说明

由于页缓存的存在,写操作不是直接对块设备进行,而是对内存中的数据操作,修改的数据首先被收集起来,然后被传输到更低的内核层,在那里可以对写操作进一步优化,以完全利用各个设备的具体功能,这在第6章讨论过。这里只讲述从页缓存的视角所能看到的情形,主要涉及一个特定的问题:数据应该在何种时机回写?当然,这个问题自动包含了如何确定回写频率的问题

这个问题没有普遍正确的答案,因为不同的系统和不同的负荷状态都会导致非常不同的场景。例如,通宵运行的服务器只收到了极少量修改数据的请求,因而基本上不需要内核的这项服务。在个人计算机上,如果用户暂时休息一会,也会造成同样的场景。但这种情形可能会突然发生改变,例如服务器突然开始通过FTP传输大量数据,或PC用户开始进行编译,处理并产生大量数据。在这两种场景下,缓存最初的回写很少,但接下来,又突然需要与底层存储介质频繁进行同步

为此,内核同时提供了如下几个同步方案:

  1. 几个专门的内核守护进程在后台运行,称为pdflush,它们将周期性激活,而不考虑页缓存中当前的情况。这些守护进程扫描缓存中的页,将超出一定时间没有与底层块设备同步的页写回。早期的内核版本对此采用了一个用户空间守护进程,称为kudpated,通常仍然使用该名称来描述这一机制
  2. pdflush的第二种运作模式是:如果缓存中修改的数据项数目在短期内显著增加,则由内核激活pdflush
  3. 提供了相关的系统调用,可由用户或应用程序通知内核写回所有未同步的数据。最著名的是sync调用,因为还有一个同名的用户空间工具,是基于该调用的

为管理可以按整页处理和缓存的各种不同对象,内核使用了“地址空间”抽象,将内层中的页与特定的块设备(或任何其他系统单元,或系统单元的一部分)关联起来

此类地址空间,决不能与系统或处理器提供的虚拟地址空间和物理地址空间混淆。它是Linux内核提供的一个独立的抽象,只是用了同一名称而已。

最初,我们只对一个方面感兴趣。每个地址空间都有一个“宿主”,作为其数据来源。大多数情况下,宿主都是表示一个文件的inode(由于大部分缓存的页都来自于对文件的访问,大多数宿主对象,实际上都是普通文件。但也有可能,inode宿主是来自于伪块设备文件系统。在这种情况下,地址空间不是关联到一个文件,而是其所在的整个块设备或分区)。因为所有现存的inode都关联到其超级块(第8章讨论过),内核只需要扫描所有超级块的链表,并跟随相关的inode,即可获得被缓存页的列表。

通常,修改文件或其他按页缓存的对象时,只会修改页的一部分,而非全部。这在数据同步时引起了一个问题。将整页写回到块设备是没有意义的,因为内存中该页的大部分数据仍然与块设备是同步的。为节省时间,内核在写操作期间,将缓存中的每一页划分为更小单位的块缓存。在同步数据时,内核可以将回写操作限制在那些实际发生了修改块缓存上。因而,页缓存的思想没有受到危害

16.2 块缓存的结构

在Linux内核中,并非总使用基于页的方法来承担缓存的任务。内核的早期版本只包含了块缓存,来加速文件操作和提高系统性能。这是来自于其他具有相同结构的类UNIX操作系统的遗产。来自于底层块设备的块数据缓存在内存的块缓存中,可以加速读写操作。其实现包含在fs/buffers.c

与内存页相比,块不仅比较小(大多数情况下),而且长度是可变的,依赖于使用的块设备(或文件系统,如第9章所示)。

随着日渐倾向于使用基于页操作实现的通用文件存取方法,块缓存作为中枢系统缓存的重要性已经逐渐失去,主要的缓存任务现在由页缓存承担。另外,基于块的I/O的标准数据结构,现在已经不再是块缓存,而是struct bio。

块缓存用作小型的数据传输,一般涉及的数据量是与块长度可比拟的。文件系统在处理元数据时,通常会使用此类方法。而裸数据的传输则按页进行,而块缓存的实现也基于页缓存(与此不同的是,2.2及此前的内核版本对块缓存和页使用了独立的缓存。建立两个不同的缓存,则需要在二者之间同步花费很多努力,因此内核开发者在许多年前就决定要统一缓存方案)

块缓存在结构上由两个部分组成:

  1. 缓冲头(buffer head)包含了与块缓存状态相关的所有管理数据,包括块号、块长度、访问计数器等,将在下文讨论。数据不是直接存储在缓冲头之后,而是存储在物理内存的一个独立区域中通过缓冲头中一个指针访问
  2. 缓存中的数据保存在专门分配的页中,这些页也可能同时存在于页缓存中。这进一步细分了页缓存,如下图所示。在我们的例子中,页划分为4个长度相同的部分,每一部分由其自身的缓冲头描述。存储缓冲头的内存区域与存储缓冲中的数据的区域是无关的

在这里插入图片描述

  1. 基于页缓存实现的块缓存

    这使得页可以细分为更小的部分,各部分之间是完全连续的(因为块缓存数据和缓冲头数据是分离的)。因为一个块缓存由至少512字节组成,每页最多可包括MAX_BUF_PER_PAGE个块缓存。该常数定义为页长度的函数

    //include/linux/buffer_head.h
    //每页最多可包含的块缓存数
    #define MAX_BUF_PER_PAGE (PAGE_CACHE_SIZE / 512)
    

    缓存中数据修改时只要将页设为脏就行

  2. 不基于页缓存的块缓存

    当然,有些应用程序在访问块设备时,使用的是块而不是页,读取文件系统的超级块,就是一个实例。一个独立的块缓存用于加速此类访问该块缓存的运作独立于页缓存,而不是在其上建立的。为此,缓冲头数据结构(对块缓存和页缓存是相同的)群集在一个长度恒定的数组中,各个数组项按LRU(least recently used,最近最少使用)方式管理。在一个数组项用过之后,将其索引位置0(即移动到数组开头),其他数组项相应后移。这意味着最常使用的数组项位于数组的开头,而不常用的数组项将被后推,如果很长时间不用,则会“掉出”数组

    因为数组的长度,或者说LRU列表中的项数,是一个固定值,在内核运行期间不改变,内核无须运行独立的线程来将缓存长度修整为合理值。相反,内核只需要在一项“掉出”数组时,将相关的块缓存从缓存删除,以释放内存,用于其他目的

    16.5节将详细讨论块缓存实现的技术细节。此前,有必要讨论地址空间的概念,因为它是实现缓存功能的关键

16.3 地址空间 (页缓存)

在Linux的发展过程中,不仅缓存由面向块缓存演化为面向页,而且与此前的Linux版本相比,将被缓存的数据与其来源相关联的方法,也已经演变为一种更一般的方案。尽管在Linux及其他UNIX衍生物的初期,inode是缓存数据的唯一来源,但内核现在采用了更为通用的地址空间方案,来建立缓存数据与其来源之间的关联。尽管文件的内容构成缓存数据的一大部分,但地址空间的接口非常通用,使得缓存也能够容纳其他来源的数据,并快速访问。

地址空间如何融入到页缓存的结构中呢?它们实现了两个单元之间的一种转换机制

  1. 内存中的页分配到每个地址空间。这些页的内容可以由用户进程或内核本身使用各式各样的方法操作。
  2. 后备存储器指定了填充地址空间中页的数据的来源。地址空间关联到处理器的虚拟地址空间,是由处理器在虚拟内存中管理的一个区域到源设备(使用块设备)上对应位置之间的一个映射

如果访问了虚拟内存中的某个位置,该位置没有关联到物理内存页,内核可根据地址空间结构来找到读取数据的来源

为支持数据传输,每个地址空间都提供了一组操作(以函数指针的形式),以容许地址空间所涉及双方面的交互,例如,从块设备或文件系统读取一页,或写回一个修改的页。在讲述地址空间操作的实现之前,下一节将详细讲述所使用的数据结构

地址空间是内核中最关键的数据结构之一。对该数据结构的管理,已经演变为内核面对的最中心的问题之一。大量子系统(文件系统、页交换、同步、缓存)都围绕地址空间的概念展开。因而,这个概念可以认为是内核最根本的抽象机制之一,以重要性而论,该抽象可跻身于传统抽象如进程、文件之列

16.3.1 地址空间数据结构 address_space

地址空间的基础是address_space结构,简化后如下:

//include/linux/fs.h
//地址空间,用于管理文件(struct inode)映射到内存的页面(struct page)的结构体,因该叫物理页缓存结构体,页高速缓存(page cache)核心数据结构
struct address_space {
	struct inode		*host;		/*指向拥有该对象的索引节点的指针,如块设备,文件*//* owner: inode, block_device */
	struct radix_tree_root	page_tree;	/*所有者基数树根节点,保存了当前地址空间管理的页*//* radix tree of all pages */

	unsigned int		i_mmap_writable;/*地址空间中共享内存(VM_SHARED)映射数*//* count VM_SHARED mappings */
	struct prio_tree_root	i_mmap;		/*radix优先搜索树根节点,子节点为struct vm_area_struct->share->prio_tree_node,用于inode相关的所有普通内存映射(“普通”是指,这些映射不是用非线性映射机制创建的)。该树的任务在于,支持查找包含了给定区间中至少一页的所有内存区域,而辅助宏vma_prio_tree_foreach就用于该目的.映射的所有页都可以在树中找到*//* tree of private and shared mappings */
	struct list_head	i_mmap_nonlinear;/*地址空间(虚拟内存区域)中非线性内存区(VM_NONLINEAR)的链表头,元素为struct vm_area_struct->shared->vm_set->list,非线性映射是在remap_file_pages系统调用控制下,通过对页表的技巧性操作而建立的*//*list VM_NONLINEAR mappings */
	
	unsigned long		nrpages;	/*地址空间中的页总数*//* number of total pages */
	pgoff_t			writeback_index;/*最后一次回写操作的页的索引,即新的回写的起始*//* writeback starts here */
	const struct address_space_operations *a_ops;	/*对所有者页进行操作的函数集合*//* methods */
	unsigned long		flags;		/*错误位和内存分配器的标志.保存映射页所来自的GFP内存区的有关信息,也可以保存异步输入输出期间发生的错误信息,在异步I/O期间错误无法之间传递给调用者。AS_EIO代表一般性的I/O错误,AS_ENOSPC表示没有足够的空间来完成一个异步写操作*//* error bits/gfp mask */
	struct backing_dev_info *backing_dev_info; /*指向拥有所有者数据的块设备的信息backing_dev_info指针*//* device readahead, etc */

	struct list_head	private_list;	/*链表头,通常是与索引节点相关的间接块的脏缓冲区的链表.用于将包含文件系统元数据(通常是间接块)的buffer_head实例彼此连接起来*//* ditto */
	struct address_space	*assoc_mapping;	/*通常是指向间接块所在块设备的address_space对象的指针*//* ditto */
} __attribute__((aligned(sizeof(long))));

//include/linux/backing-dev.h
//后备存储设备信息
struct backing_dev_info {
	unsigned long ra_pages;	/*最大预读页数,单位为PAGE_CACHE_SIZE(一页的大小)*//* max readahead in PAGE_CACHE_SIZE units */
	unsigned long state;	/*状态,该成员使用原子位操作*//* Always use atomic bitops on this */
	unsigned int capabilities; /*设备能力,如存储的数据是否可以直接执行(ROM中的数据),页是否可以回写(RAM中不用回写,即在内存中),设置了BDI_CAP_NO_WRITEBACK那么不需要数据同步*//* Device capabilities */
    ...
};

地址空间 通过address_space->host(inode,建立与文件或块设备的关系), address_space->backing_dev_info(后备存储设备信息),address_space->page_tree(基数树中存着地址空间管理的页) 与存储设备和管理的页建立关系

address_space->i_mmap(优先搜索树,用于查找inode中的所有页)

地址空间与内核其他部分的最重要的部分的关联如下:
在这里插入图片描述

16.3.2 地址空间中的基数树使用

  1. 基数树结构体与操作函数

    内核使用了基数树来管理与一个地址空间相关的所有页,以便尽可能降低开销。对此类树的一般性概述已经在上文给出;现在我们来关注内核中与之对应的数据结构

    //include/linux/radix-tree.h
    struct radix_tree_root {//基数树根节点
        unsigned int		height;//树当前的高度
        gfp_t			gfp_mask;//指定了构建树所需的数据结构实例从哪个内存域分配
        struct radix_tree_node	*rnode;//第一个结点
    };
    
    //lib/radix-tree.c
    #define RADIX_TREE_MAX_TAGS 2
    #define RADIX_TREE_MAP_SHIFT	(CONFIG_BASE_SMALL ? 4 : 6)
    #define RADIX_TREE_MAP_SIZE	(1UL << RADIX_TREE_MAP_SHIFT)/*默认情况使用2的6次方=64,小型系统会使用4=16*/
    #define RADIX_TREE_TAG_LONGS	\
        ((RADIX_TREE_MAP_SIZE + BITS_PER_LONG - 1) / BITS_PER_LONG)
    
    struct radix_tree_node {//基数树节点,用于管理页缓存中包含的页
        unsigned int	height;		/* Height from the bottom *///当前结点在树中的高度
        unsigned int	count;//数组slots中已使用的数组项的数目
        struct rcu_head	rcu_head;
        void		*slots[RADIX_TREE_MAP_SIZE];//指针数组,根据结点在树中的层次,指向其他结点或数据元素,指向节点的数据,可以是任何类型的数据,数组顺序使用,以NULL表示结束,该类型指向数据(struct page)或其他结点.由数组大小为64可知,这个树结点可以有64个子结点,每个结点中的数组长度都只能为2的幂
        unsigned long	tags[RADIX_TREE_MAX_TAGS][RADIX_TREE_TAG_LONGS];//每一位表示一个标记,用于表示结点中的页是否具有标记中指定的属性。可以向slots中每个数组项都附加 RADIX_TREE_MAX_TAGS 个标记,默认值是2,第一维区分不同的标记,第二维使得该结点中的每个页都能对应一个比特位.如在地址空间中用于标记结点中有脏页,PAGECACHE_TAG_DIRTY指定页是否是脏的,PAGECACHE_TAG_WRITEBACK表示该页当前正在回写
    };
    
    //对基数树中对应结点设置要标记的比特位,并将修改的信息传递到所有父结点,修改的标记变量为 radix_tree_root->rnode->tags
    void *radix_tree_tag_set(struct radix_tree_root *root,
                unsigned long index, unsigned int tag)
    //查找基数树中结点是否设置了标记
    int radix_tree_tagged(struct radix_tree_root *root, unsigned int tag)
    
    //将一个新结点插入到基数树
    int radix_tree_insert(struct radix_tree_root *root,
                unsigned long index, void *item)
    //在基数树中根据键值查找结点
    void *radix_tree_lookup(struct radix_tree_root *root, unsigned long index)
    //根据键值,删除基数树中对应的数据项。如果删除成功,则返回指向被删除对象的指针
    void *radix_tree_delete(struct radix_tree_root *root, unsigned long index)
    //检查指定的基数树结点上是否设置了某个标记。如果设置了标记,则函数返回1,否则返回0。
    int radix_tree_tag_get(struct radix_tree_root *root,
                unsigned long index, unsigned int tag)
    //清除标记比特位,从基数树根到页的每个节点都会改变.在成功的情况下,将返回被标记数据项的地址 radix_tree_root->rnode->tags
    void *radix_tree_tag_clear(struct radix_tree_root *root,
                unsigned long index, unsigned int tag)
    //确保CPU缓存中有预分配的基数树结点,基数树结点插入前调用
    int radix_tree_preload(gfp_t gfp_mask)
    
    //CPU缓存池,存了预分配的基数树结点,用于加速基数树结点插入操作
    struct radix_tree_preload {
        int nr;
        struct radix_tree_node *nodes[RADIX_TREE_MAX_PATH];
    };
    

    为确保基数树的操作快速,内核使用了一个独立的slab缓存来保存radix_tree_node的实例,以便快速分配此类型的结构实例
    slab缓存只存储了创建树所需的数据结构。它与被缓存页的内存完全无关,后者的分配和管理是独立的

    每个基数树都还有一个CPU池,其中存放了预分配的结点,以便进一步加速向树插入新数据项的操作。radix_tree_preload是一个函数,它保证在该缓存中至少有一个结点。在使用radix_tree_insert向基数树添加数据项之前,总是会调用该函数(在以后几节里,将忽略这一点)(更精确地说,插入操作嵌入在radix_tree_preload()和radix_tree_preload_end()之间。对各CPU变量的使用,意味着必须停用内核抢占(参见第2章),在操作完成后重新启用。当前这是radix_tree_preload_end的唯一任务)

  2. 使用基数树的锁

    基数树没有针对通常的并发访问提供任何形式的保护。按照内核的惯例,对锁或其他同步原语的处理,是使用基数树的各子系统的职责,如第5章所述。但对几个重要的读取函数来说,有一个例外。其中包括进行查找操作的radix_tree_lookup、获得某个基数树结点标记的radix_tree_tag_get、和测试树中是否有数据项带有标记的radix_tree_tagged。

    如果前两个函数被rcu_read_lock()… rcu_read_unlock()包围,那么不进行特定于子系统的锁定操作,即可调用这两个函数,而第三个函数根本不需要任何锁

    rcu_head提供了基数树结点和RCU实现之间的关联。请注意,关于如何对基数树实现适当的同步,<radix-tree.h>包含了更多建议

16.3.3 地址空间操作

地址空间将后备存储器与内存区关联起来。在二者之间传输数据,不仅需要数据结构,还需要相应的函数。因为地址空间可用于不同的组合,所需的函数不是静态定义的,而是根据具体的映射借助一个结构来确定,其中保存了指向适当实现的函数指针

//include/linux/fs.h
/*对地址空间中的页与存储设备间进行操作的函数集合*/
struct address_space_operations {
	int (*writepage)(struct page *page, struct writeback_control *wbc);//写操作(将一页从内存写到磁盘),向块设备提交了一个请求
	int (*readpage)(struct file *, struct page *);//读一页操作(从磁盘读到页),向块设备提交了一个请求
	void (*sync_page)(struct page *);//对尚未回写到后备存储器的数据进行同步。不同于writepage,该函数在块层的层次上运作,试图将仍然保存在缓冲区中的待决写操作写入到块层。与此相反,writepage在地址空间的层次上运作,只是将数据转发到块层,而不关注块层中的缓冲问题

	/* Write back some dirty pages from this mapping. */
	int (*writepages)(struct address_space *, struct writeback_control *);//把指定数量的脏页写回磁盘

	/* Set a page dirty.  Return true if this dirtied it */
	int (*set_page_dirty)(struct page *page);//把页设为脏页,即已与磁盘中内容不同

	int (*readpages)(struct file *filp, struct address_space *mapping,
			struct list_head *pages, unsigned nr_pages);//从磁盘读页的链表

	/*
	 * ext3 requires that a successful prepare_write() call be followed
	 * by a commit_write() call - they must be balanced
	 */
	int (*prepare_write)(struct file *, struct page *, unsigned, unsigned);//为写操作做准备(由磁盘文件系统使用),将事务数据存储到日志中
	int (*commit_write)(struct file *, struct page *, unsigned, unsigned);//完成写操作(由磁盘文件系统使用)

	int (*write_begin)(struct file *, struct address_space *mapping,
				loff_t pos, unsigned len, unsigned flags,
				struct page **pagep, void **fsdata);
	int (*write_end)(struct file *, struct address_space *mapping,
				loff_t pos, unsigned len, unsigned copied,
				struct page *page, void *fsdata);

	/* Unfortunately this kludge is needed for FIBMAP. Don't use it */
	sector_t (*bmap)(struct address_space *, sector_t);//从文件块索引中获取逻辑块号,将地址空间内的逻辑块偏移量映射为物理块号
	void (*invalidatepage) (struct page *, unsigned long);//使所有者的页无效(截断文件时使用).如果一页将要从地址空间移除,而通过PG_Private标志可判断有缓冲区与之相关,则调用该函数
	int (*releasepage) (struct page *, gfp_t);//由日志文件系统使用以准备释放页
	ssize_t (*direct_IO)(int, struct kiocb *, const struct iovec *iov,
			loff_t offset, unsigned long nr_segs);//页的直接I/O传输(绕过页高速缓存).允许应用程序非常直接地与块设备进行通信。大型数据库会频繁使用该特性,因为与内核的通用机制相比,它们能更好地预测未来的输入输出情况,因而通过自行实现的缓存机制,能够达到更好的效果
	struct page* (*get_xip_page)(struct address_space *, sector_t,
			int);//用于就地执行(execute-in-place)机制,该机制可用于启动可执行代码,而无须将其先加载到页缓存。这对有些场合是有用的,例如,基于内存的文件系统如RAM磁盘,或在内存较少的小型系统上,CPU可直接寻址ROM区域包含的文件系统.很少使用
	/* migrate the contents of a page to the specified target */
	int (*migratepage) (struct address_space *,
			struct page *, struct page *);//重新定位一页,即将一页的内容移动到另外一页.由于页通常都带有私有数据,只是将两页对应的物理页帧的裸数据进行复制是不够的。举例来说,支持内存热插拔就需要对页进行移动。
	int (*launder_page) (struct page *);//在释放页之前,提供了回写脏页的最后的机会
};

writepage,prepare_write和commit_write并不直接发起I/O操作(换句话说,它们不向块层发送相应的命令),在标准实现中,它们只将整个页或其中部分标记为脏写操作由一个内核守护进程触发,该进程专用于此,会周期性地检查现存的页

Documentation/filesystems/vfs.txt

大多数地址空间都没有实现所有的函数,对某些函数指定了NULL指针。在许多情况下,会调用内核的默认例程,而不是具体地址空间所提供的特定实现。接下来,我们将讲述内核提供的几个address_space_operations实例,给出可用选项的概述

Ext3文件系统定义了ext3_writeback_aops全局变量,它是一个填充好的address_space_operations实例。其中包含了用于回写的函数

//fs/ext3/inode.c
static const struct address_space_operations ext3_writeback_aops = {
	.readpage	= ext3_readpage,
	.readpages	= ext3_readpages,
	.writepage	= ext3_writeback_writepage,
	.sync_page	= block_sync_page,
	.write_begin	= ext3_write_begin,
	.write_end	= ext3_writeback_write_end,
	.bmap		= ext3_bmap,
	.invalidatepage	= ext3_invalidatepage,
	.releasepage	= ext3_releasepage,
	.direct_IO	= ext3_direct_IO,
	.migratepage	= buffer_migrate_page,
};

上面的回调函数很多实际调用了内核提供的通用辅助函数,只是将参数转为了通用函数的参数然后调用:
在这里插入图片描述

共享内存文件系统的address_space_operations实例:

//mm/shmem.c
static const struct address_space_operations shmem_aops = {
	.writepage	= shmem_writepage,
	.set_page_dirty	= __set_page_dirty_no_writeback,
	.migratepage	= migrate_page,
};

需要实现的操作只有:将页标记为脏,页的回写,页的迁移。共享内存不需要其他操作(如果启用了tmpfs,其实现基于共享内存,那么也会实现readpage、write_begin和write_end。)。在这里,内核使用的后备存储器是什么呢?共享内存文件系统的内存完全独立于具体的块设备,因为该文件系统中所有的文件都是动态生成的(例如,从另一个文件系统复制某个文件的内容,或将计算出的数据写入一个新文件),这些文件并不存在于任何源块设备上。

当然,内存不足也会影响到该文件系统的页,使得有必要将某些页写回到后备存储器。因为该文件系统没有真正的后备存储器,可使用交换区替代。普通文件需要写回到其在硬盘(或其他块设备)上的文件系统,以释放所用的页帧,而共享内存文件系统的文件则必须保存到交换区

由于对块设备的访问并不总是经由文件系统,也可能直接访问裸设备,也有支持直接操作块设备内容的地址空间操作(例如,在从用户空间创建文件系统上,就需要此类访问模式)。
直接访问裸设备块的实现:

//fs/block_dev.c
const struct address_space_operations def_blk_aops = {
	.readpage	= blkdev_readpage,
	.writepage	= blkdev_writepage,
	.sync_page	= block_sync_page,
	.write_begin	= blkdev_write_begin,
	.write_end	= blkdev_write_end,
	.writepages	= generic_writepages,
	.direct_IO	= blkdev_direct_IO,
};

这里仍然使用了大量专门的函数来实现此项功能需求,但这些实现也会迅速归结到内核的标准函数,如下所示:

在这里插入图片描述

内核对文件系统和直接访问块设备所需地址空间操作的实现有许多共同之处,因为二者共享了同一组辅助函数

16.4 页缓存的实现

页缓存的实现基于基数树。尽管该缓存属于内核中性能要求最苛刻的部分之一,而且广泛用于内核的所有子系统,但其实现简单得惊人。能做到这一点,精心设计的数据结构是一个必要前提

16.4.1 分配页加入页缓存

page_cache_alloc用于为一个即将加入页缓存的新页分配数据结构。与后缀为_cold的变体工作方式相同,但试图获取一个冷页(对CPU高速缓存而言):

//include/linux/pagemap.h
//从伙伴系统获取页用于热页,后面会调用其他函数将该页加入页缓存中
static inline struct page *page_cache_alloc(struct address_space *x)
//从伙伴系统获取页用于冷页,后面会调用其他函数将该页加入页缓存中
static inline struct page *page_cache_alloc_cold(struct address_space *x)

将页加入页缓存address_space的过程:

  • page_cache_alloc 从伙伴系统获取页用于热页

  • add_to_page_cache_lru 将该页插入到页缓存的LRU链表中

    • add_to_page_cache 将新页添加到页缓存
      • radix_tree_insert 将页加入地址空间的基数树中
      • 在页缓存中的索引和指向页所属地址空间的指针保存在struct page的对应成员中(index和mapping)。最后,将地址空间的页计数(nrpages)加1,因为地址空间中现在又多了一页
  • page_cache_alloc 从伙伴系统获取页用于热页

  • add_to_page_cache 将新页添加到页缓存

16.4.2 查找页缓存中的页

在系统需要判断给定页是否已经在页缓存中时,保存所有缓存页的基数树特别有用。find_get_page用于查找:

//mm/filemap.c
//从页缓存基数树中查找页,没找到放回null
struct page * find_get_page(struct address_space *mapping, pgoff_t offset)
  • find_get_page 查找页缓存中的页
    • radix_tree_lookup 将查找操作交给基数树的实现

但在很多情况下,页是属于文件的。遗憾的是,文件中的位置是按字节偏移量指定的,而非页缓存中的偏移量。如何将文件偏移量转换为页缓存偏移量呢?

当前,页缓存的粒度是单个页,即页缓存基数树的页结点是一个页。但未来的内核可能增加该缓存的粒度,因而假定缓存的粒度为单页是不可靠的。相反,内核提供了PAGE_CACHE_SHIFT宏。页缓存结点的对象长度,可通过2PAGE_CACHE_SHIFT计算

那么,在文件的字节偏移量和页缓存偏移量之间的转换就变得比较简单,将文件偏移量右移PAGE_CACHE_SHIFT位即可:

index = ppos >> PAGE_CACHE_SHIFT;

ppos是文件的字节偏移量,而index则是页缓存中对应的偏移量

为方便使用,内核提供了两个辅助函数:

//mm/filemap.c
//在页缓存中查找一页,如果没有则分配一个新页
struct page *find_or_create_page(struct address_space *mapping,
		pgoff_t index, gfp_t gfp_mask)
//与find_get_page类似,查找页缓存中的页,但会锁定该页.如果该页已经被内核的其他部分锁定,该函数可以睡眠,直至页被解锁
struct page *find_lock_page(struct address_space *mapping,
				pgoff_t offset)

查找多个页:

//mm/filemap.c
//从页缓存偏移量start开始,返回映射中最多nr_pages页放入pages中,不保证页是连续的,不存在的页会形成空洞,返回值是找到的页的数目
unsigned find_get_pages(struct address_space *mapping, pgoff_t start,
			    unsigned int nr_pages, struct page **pages)
//从页缓存偏移量index开始,返回映射中最多nr_pages页放入pages中,保证页是连续的,遇到第一个空洞时停止查找,返回值是找到的页的数目
unsigned find_get_pages_contig(struct address_space *mapping, pgoff_t index,
			       unsigned int nr_pages, struct page **pages)
//从页缓存偏移量index开始,返回映射中指定标记tag最多nr_pages页放入pages中,不保证页是连续的,不存在的页会形成空洞,返回值是找到的页的数目,index参数中将包含一个页缓存的索引,指向pages数组中最后一页的下一页
unsigned find_get_pages_tag(struct address_space *mapping, pgoff_t *index,
			int tag, unsigned int nr_pages, struct page **pages)

16.4.3 等待对页的操作完成

内核经常需要在页上等待,直至其状态改变为某些预期值。例如,数据同步的实现有时候需要确保对某页的回写操作已经结束,而内存页中的内容与底层块设备的数据是相同的。处于回写过程中的页会设置PG_writeback标志位。

内核提供了wait_on_page_writeback函数,用于等待页的该标志位清除:

//include/linux/pagemap.h
/* 睡眠等待页写回操作结束后唤醒 */
static inline void wait_on_page_writeback(struct page *page)
/* 睡眠等待被锁定的页解锁后唤醒 */
static inline void wait_on_page_locked(struct page *page)
  • wait_on_page_writeback 睡眠等待页写回操作结束后唤醒

    • wait_on_page_bit 安装等待队列,进程可以在其上睡眠,直至 PG_writeback 标志位从页的标志中清除
  • wait_on_page_locked

    • wait_on_page_bit

16.4.4 读写页,通过提交bio请求

虽然名为“块”设备,但现代块设备可以在一个操作中传输比块大得多的数据单位,以提升系统性能。从Linux也可以反映出这一点,内核在块设备与内存之间传输数据时,相关的算法和数据结构都以页为基本单位。在整页处理数据时,逐缓冲区/块的传输实际上是对性能踩刹车。在重新设计块层的过程中,内核版本2.5开发期间引入了BIO,以替换块缓存,来处理与块设备的数据传输。内核添加了4个新的函数,来支持读写一页或多页

//include/linux/mpage.h
/*
发出读取多个页的bio请求
该函数需要`nr_pages`个page实例,以链表的形式通过参数传递进来。`mapping`是相关的地址空间,而`get_block`k照例用于查找匹配的块地址
*/
int mpage_readpages(struct address_space *mapping, struct list_head *pages,
				unsigned nr_pages, get_block_t get_block);
int mpage_readpage(struct page *page, get_block_t get_block);
int mpage_writepages(struct address_space *mapping,
		struct writeback_control *wbc, get_block_t get_block);
int mpage_writepage(struct page *page, get_block_t *get_block,
		struct writeback_control *wbc);

这4个函数的实现有很多共同之处(其目标都是构建一个适当的BIO实例,用于对块层进行传输),接下来以其中一个为例进行讨论,即mpage_readpages。该函数需要nr_pages个page实例,以链表的形式通过参数传递进来。mapping是相关的地址空间,而get_block照例用于查找匹配的块地址

  • mpage_readpages 发出读取多个页的bio请求
    • 遍历传入的page链表对每页进行如下操作:
      • add_to_page_cache_lru 将页加入地址空间
      • do_mpage_readpage 创建一个bio请求,也包括了此前各页的BIO数据,如果由可以合并的bio请求则构造一个合并的请求,从块层读取所需的数据
    • 循环结束后如果创建了bio请求,则提交请求 mpage_bio_submit

16.4.5 页缓存预读 ondemand_readahead

在进程从文件读取数据时,通常,页是顺序读取的,这也是大多数文件系统的假定。回想第9章的内容,Ext文件系统族做了很多工作,试图为一个文件分配相邻的块,使得块设备的读写头在读写数据时可以尽可能少移动。

考虑一个进程从位置A到B线性读取一个文件内容的情形。这个操作通常会持续片刻。因而从B向前预读(假定,预读到位置C)是有意义的,在进程发出请求读取B和C之间的页时,这些数据已经在页缓存中了

很自然,预读不能由页缓存独立解决,还需要VFS和内存管理层的支持。实际上,预读机制已经在8.5.2节和8.5.1节讨论过。回想可知,就内核直接关注的问题而言,预读是从3个地方控制的(实际上,在用户层可以用madvise、fadvise和readahead系统调用影响预读机制)

  1. do_generic_mapping_read,这是一个通用的读取例程,其中,大多数依赖内核的标准例程来读取数据的文件系统都结束于某些位置
  2. 缺页异常处理程序filemap_fault,它负责为内存映射读取缺页
  3. __generic_file_splice_read,调用该例程是为支持splice系统调用,该系统调用使得可以直接在内核空间中在两个文件描述符之间传输数据,而无须涉及用户空间(本书中其他地方将不会更详细地讨论该系统调用,更多的信息请读者参考手册页splice(2)。)

各预读例程在源代码层次上的时序控制已经在第8章讨论过,但从一个更高的层次来考察其行为仍然是有益的。下图提供了这样的一个视角。为简单起见,下文只考虑do_generic_mapping_read。

在这里插入图片描述

  • do_generic_mapping_read 从映射读取
    • find_get_page 在页高速缓存中查找
    • page_cache_sync_readahead 如果没在缓存中,调用该函数发出同步预读请求
    • page_cache_async_readahead 如果还没有,根据设置的标志调用该函数发出异步预读请求
    • mapping->a_ops->readpage(实际为mpage_readpage函数) 如果读到了数据,判断数据是否为最新的,如果不是最新的调用该函数读取最新数据(该函数只向块设备提交了一个bio请求,没进行实际读操作)

假定进程已经打开了一个文件,想要读取第一页。该页尚未读入页缓存。由于通常的所有者不会只读取一页,而是顺序读取多页,内核采用page_cache_sync_readahead读取一行中的8页,这个数字只是举例来说,实际上不见得如此。第一页对do_generic_mapping_read来说是立即可用的(实际上,内核在这里使用的术语同步,有一点误导。内核并未等待由page_cache_sync_readahed提交的读操作完成,因此在通常意义上这不是同步的。但由于读入一页比较快速,在page_cache_sync_readahead返回到调用者时,目标页已经读入页缓存的几率是很高的。但调用者必须小心目标页尚未读入的情况)。而在实际需要之前就被选择读入页缓存的页,则称为处于预读窗口中

进程现在继续读取接下来的各页,与我们的预期相同。在访问第6页时(请注意,在进程发出读请求之前,该页已经读入页缓存),do_generic_mapping_read注意到,该页在同步读取处理过程中设置了PG_Readahead标志位(由于预读状态是针对每个文件分别跟踪的,内核在本质上不需要这个专门的标志,因为没有该标志,也可以获得相应的信息。但在多个并发的读取操作作用于一个文件时,是需要该标志的)。这触发了一个异步操作,在后台读取若干页。由于页缓存中还有两页可用,不必匆忙读取,所以不需要一个同步操作。但在后台进行的I/O操作,将确保在进程进一步读取文件时,相关页已经读入缓存。如果内核不采用这种方案,预读只能在进程遇到一个缺页异常后开始。虽然所需的页(以及另一些预读的页)可以同步读入页缓存,但这将引入延迟,显然不是我们期待的情形。

现在将进一步重复这种做法。由于page_cache_async_read(负责发出异步读请求)又将预读窗口中的一页标记为PG_Readahead,在进程遇到该页时,将再次开始异步预读,依此类推。

do_generic_readahead就讲到这里。filemap_fault的处理方式,与do_generic_readahead的区别有两个方面:仅当设置了顺序读取提示的情况下,才会进行异步自适应的预读。如果没有设置预读提示,那么do_page_cache_readahead只进行一次预读,而不设置PG_Readahead,也不会更新文件的预读状态跟踪信息。

预读机制的实现涉及几个函数。下图说明了这些函数彼此的关联。
在这里插入图片描述

从技术角度来看,在实际需要页之前将其读入页缓存是简单的,用本章中到目前为止介绍的框架可以轻易实现。问题在于预测预读窗口的最优长度。为此,内核会记录每个文件上一次的设置。下列数据结构将关联到每个file实例:

//include/linux/fs.h
//跟踪文件的预读状态
struct file_ra_state {
	pgoff_t start;			/*预读起始位置*//* where readahead started */
	unsigned int size;		/*预读的页数,预读窗口的长度*//* # of readahead pages */
	unsigned int async_size;	/* 剩余预读页的最小值。如果预读窗口中只有这么多页,那么将发起异步预读,将更多页读入页缓存 *//* do asynchronous readahead when
					   there are only # of pages ahead */

	unsigned int ra_pages;		/*预读最大页数,预读窗口的最大长度。内核读入的页数可以比这个值少,但决不会比这个值多*//* Maximum readahead window */
    ...
	loff_t prev_pos;		/*前一次读取时,最后访问的位置,这个偏移量是文件中的字节偏移量,不是页缓存中的页偏移量*//* Cache last read() position */
};

prev_pos 最重要的提供者是do_generic_mapping_read和filemap_fault

ondemand_readahead例程负责实现预读策略,即判断读入多少当前并不需要的页。如上图所示,page_cache_sync_readaheadpage_cache_async_readahead都依赖于该函数。在确定预读窗口的长度之后,调用ra_submit,将技术性问题委托给__do_page_cache_readahead完成。在这里,页是在页缓存中分配的,而后由块层填充

在讨论ondemand_readahead之前,需要介绍两个辅助函数:get_init_ra_size为一个文件确定最初的预读窗口长度,而get_next_ra_size为后来的读取计算窗口长度,即此时已经有一个先前的预读窗口存在。get_init_ra_size根据进程请求的页数目来确定窗口长度,而get_next_ra_size则根据前一个预读窗口的长度来计算新的窗口长度。两个函数都会确保预读窗口的长度不超过特定于文件的上限值。虽然该上限可用fadvise系统调用修改,但通常都设置为VM_MAX_READAHEAD * 1024/ PAGE_CACHE_SIZE,在页长度为4 KiB的系统上,相当于32页。两个函数的结果如下图所示。下图说明了初始预读窗口长度随请求长度的变化关系,以及后续的预读窗口长度随前一个预读窗口长度的变化关系。从数学意义上说,最大的预读长度相当于这两个函数的一个不动点。实际上,这意味着预读窗口长度决不能超过最大的容许值,在这里是32页。
在这里插入图片描述

我们返回到ondemand_readahead,该函数必须借助这两个辅助函数来设置预读窗口长度。如下三种情形是最基本的

  1. 当前偏移量在前一个预读窗口末尾,或在同步读取范围的末尾。在这两种情况下,内核假定进程在进行顺序读取,使用上文讨论的get_next_ra_size来计算新的预读窗口长度
  2. 如果遇到了预读标记,但与前一次预读的状态不符,那么很可能有两个或更多并发的控制流在交错地读取文件,使得对方的预读状态无效。内核将构建一个新的预读窗口,以适应所有的读取者
  3. 如果是在对文件进行第一次读取(特别是这种情况)或发生了缓存失效,则用get_init_ra_size建立一个新的预读窗口
  • ondemand_readahead 实现预读策略,即判断读入多少当前并不需要的页
    • 当前偏移量在前一个预读窗口末尾,或在同步读取范围的末尾.这时内核假定进程在进行顺序读取
      • get_next_ra_size 根据前一次读取的页数计算新的预读长度
      • 提交bio请求,函数结束
    • 如果遇到了预读标记,但与前一次预读的状态不符,那么很可能有两个或更多并发的控制流在交错地读取文件,使得对方的预读状态无效。内核将构建一个新的预读窗口,以适应所有的读取者
      • get_next_ra_size 根据前一次读取的页数计算新的预读长度
      • 提交bio请求,函数结束
    • 如果是在对文件进行第一次读取(特别是这种情况)或发生了缓存失效
      • 为一个文件确定最初的预读窗口长度
      • 提交bio请求,函数结束

16.5 块缓存的实现

块缓存不仅仅用作页缓存的附加功能,对以块而不是页进行处理的对象来说,块缓存是一个独立的缓存

16.5.1 数据结构

两种类型的块缓存,即独立的块缓存和用作页缓存附加功能的块缓存,二者的数据结构是相同的。块缓存主要的数据元素是缓冲头:

//块缓存头结构
struct buffer_head {
	unsigned long b_state;		/*位图,缓冲头的当前状态,每一位的含义为枚举 bh_state_bits,几个值可以同时置位*//* buffer state bitmap (see above) */
	struct buffer_head *b_this_page;/*链表元素,建立缓存头的循环链表,链表头为 struct page->private,基于页的块缓存,将一页用于多个块缓存来存储数据,属于同一页的块缓存通过该变量组成链表*//* circular list of page's buffers */
	struct page *b_page;		/*在块缓存基于页缓存实现的情况下,当前缓冲头相关的page实例。如果块缓存是独立于页缓存的,则b_page为NULL指针*//* the page this bh is mapped to */

	sector_t b_blocknr;		/*块设备的块号*//* start block number */
	size_t b_size;			/*块大小*//* size of mapping */
	char *b_data;			/*指向块缓存的数据,一页用于多个块缓存,指向了一页中某个块缓存数据的起始位置,通过b_size可计算出数据结束位置*//* pointer to data within the page */

	struct block_device *b_bdev;/*块设备,标识了数据来源*/
	bh_end_io_t *b_end_io;		/*函数,在涉及该缓冲区的一个I/O操作完成时,由内核自动调用(bio相关函数会调用该函数)。这使得内核可以将进一步的缓冲区处理推迟到预期的输入/输出操作实际完成时。*//* I/O completion */
 	void *b_private;		/*指向b_end_io函数的参数,它主要由日志文件系统使用。如果不需要,通常设置为NULL*//* reserved for b_end_io */
	...
	atomic_t b_count;		/*访问计数*//* users using this buffer_head */
};

enum bh_state_bits {
	BH_Uptodate,	/*缓冲区当前的数据与存储设备上的相同*//* Contains valid data */
	BH_Dirty,	/*缓冲区中的数据已经修改,不再与存储设备上的相同*//* Is dirty */
	BH_Lock,	/*缓冲区被锁定,以便进行进一步的访问。缓冲区在I/O操作期间会显式锁定,以防几个线程并发处理缓冲区,导致彼此干扰*//* Is locked */
	BH_Req,		/* Has been submitted for I/O */
	BH_Uptodate_Lock,/* Used by the first bh in a page, to serialise
			  * IO completion of other buffers in the page
			  */

	BH_Mapped,	/*缓冲区被映射到磁盘就置位,即缓冲区头的b_bdev和b_blocknr是有效的就置位。所有起源于文件系统或直接访问块设备的缓冲区,都是这样*//* Has a disk mapping */
	BH_New,		/*新创建的缓冲区,相应的块刚被分配而还没有被访问过*//* Disk mapping was newly created by get_block */
  ...
};

BH_Uptodate和BH_Dirty也可以同时设置,通常都是这样。在块缓存填充了来自块设备的数据之后会设置BH_Uptodate,而在内存中的数据修改以后尚未写回之前,内核会设置BH_Dirty

内核定义了set_buffer_foo和get_buffer_foo函数,来为BH_Foo设置和读取块缓存状态位 buffer_head->b_state

16.5.2 块缓存的操作

内核必须提供一组操作,使得其余代码能够轻松有效地利用块缓存的功能。本节描述用于创建和管理新缓冲头的机制

这些机制对内存中实际缓存的数据没有贡献

内核源代码确实提供了一些函数,可用作前端,来创建和销毁缓冲头。alloc_buffer_head生成一个新缓冲头,而free_buffer_head销毁一个现存的缓冲头。二者都定义在fs/buffer.c中。与读者预期的相同,这两个函数只使用了内存管理的函数,还涉及一些统计工作,这些无须在此处讨论。

16.5.3 页缓存和块缓存的交互

本节将讨论页与缓冲头之间的关联

  1. 页和缓冲头的关联 page->private=head
    一页划分为几个数据单元(实际的数目取决于页长度和块长度,随体系结构而变),但缓冲头保存在独立的内存区中,与实际数据无关。与块缓存的交互没有改变的页的内容,块缓存只不过为页的数据提供了一个新的视图

    为支持页与块缓存的交互,需要使用struct pageprivate成员。其类型为unsigned long,可用作指向虚拟地址空间中任何位置的指针(page的确切定义已经在第3章给出):

    //include/linux/mm_types.h
    /* 页描述符,每个物理内存页(页帧),都对应于一个 struct page 实例 */
    struct page {
    ...
    unsigned long private;
    ...
    }
    

    private成员还可以用作其他用途,根据页的具体用途,可能与缓冲头完全无关(如果页位于交换缓存,则缓存中也存储了一个swp_entry_t的实例,private即指向该实例。如果页是空闲的,则private成员保存了其在伙伴系统中的阶).但其主要的用途是关联块缓存和页。这样的话,private指向将页划分为更小单位的第一个缓冲头。各个缓冲头通过b_this_page连接为一个环形链表。在该链表中,每个缓冲头的b_this_page成员指向下一个缓冲头,而最后一个缓冲头的b_this_page成员指向第一个缓冲头。这使得内核从page结构开始,可以轻易地扫描与页关联的所有buffer_head实例。

    //fs/buffer.c
    //创建一组新的缓冲区,关联 page 和 buffer_head
    void create_empty_buffers(struct page *page,
        unsigned long blocksize, unsigned long b_state)
    //将一组现存的缓冲区头关联到一页
    static inline void
    link_dev_buffers(struct page *page, struct buffer_head *head)
    

    在用block_read_full_page__block_write_full_page读写整页时,就会调用create_empty_buffers

    • create_empty_buffers 创建一组新的缓冲区,关联 page 和 buffer_head
      • alloc_page_buffers 创建所需数目的缓冲头(该数目可能随页长度和块长度而变化)
      • 设置所有新申请的缓冲头状态
      • 根据页状态设置缓冲头的状态
      • attach_page_buffers 将缓冲头与页建立关联
        • 设置页的PG_private标志位,表示page实例的private成员正在使用
        • page->private=head 将缓冲头与页建立关联

    函数page_has_buffers(page)用来检查是否设置了PG_private标志,来确定页是否关联了块缓存

  2. 交互
    如果对内核的其他部分无益,那么在页和块缓存之间建立关联就没起作用。如上所述,一些与块设备之间的传输操作,传输单位的长度依赖于底层设备的块长度,而内核的许多部分更喜欢按页的粒度来执行I/O操作,因为这使得其他事情更容易处理,特别是内存管理方面(如果数据按页读写,I/O操作通常会更高效。这是引入BIO层来替代原本基于缓冲头的方法的主要原因)。在这种场景下,块缓存充当了双方的中介

    1. 在块缓存中读取整页
      首先考察内核在从块设备读取整页时采用的方法,以block_read_full_page为例。我们来讨论缓冲区实现所关注的部分。下图给出block_read_full_page中与缓冲区相关的函数调用
      在这里插入图片描述

      • block_read_full_page 从块设备读取整页,只会读取一页中不是最新的块缓存部分,如果能确定整页都不是最新的,那么最好调用mpage_readpage,避免缓冲区的多余开销
        • 页没关联块缓存,则创建块缓存并关联页 create_empty_buffers
        • 遍历与页关联的所有缓冲区,检查哪些缓冲区的数据是最新的(BH_Uptodate)(与块设备的数据匹配或更新),因而无须读取,哪些缓冲区的数据是无效的(BH_Mapping)需要读取.将需要读取的块缓存放入临时数组
        • lock_buffer 锁定块缓存,防止其他内核线程在下一步进行干扰。
        • 将需要读取的块缓存转交给块层(bio层),在其中开始读操作
    2. 将整页写入到缓冲区

      除了读操作之外,页的写操作也可以划分为更小的单位。只有页中实际修改的内容需要回写,而不用回写整页的内容。遗憾的是,从块缓存的角度来看,写操作的实现比上述的读操作复杂得多。在下文的讨论中,将忽略(简化了一些)写操作的次要细节,而专注于内核所需的关键操作

      下图给出了__block_write_full_page函数中回写脏页涉及的缓冲区相关操作的代码流程图(不涉及错误处理,另外还忽略了一些不常见的边角情况,但实际上是必须处理的)。
      在这里插入图片描述

      • __block_write_full_page 回写脏页中修改的部分
        • 页没关联块缓存,则创建块缓存并关联页 create_empty_buffers
        • 第一遍遍历块缓存,对所有未映射的块缓存,在块缓存和块设备之间建立映射
        • 第二遍遍历块缓存,过滤出所有的脏块缓存
          • 对脏的块缓存设置BH_Async_Write状态位,并将end_buffer_async_write指定为BIO完成处理程序(即b_end_io)
        • 第三遍遍历块缓存,调用submit_bh将前一次遍历中标记为BH_Async_Write的所有缓冲区转交给块层执行实际的写操作,该函数向块层提交了一个对应的请求(bio请求)

      在针对某个块缓存的写操作结束时,将自动调用end_buffer_async_write,检查页的所有其他块缓存上的写操作是否也已经结束。倘若如此,则唤醒在与该页相关的队列上睡眠、等待此事件的所有进程

16.5.4 独立的块缓存(不基于页实现的块缓存,linux早期为该方案,在后续版本重要性下降)

块缓存不仅可用于页缓存的环境中。在Linux内核的早期版本中,所有缓存都是用块缓存实现的,而不依靠页缓存。这种方法的价值在后续版本逐渐降低,几乎所有的重要缓存都已经基于页缓存实现。但仍然有些情形需要在块级访问块设备的数据,而不是从高层代码看到的页级进行。为加速这样的操作,内核提供了另一个缓存,称为LRU块缓存,在下文讨论

这种用于独立缓冲区的缓存,并不是与页缓存完全分离的。因为物理内存总是按页管理,缓冲块也必须保存在页中,所以仍然与页缓存有一些联系。这些联系是不能也不应忽略的,毕竟仍然可以通过块缓存访问各个块,而无须关注块在页中的组织

  1. 独立块缓存实现机制说明

    为什么采用LRU?该缩写代表最近最少使用(least recently used),指的是一种一般方法,可用于有效管理一个集合中最常使用的那些成员。如果经常访问一个数据元素,则该元素很可能位于物理内存中(因而被缓存)。较不常用或很少使用的数据元素,将随时间的推移,逐渐自动退出缓存

    在每次进行请求,需要查找一个独立缓冲区时,为使查找操作更快速,内核首先自顶向下扫描所有缓存项。如果每个数据元素包含所需数据,则可以使用缓存的该数据实例。否则,内核必须向块设备提交一个底层请求,来获取所需数据

    上一次使用的数据元素,将由内核自动放置到LRU列表的第一个位置上。如果缓存中已经有数据元素,则只改变各个元素的位置。如果该数据元素是从块设备读取的,则将数组的最后一个元素退出缓存,从内存中释放

    算法非常简单,但很有效。这减少了查找常用数据元素的时间,因为相关的元素都位于数组的顶部。同时,不常用的数据元素在持续一段时间都没有访问之后,将自动退出缓存。该方法唯一的不利之处是,在每次查找操作之后,几乎数组的所有内容都需要重新定位。这是耗时的,只能对小型缓存实现。因而,块缓存的容量较低

  2. 实现
    下面讨论内核为上述LRU缓存实现的算法

    1. 数据结构

      //fs/buffer.c
      #define BH_LRU_SIZE	8
      //不基于页缓存的块缓存,实现lru(最近最少使用(least recently used))算法的结构体,最近使用过的数据在头,最久使用的在尾,用于缓存,由于数组这样移动比较耗时,只能用于小型缓存
      struct bh_lru {
          struct buffer_head *bhs[BH_LRU_SIZE];//缓冲头指针的数组
      };
      
      //为系统的每个CPU都建立一个实例,改进对CPU高速缓存的利用率
      static DEFINE_PER_CPU(struct bh_lru, bh_lrus) = {{ NULL }};
      

      该缓存通过内核提供的两个函数来进行管理和使用:lookup_bh_lru检查所需数据项是否在缓存中,而bh_lru_install将新的缓冲头添加到缓存中

      两个函数的实现并不出人意料,因为它们只是实现了上述的算法(如内核代码中的注释所言:抱歉,但我得说,LRU的管理算法迟钝而简单)。它们只需要在操作开始时,根据当前CPU选择对应的数组,使用的代码如下

      //fs/buffer.c
      lru = &__get_cpu_var(bh_lrus);
      

      如果lookup_bh_lru失败,不会自动从块设备读取所需的块.这是通过下列接口函数完成的

      1. 接口函数
        普通的内核代码通常不会接触到lookup_bh_lrubh_lru_install,因为二者被封装起来。内核提供了通用例程来访问各个块,它们自动涵盖了块缓存,使得没必要与块缓存进行显式交互。这些例程包括__getblk__bread,实现在fs/buffer.c

        //fs/buffer.c
        //返回一个数据块缓冲区头,文件系统在读取超级块或管理块时使用,在lru缓存和页缓存中找到对应的缓冲头,找不到就分配对应空间直到成功.如果所要的块长度小于512字节,或大于一页,或不是底层块设备硬件扇区长度的倍数,则该函数返回一个NULL指针。但同时还输出一个栈转储,因为无效的块长度解释为内核bug.
        struct buffer_head *
        __getblk(struct block_device *bdev, sector_t block, unsigned size)
        //确保返回一个数据最新的块缓冲区,文件系统在读取超级块或管理块时使用
        struct buffer_head *
        __bread(struct block_device *bdev, sector_t block, unsigned size)
        
      2. __getblk函数

        在这里插入图片描述

        • __getblk 返回数据块缓冲区头
          • __find_get_block 查找所要的缓冲区
            • lookup_bh_lru 在lru缓存中查找
              • 没找到则调用 __find_get_block_slow 在页缓存中找
                • 如果数据不在页缓存中,或虽然在页缓存中,但对应的页没有与之关联的缓冲区,则返回一个NULL指针
                • 如果数据在页缓存中,且对应页有相关的缓冲区,则返回指向所要缓冲头的指针
              • __find_get_block_slow 找到后调用 bh_lru_install 将其添加到缓存
            • 找到了则调用 touch_buffer(mark_page_accessed) 将页与块缓存关联
          • 没找到就调用 __getblk_slow 在lru缓存和页缓存中找到对应的缓冲头,找不到就分配对应空间直到成功
            • 循环调用下面过程
              • __find_get_block 这次只有在与此同时有另一个CPU建立了所需的缓冲区,并在内存中创建了对应的数据结构时,这一次函数调用才会成功。尽管这不太可能,但仍然必须检查
              • grow_buffers 试图为缓冲头和实际数据分配内存,并将该内存空间添加到内核的数据结构.负值意味着块超出了页缓存索引的范围,函数结束.等于0则内存不足.大于0成功,回到上一步成功返回
                • 等于0内存不足,则调用 free_more_memory 启动回写线程,试图释放更多的物理内存来改善这种状况

        返回缓冲头并不意味着数据区的内容是正确的

        在这里插入图片描述

        • grow_buffers 查找一个适当的页或创建一个新页,关联相应的缓冲区
          • grow_dev_page 查找一个适当的页或创建一个新页,关联相应的缓冲区
            • find_or_create_page 查找一个适当的页或创建一个新页,来保存数据,找不到且内存不足时会失败
            • 如果页中已有块缓存
              • init_page_buffers 填充缓冲头的状态(b_status)和管理数据(b_bdev、b_blocknr)并结束函数
            • alloc_page_buffers 生成一组新的块缓存
            • link_dev_buffers 将块缓存与页关联
            • init_page_buffers 填充缓冲头的状态(b_status)和管理数据(b_bdev、b_blocknr)
      3. __bread函数

        __bread确保返回一个数据最新的缓冲区

        • __bread
          • __getblk 返回数据块缓冲区头.如果缓冲的数据已经是最新的,则函数结束
          • 如果数据不是最新的
            • __bread_slow 向块层提交一个请求,在物理上读取数据,并等待操作完成
      4. 在文件系统中的使用

        内核中必须用这种读取方式的场景不多,但都很重要。特别是,文件系统在读取超级块或管理块时利用了上述的例程

        //include/linux/buffer_head.h
        //返回一个数据最新的块缓冲区,文件系统在读取超级块或管理块时使用
        static inline struct buffer_head *
        sb_bread(struct super_block *sb, sector_t block)
        //返回一个数据块缓冲区,文件系统在读取超级块或管理块时使用
        static inline struct buffer_head *
        sb_getblk(struct super_block *sb, sector_t block)
        
        • sb_bread

          • __bread
        • sb_getblk

          • __getblk

总结

块缓存->页缓存 从小到大

块缓存有两种实现方式:

  1. 基于页缓存的块缓存,缓存数据修改后将页设为脏即可
  2. 不基于页缓存的块缓存,块缓存头在一个数组中,按LRU组织,最近使用过的块缓存,将缓存头移动到数组头,数组其他项向后移,一直不使用的缓存最后会被移出数组

页分配到地址空间中,地址空间关联到内存虚拟地址,虚存管理中建立了虚拟地址到块设备上的映射

地址空间(页缓存)中用基数树管理页

所有显示调用的读写函数最后都是提交一个bio请求
实际对磁盘的写操作由一个系统进程执行,其他的写操作函数只将页设为脏

预读机制,三个读函数接口

  1. 普通读函数 do_generic_mapping_read
  2. 缺页异常读 filemap_fault
  3. __generic_file_splice_read系统调用

do_generic_mapping_read 读取页时如果页被设置了 PG_readahead 预读标志,会提交一个异步读取bio在稍后读取。当前则继续从页缓存中获取页数据
filemap_fault 与do_generic_mapping_read类似,差别为仅当设置了顺序读取提示的情况下,才会进行异步自适应的预读

bio替换按块读写,因为内核很多部分更喜欢按页的粒度执行I/O操作,因此增加bio,将多个块操作合并成一个更大的操作

读写一页时都是通过检查页中哪个块缓存需要读写,然后将需要读写的块缓存组织成bio请求,提交读写

不基于页缓存的块缓存实现,每个CPU一个lru数组,文件系统在读取超级块或管理块时使用

=========================================

涉及的命令和配置:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值