Linux存储栈(二)

对磁盘的I/O操作是一个耗时的操作,对于这部分时间的优化,有以下两个方面。

  1. 批量读取,以块的方式,批量读取将分摊到每一部分的数据的时间就变少。
  2. 预先处理,将磁盘的数据缓存到内存中,这样就把对磁盘的访问变为对物理内存的访问。

Page Cache

首先明确页高速缓存是一种软件的机制,只是将内存的一部分逻辑上化为缓存区,其内容对应磁盘上的物理块,因此其大小可以动态的调整。

读策略

当内核开始一个read()操作,首先会检查数据是否在页高速缓存中,如果在,直接从内存中读取,称为缓存命中,否则就要去磁盘读取文件,之后再将其放入页缓存。(过程类似于TLB存放页表,但TLB是一个实际的硬件,操作系统产生实际的页表项,并放入更新TLB)。

写策略

讨论写策略是在讨论

  1. 是否在内存缓存修改,如果不缓存,那么不存在同步问题,一旦更新那么就清空缓存,效率低
  2. 同步缓存,在内存中的修改,在什么时间点同步到硬盘
    1. 内存修改,立即更新,写穿(write through),这样不考虑不一致的失效问题,但是可以对磁盘的写入频繁,相应带来效率上的问题。
    2. 回写,是一种lazy的策略,先标记不更新,通过延迟写策略,方便之后的合并和一次刷新。(和fork进程中的copy-on-write,线段树的lazy tag都是同样的思想)。

回收策略

因为内存小于磁盘,所以必然要考虑回收替换策略,为LRU的优化和变种,如双链,和n链策略

Linux页高速缓存

文件缓存,主要是为了缓存内存和存储的速度因为内核基于块来访问物理文件系统,而磁盘块与内存中的缓冲区又是一一对应的映射关系,所以为了提高磁盘的存取效率,内核引入了缓冲区缓存机制,将通过虚拟文件系统访问的块的内容缓存在内存中。在早期版本的内核中,Page Cache和Buffer Cache是两个独立的缓存,前者缓存页,后者缓存块,由于一个磁盘块可以在两个缓存中同时存在,因此除了耗费额外的内存,还需要对两个缓存中的内容进行同步操作。从2.4.10版本内核开始,Buffer Cache不再是一个独立的缓存了,它被包含在Page Cache中,通过Page Cache来实现。对于4KB大小的page来说,根据不同的块大小,它可以包含1~8个缓冲区。

x86体系中,物理页大小为4KB,文件系统的块大小为512 byte = 512B,故一页可以缓存8个块。

linux内核中使用address_space这个对象来管理内存缓存页3,其结构定义在<linux/fs.h>,因为read/write,这些系统调用执行页I/O,最终是通过file->f_op->read()file->f_op->write()来完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* struct address_space - Contents of a cacheable, mappable object.
* @host: Owner, either the inode or the block_device.
* @i_pages: Cached pages.
* @gfp_mask: Memory allocation flags to use for allocating pages.
* @i_mmap_writable: Number of VM_SHARED mappings.
* @nr_thps: Number of THPs in the pagecache (non-shmem only).
* @i_mmap: Tree of private and shared mappings.
* @i_mmap_rwsem: Protects @i_mmap and @i_mmap_writable.
* @nrpages: Number of page entries, protected by the i_pages lock.
* @nrexceptional: Shadow or DAX entries, protected by the i_pages lock.
* @writeback_index: Writeback starts here.
* @a_ops: Methods.
* @flags: Error bits and flags (AS_*).
* @wb_err: The most recent error which has occurred.
* @private_lock: For use by the owner of the address_space.
* @private_list: For use by the owner of the address_space.
* @private_data: For use by the owner of the address_space.
*/
struct address_space {
struct inode *host;
struct xarray i_pages;
gfp_t gfp_mask;
atomic_t i_mmap_writable;
#ifdef CONFIG_READ_ONLY_THP_FOR_FS
/* number of thp, only for non-shmem files */
atomic_t nr_thps;
#endif
struct rb_root_cached i_mmap;
struct rw_semaphore i_mmap_rwsem;
unsigned long nrpages;
unsigned long nrexceptional;
pgoff_t writeback_index;
const struct address_space_operations *a_ops;
unsigned long flags;
errseq_t wb_err;
spinlock_t private_lock;
struct list_head private_list;
void *private_data;
}

其中比较重要的是基树的实现,因为频繁的检查和搜索,搜索的开销会抵消内存代替磁盘的开销,所以引入了基树,每个address_space对象都有一个唯一的基树。

flusher线程

这是一个内核线程,周期性地运行,用来实现我们之前提到的关于写策略。

其执行的条件为:

  1. 空闲内存低于一个阈值,内核将脏页写回,释放内存
  2. 脏页在内存中驻留时间超过某个阈值
  3. 用户主动调用sync()fssync()

我们可以在proc/sys/vm配置相关参数。

块I/O层(Block Layer)

维持一个I/O请求在上层文件系统与底层物理磁盘之间的关系是由通用块层(Generic Block Layer)来负责的。在通用块层中,使用bio结构体来描述一个I/O请求,而到了Linux驱动,则是使用request结构体来描述向块设备发出的I/O请求的。对于慢速的磁盘设备而言,请求的处理速度很慢,这时内核就提供一种队列的机制把这些I/O请求添加到队列中(请求队列),使用request_queue结构体描述。为了提高访问效率,在向块设备提交这些请求前,内核会先通过一定的调度算法对请求进行合并和排序预操作,对连续扇区操作的多个请求进行合并以提高执行效率,这部分工作由I/O调度器负责。

bio 与 request

对磁盘块的请求,最终被抽象为一个bio。其中,bio描述了磁盘里要真实操作的位置与Page Cache中页的映射关系。bio在块层会被转化为request,多个连续的bio可以合并到一个request中,生成的request会继续进行合并、排序,并最终调用块设备驱动的接口将request从块层的request队列(request_queue)中移到驱动层进行处理,以完成I/O请求在通用块层的整个处理流程。request用来描述单次I/O请求,request_queue用来描述与设备相关的请求队列,每个块设备在块层都有一个request_queue与之对应,所有对该块设备的I/O请求最后都会流经request_queue。块层正是借助biobio_vecrequestrequest_queue这几个结构将I/O请求在内核I/O子系统各个层次的处理过程联系起来的。

bio_vec结构是形式为<page,offset,len>的向量,也就是说,bio是对物理页中中的块进行操作。

I/O调度程序

I/O调度,主要从吞吐量和截止时间两个指标来考虑。

电梯调度则是比较经典的从吞吐量的算法,但是这种会造成饥饿,也就是说特定情况下某些I/O迟迟得不到调度,而比较经典的是写-饥饿-读,也就是写饥饿。考虑截止时间比较经典的最终期限I/O调度,为每一个I/O请求都加入一个截止时间,并且分成排序队列,读FIFO队列,写FIFO队列,正常情况下安装排序->派发的情况,但是一旦读/写队列超时,那么读/写队列队头出队,直接加入到派发队列。除此之外还有预测I/O调度,和完全公平的排队I/O调度队列。

可以在内核中配置不同的I/O调度程序,参数elevator,默认为完全公平的I/O调度程序。

参数 I/O调度程序
as 预测
cfq 完全公平的排队
deadline 最终期限
noop 空操作