【推荐阅读】
页缓存和块缓存
内核为块设备提供了两种通用的缓存方案:
- 页缓存:针对以页为单位的所有操作,例如内存映射技术,负责了块设备的大部分工作
- 块缓存:以块为操作单位,存取的单位是设备的各个块,由于块长度取决于特定的文件系统,块缓存能处理不通长度的块
缓冲区曾经是块设备进行I/O操作的传统方法,目前只用于支持很小的读取操作,对于块传输的标准数据结构变为struct bio,这种方式可以合并统一请求中后续的块,加速处理,但是对于单个块的操作,缓冲区仍然是首选,例如经常按块读取元数据的系统。
很多场合下,页缓存和块缓存结合使用(一个缓存的页在写操作期间划分为不同的缓冲区,在更细的粒度识别出被修改的部分,在数据写回时,只需要回写被修改的部分,不需要整页传输)
页缓存的结构
管理和查找缓存的页
Linux采用基数树的数据结构管理页缓存中的页,如下图
树的根有一个简单的数据结构表示,包含了树的高度(所包含节点的最大层次数目)和一个指针,指向组成树的第一个节点的数据结构
树的节点具备两种搜索标记,二者用于指定给定页当前是否为脏(即页的内容和后备存储器的数据不同)或该页是否正在向底层块设备回写。标记会一直向上设置到根节点,如果某个层次 n+1 的节点设置了某个标记,那么 n 层次的父节点也会设置该标记。好处是内核可以判断在某个范围内是否有一页或多页设置了某个标记位
回写修改的数据
由于页缓存的存在,写操作不是直接对块设备进行,而是在内存中进行,修改的数据首先被收集起来,然后被传输到更低的内核层,在那里对写操作进一步优化,具体的优化过程详见 通用块设备层。这里从页缓存的视角来看,需要确定何时回写?
内核提供如下几种同步方案:
- 内核守护进程pdflush周期性地同步,他们扫描缓存中的页,将超出一定时间没有与底层块设备同步的页写回
- 如果缓存中修改的数据项数目在短期内内明显增加,内核会主动激活pdflush进程
- 提供了系统调用给用户调用来写回未同步的数据,常用的有sync调用
为管理可以按整页处理和缓存各种不同对象,内核使用了**“地址空间”**抽象,将内存中的页和特定的块设备关联起来,每个地址空间都有一个“宿主”,所谓其数据来源,一般用inode表示
一般修改文件或者按页缓存的对象时,只会修改页的一部分,为节约时间,在写操作期间,会将缓存中的每一页划分为较小的单位,称为缓冲区,回写过程中以缓冲区为单位来操作
块缓存的结构
Linux早期版本只包含块缓存,用于加速文件操作和系统性能,底层块设备的块缓存存在内存的缓冲区中,可以加速读写(实现部分包含在fs/buffers.c中)
与内存页相比,块比较小而且长度可变(依赖于使用的块设备或者文件系统)
文件系统在处理元数据时,一般会使用块缓存;而裸数据的传输则按页进行
缓冲区的实现基于页缓存,Linux 2.6之前,缓冲区使用缓冲区头buffer head结构实现,在2.6之后,不再使用缓冲区头结构,而是使用bio结构
地址空间
地址空间建立了缓存数据与后备存储器之间的关联,实现了两个单元之间的转换机制
- 内存中的页关联到每个地址空间,这些页表示缓存的内容
- 后备存储器指定了填充地址空间中页的数据的来源,它是虚拟内存中区域到后备存储器(块设备)上对应位置的映射
数据结构
地址空间的基础是address_struct结构,定义如下:
主要成员:
- 与地址空间管理的区域之间的关联,通过两个成员建立:host指向inode实例,指定了后备存储器;一个基数树的根page_tree列出了地址空间中所有的物理内存页
- i_mmap是一棵树的根节点,包含了与该inode相关的所有普通内存映射,该树的作用在于,支持查找给定区间至少一页的所有内存区域
- backing_dev_info是一个指针,指向另一个结构,包含了与地址空间相关的后备存储器的有关信息(后备存储器是指与地址空间相关的外部设备,用作地址空间中信息的来源,通常为块设备)
- aps指针指向address_space_operation结构,其中包含了一组函数指针,指向用于处理地址空间的特定操作
地址空间与内核其他部分的关联如下图
页缓存的实现
分配页
page_cache_alloc用于为一个即将加入页缓存的新页分配数据结构,加上后缀_cold的函数是获取一个冷页
- 首先page_cache_alloc将工作委托给alloc_pages,它从伙伴系统获得一个页帧
- 接下来将新页添加到页缓存,这是在add_to_page_cache函数中实现,如下所示,radix_tree_insert将与页相关的page实例插入到地址空间的基数树,在页缓存中的索引和指向所属地址空间的指针保存在page的成员index和mapping中
内核还提供了另一个可选的参数add_to_page_cache_lru,他首先调用add_to_page_cache向地址空间的页缓存添加一页,然后使用lru_cache_add函数将该页加入到系统的LRU缓存
查找页
使用基数树判断给定页是否已经缓存,使用find_get_page实现该功能
其中radix_tree_lookup用于查找位于给定偏移量的页,在找到页之后,page_cache_get将页的引用计数加1
在页上等待
内核经常要在页上等待,直至其状态改变为预期值。例如,数据同步时需要确保对于某页的回写已经结束,此时内存页的内容和底层块设备是相同的。处于回写过程中的页会设置PG_writeback标志位
内核提供了wait_on_page_writeback函数,用于等待页的该标志位清除
wait_on_cahe_bit安装一个等待队列,进程可以在上面睡眠,直至PG_writeback标志位清除
另外也有等待页解锁的需求,使用函数wait_on_page_locked实现
对整页的操作
内核在块设备和内存之间传输数据时,相关的算法和数据结构都是以页为基本单位,而逐个缓冲区/块的传输会对性能产生负面影响。因此,在再重新设计块层的过程中,内核版本2.5引入BIO,来替代缓冲区,用于处理与块设备的数据传输。内核添加了4个函数,来支持读写一页或多页
其中,writeback_control用于精确控制回写操作的选项
这4个函数共同之处:都是构建一个BIO实例,用于对块层进行传输
以mpage_readpages为例,该函数需要nr_pages个page实例,以链表的形式通过参数pages传递进去,mapping是相关的地址空间,get_block用于查找匹配的块地址
该函数首先遍历所有的page实例,在循环的每一遍,首先将该页添加到地址空间相关的页缓存中,然后创建一个bio请求,从块层读取所需的数据
在do_mpage_readpage建立bio请求时,会包含此前各页的BIO数据,以构造一个合并的请求(将几页的读取合并到一个请求,而不是每页一个请求)
如果在循环结束时,do_mpage_readpage留下一个未处理的BIO请求,则提交该请求
页缓存预读
页缓存预读不是由页缓存独立完成的,还需要VFS和内存管理层的支持
预读在内核的几处都有涉及
- do_generic_mapping_read:一个内核通用的读取例程
- filemap_fault函数:缺页异常处理程序,负责为内存映射读取缺页
这两个函数在读取文件时会调用,其中通过系统调用read()正常读文件时,一般会调用do_generic_mapping_read函数,而内存映射时第一次读取文件内容时,会触发缺页中断调用filemap_fault函数
代码详细实现见 深入Linux内核架构 P458
下面以do_generic_mapping_read为例,来考察预读的具体过程
假定进程已经打开了一个文件,准备读取第一页,该页尚未读入页缓存,此时不会只读入一页,而是顺序读取多页,内核调用page_cache_sync_readahead读取一行中的8页(数字8是具体说明),第一页对于do_generic_mapping_read来说是立即可用的,而在实际需要之前就被读入页缓存的页,处于预读窗口中
之后进程继续读取接下来的页,在访问第6页(6是举例说明)时,内核检测到在该页设置了PG_Readahead标志。此时触发了一个异步操作,会在后台读取若干页(由于还有两页可用,所以不需要同步读取,但在后台进行的I/O操作需要确保进一步读取文件时,相关页已经读入页缓存)。page_cache_async_read函数负责发出异步读请求,它又会将窗口中的一页标记为PG_Readahead,在进程遇到该页时,又会触发异步读取,以此类推
最重要的问题在于预测预读窗口的最优长度。因此,内核会记录每个文件上一次的设置,使用file_ra_state关联到每个file实例
主要成员:
- start:表示页缓存开始预读的位置
- size:表示预读窗口的长度
- async_size:表示剩余预读页的最小值,如果预读窗口中的页数等于这个值,则会触发异步预读
- ra_pages:表示预读窗口的最大长度,内核读入的页数可以小于这个值,但是不能超过
- prev_pos:表示前一次读取时,最后访问的位置
预读机制的实现涉及如下几个函数
其中,ondemand_readahead例程负责实现预读策略(即判断预读多少当前不需要的页),在确定预读窗口的长度之后,调用ra_submit,将技术性问题委托给__do_page_cache_readhead函数,其中页是在页缓存中分配的,而后由块层填充