Linux内核设计与实现总结(8) —— I/O子系统

I/O子系统

本人觉得I/O子系统是Linux内核中最难的东西,因为它需要兼容横跨物理介质、文件系统、空间位置(网络),为上层提供统一接口,还需要保持高性能,这简直amazing。

I/O子系统的架构图如上图所示。

这里将Linux I/O子系统分为VFS(虚拟文件系统)、块I/O、高速缓存这三个部分总结。

VFS

VFS作为内核子系统,为上层例如open、write等系统调用提供了统一的接口,而不需要考虑文件系统和具体物理介质。它建立了一个文件抽象层,要求不同的文件系统驱动都实现遵守这个统一的文件系统模型。

不同于Windows这种将具体的设备和分区划分命名空间(比如命名空间C:分配给固态硬盘设备的一个分区,D:分配给另一个分区,E:分配给另一个存储设备)的操作系统,Unix类的操作系统所有的文件系统都被安装在一个特定的安装点下面,也就是说安装点文件系统作为一个树的枝叶出现在系统中,相当优雅。例如一个文件:

1
/home/lyq1996/deadbeef

它包括:/目录,/home目录,/home/lyq1996目录,deadbeef文件。在Linux中,目录也被当作一个文件,所以可以对目录进行操作。

VFS对象

包括:

  1. 超级块对象:代表已安装的操作系统
  2. inode对象:代表一个具体文件
  3. dentry对象:代表一个目录项,路径组成部分
  4. 文件对象:代表进程打开的文件

超级块

超级块,包含了文件系统的重要信息,比如inode的总个数、块总个数、每个块组的inode个数、每个块组的块个数等信息,超级块对象在挂载文件系统时从物理设备读入内存。对于内存文件系统,例如sysfs,procfs,tmpfs等,会现场创建超级块,并保存到内存中。

超级块结构体见struct super_block,最重要的域是struct super_operations s_op,包含了操作超级块的函数。也就是说,这里采用的是面向对象的思想,数据与数据操作绑定了。

inode、dentry、file

索引节点

Unix将文件和文件元数据划分开,元数据被称为inode(索引节点),inode结构体包括了访问权限控制、大小、时间等信息。索引节点也需要保存到物理设备上,对于有些没有单独保存元信息的文件系统,或者没有单独存放,文件系统也必须要提取这些信息。inode表示文件系统的一个文件,并不会将所有的inode一次性保存到内存,只有该文件被访问时才会在内存中创建,索引节点表示的文件可以是设备文件、管道文件。

索引节点见struct inode,和超级块一样,域struct inode_operations *i_op包含了操作索引节点的函数。

目录项存在三种状态:被使用,未使用,负状态。被使用的目录项,对应一个有效的索引节点,并存在一个或多个使用者,通过d_count标记;未使用的目录项,对应一个有效的索引节点,但没有使用,d_count为0;而负状态的目录项,则没有有效的索引节点,保留在内存中用作快速查询。

如果每一个文件,访问时都依次解析成目录项对象,无疑非常的费力,因此内核将目录项缓存到目录项缓存中,也就是dcache。访问一个文件时,VFS首先在目录项缓存中查找路径名,如果找到了,则直接返回目录项。目录项同时也提供了一定意义上的inode缓存,因为目录项会让相关inode索引节点的计数为正,这样inode就不会被释放,也就不需要将inode重新加载到内存。

目录项

目录项对象可以想象成一个查找文件用的目录,上面提到了一个路径/home/lyq1996/deadbeef,/是目录项对象,/home,lyq1996/,/deadbeef也是目录项对象。同样,目录项对象的操作保存在域struct dentry_operations *d_op中。

文件对象

文件对象表示进程打开的一个文件,进程处理的是文件,当然不是dentry和inode。文件对象没有对应的磁盘数据,所以状态没有脏、是否需要回写的标记,因为文件对象有一个指针指向dentry,dentry会指向对应的索引节点,索引节点中会记录文件是否为脏的。文件对象操作保存在struct file_operations *f_op中。


对于字符设备,还有一些特殊文件,比如/proc和/sys,它们没有文件系统,所以这些文件对象的操作直接由设备驱动提供。

进程相关数据结构

每个进程都有自己打开的文件,根文件系统、当前工作目录、安装点等。存在三个结构体与进程紧密相连,分别是:files_struct、fs_struct、namesapce结构体。

files_struct:由task_struct的files域指向,包含与单个进程相关的信息,比如打开的文件和文件描述符。


图片来自CSDN-metersun
files_struct定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct files_struct {
atomic_t count; // 结构使用计数,两个进程可能共享该结构体
bool resize_in_progress; // 标记正在进行fdtable resize的操作
wait_queue_head_t resize_wait;

struct fdtable __rcu *fdt; // 指向其他fd表的指针
struct fdtable fdtab; // fd表

spinlock_t file_lock ____cacheline_aligned_in_smp; // 单个文件的锁
unsigned int next_fd; // 缓存下一个可用的fd
unsigned long close_on_exec_init[1]; // exec时关闭的文件描述符链表
unsigned long open_fds_init[1]; // 打开的文件描述符链表
unsigned long full_fds_bits_init[1]; // 所有的文件描述符链表
struct file __rcu * fd_array[NR_OPEN_DEFAULT]; // 进程打开小于NR_OPEN_DEFAULT个文件对象时使用这个数组,大于就新建fdtab,用fdt指向它
};

fs_struct:由task_struct的fs域指向,包含文件系统和进程相关的信息。
fs_struct定义如下:

1
2
3
4
5
6
7
8
struct fs_struct {
int users; // 用户数量
spinlock_t lock; // 保护结构的锁
seqcount_spinlock_t seq; // 顺序计数器和顺序锁
int umask; // 掩码
int in_exec; // 当前正在执行的文件
struct path root, pwd; // 根目录 与 当前工作目录的路径
} __randomize_layout;

mnt_namespace:由task_struct的namespace结构体,由task_struct的mmt_namespace域指向。默认所有的进程共享相同的命名空间,除非在clone时使用CLONE_NEWS标志。namespcae可以隔离资源,docker就采用了这种技术,除了mmt_namespace以外,还有uts_namespace,ipc_namespace,pid_namespace等。
mnt_namespace定义入下:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct mnt_namespace {
struct ns_common ns;
struct mount * root;
struct list_head list;
spinlock_t ns_lock;
struct user_namespace *user_ns;
struct ucounts *ucounts;
u64 seq; /* Sequence number to prevent loops */
wait_queue_head_t poll;
u64 event;
unsigned int mounts; /* # of mounts in the namespace */
unsigned int pending_mounts;
} __randomize_layout;

文件系统的挂载

只有理解了文件系统的挂载,才能真正串联这些结构体。

推荐阅读:

  1. 深入理解Linux文件系统之文件系统挂载(上)
  2. 深入理解Linux文件系统之文件系统挂载(下)

块I/O

块指的是文件系统寻址的最小单元,扇区是块设备寻址的最小单元。在以前,大多数块设备扇区可能是512字节,块大小是扇区大小的2的整数倍,且不能超过页大小,所以块大小为512B、1KB、4KB,也就是一个块包含1、2、4个扇区。

目前,用的最多的ext4文件系统的块大小通常为4KB,与页面的大小4KB一致。而且硬盘设备的容量越来越大,设备扇区寻址支持和块大小保持一致,这也就是所谓的4K对齐

缓冲区

当一个块被调入内存时,需要存放在内存的缓冲区中,所以缓冲区就是块在内存中的表示,缓冲区头结构见struct buffer_head。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct buffer_head {
unsigned long b_state; /* buffer state bitmap (see above) */
struct buffer_head *b_this_page;/* circular list of page's buffers */
struct page *b_page; /* the page this bh is mapped to */

sector_t b_blocknr; /* start block number */
size_t b_size; /* size of mapping */
char *b_data; /* pointer to data within the page */

struct block_device *b_bdev;
bh_end_io_t *b_end_io; /* I/O completion */
void *b_private; /* reserved for b_end_io */
struct list_head b_assoc_buffers; /* associated with another mapping */
struct address_space *b_assoc_map; /* mapping this buffer is
associated with */
atomic_t b_count; /* users using this buffer_head */
spinlock_t b_uptodate_lock; /* Used by the first bh in a page, to
* serialise IO completion of other
* buffers in the page */
};

b_this_page是页面中的缓冲区,b_page是存储缓冲区的页面,b_blocknr是起始块号,b_data是页面中的数据指针,它直接指向相应的块(位于b_page的某个位置),b_bdev指向对应的块设备。缓冲区头的目的在于描述块和内存缓冲区的关系。

在2.6内核以前,缓冲区头作为所有I/O操作单元的容器,历史上存在的东西也没必要仔细细究。目前内核中块IO操作的基本容器以bio结构体表示,结构体表示目前活动的、以片段(segment)链表形式组织的块IO操作。


bi_io_vec域表示一个IO操作所关联的所有片段,每个bio_vec都是一个page,offset,len的向量,描述了片段所在的物理页、块在物理页的偏移位置、块的长度。整个bi_io_vec数组表示了一个完整的缓冲区。总而言之,每个IO请求都是通过bio结构体表示的,每个请求包含一个或多个块。

bio与之前缓冲区不同之处在于,bio代表的是抽象的IO操作,作为一个或者多个IO块的容器,包含了一个或者多个页中的片段,而缓冲区描述的仅仅是一个块,所以可能会引起以块为单位的分割,需要后续的处理重新组合。

  1. bio结构体可以处理高端内存,物理页不是直接指针。
  2. bio可以代表普通IO,也可以代表直接IO,不通过页高速缓存
  3. bio便于执行分散-集中的块IO操作
  4. bio结构体只需要包含块IO操作所需的信息即可,不需要包含与缓冲区本身不必要的信息。

IO调度

IO调度程序负责对IO请求队列中的请求进行调度,有利于减少寻址时间,提高吞吐量。这部分等以后工作中真正遇到了在进行详细的学习(日后如果有机会进行性能调优的话…)。

IO调度有:

  1. Linus电梯
  2. deadline
  3. CFQ
  4. Noop
    ….

页高速缓存

前言:

我在学习这部分内容的时候产生了相当大的疑惑,页高速缓存是将磁盘中的数据缓存到物理内存中,相当于对磁盘的访问变成了对物理内存的访问。而上面介绍了bio结构体,是块IO在内存中的表示,它映射了内存中的页面到磁盘块,这样页高速缓存在进行块IO操作时也减少了磁盘访问。那么,在对某一个磁盘块进行操作时,通过块IO缓冲会保存在内存中一份,通过页面高速缓存也会保存在内存中一份,两份内存??还得保持两个缓存的同步??

其实,这两个并非天生统一,2.4内核主要工作将是统一它们。因此在实现上,块IO缓冲区没有作为独立缓存,而是作为了页高速缓存的一部分。但是内核仍然需要在内存中使用IO缓冲来表示块,而缓冲是用页映射块(还记得吗?),所以它正好在页高速缓存中。

读缓存

页高速缓存由内存中的物理页组成,页对应物理块。当内核开始一个读操作时,首先检查需要的数据是否在页缓存中,在则放弃访问磁盘,直接从内存中读取,这个行为称作缓存命中;若没有命中,则调度块IO操作从磁盘中读取数据。缓存可以持有一个文件的全部内容,也可以存储文件的部分页。

写缓存

当内核开始写操作时,缓存实现为三种策略:

  1. 不缓存,高速缓存不缓存任何写操作,当写时直接跳过缓存,写到磁盘,使缓存中的数据失效,下次读时重新从磁盘中读取数据。这个策略不缓存写操作,而且需要额外操作使缓存数据失效。
  2. 写操作自动更新内存缓存,同时更新磁盘文件。写操作直接穿透缓存到磁盘中,这种策略不需要让缓存失效,缓存数据时刻与磁盘存储保持一致。
  3. 回写策略,Linux采用了这种策略,也就是写操作直接写入缓存中,但是磁盘中对应的文件不会立即更新,而是将缓存标记为脏的数据,并加入到脏页链表中。然后由回写进程周期性的将链表中的脏页回写到磁盘,使磁盘中的数据与内存中的数据保持一致。最后清理脏页标识,脏指的是缓存中的数据未与磁盘数据同步。回写策略认为好与写穿透策略,因为延迟写方便在以后的时间内合并更多的数据和再一次刷新。

缓存回收

Linux内核的缓存回收是通过选择干净页进行简单替换,如果缓存中没有足够的干净页,则强制进行回写操作,腾出更多的干净页面。回收算法:

  1. LRU,最近最少使用,LRU算法通过跟踪每个页面的访问轨迹,回收最老时间戳的页面。数据如果越久没有被访问,则不太可能最近再被访问。对于许多只被访问一次的,而不再进行下一次访问的场景,LRU表现不佳,因为但内核并不知道一个文件只被访问一次,它只知道过去访问了多少次,将这些页面放到LRU链的顶端不是最优解。
  2. 双链策略,一个修改过的LRU,维护连个链表,活跃链表和非活跃链表。活跃链表是热的,不会被换出;非活跃链表则可以被换出。两个链表都被伪LRU规则维护:页面从尾部加入,从头部移除。如果活跃链表页面过多,则头部页面被重新移回非活跃链表,以便被回收。在活跃链表中的页面必须在其被访问时就处于非活跃链表中。

address_space

页高速缓存缓存的是内存页面,缓存来自对正规文件、块设备文件、内存映射文件的读写。页高速缓存包含了最近被访问过的文件的数据块。Linux内核对被缓存的页面定义非常宽泛,可以缓存任何基于页的对象,包括各种类型的文件和各种类型的内存映射,要不然在普通文件inode里面就可以直接管理页缓存。

所以Linux内核使用address_space结构体管理缓存项和页IO操作,每个缓存文件只和一个address_space相关联。该结构包含文件对应的所有页,嵌入在拥有该页面的inode对象中。其中,address_space_operations是对应address_apce结构体的操作结构体,主要包括:

1
2
3
4
5
6
7
8
9
10
11
12
writepage:			写操作,将页写到所有者所在的磁盘;
readpage: 读操作,从所有者的磁盘上读取页;
sync_page: 若对所有者拥有的页的操作准备好,则立即开始I/O数据传输;
writepages: 将所有者的多个脏页写到磁盘上;
set_page_dirty: 将所有者的页的状态,设为脏;
readpages: 从磁盘上读取多个所有者的页;
prepare_write: 准备一个写操作(由磁盘文件系统使用);
commit_write: 完成一个写操作(由磁盘文件系统使用);
bmap: 从文件块索引中,获取逻辑块号;
invalidatepage: 使拥有者的页无效(截断文件时使用);
releasepage: 准备释放页,由日志文件系统使用;
direct_IO: 对所有者的页进行直接I/O数据传输(不经过页面Cache)。

其中最重要的方法是readpage/readpages、writepage/writepages、prepare_write和commit_write(最新版本已不存在)。在大多数情况下,这些方法把所有者的inode对象和访问物理设备的底层设备驱动联系起来。如,为普通文件的inode实现readpage方法的函数知道如何确定文件页的对应块在物理磁盘上的位置。

一个页面缓存的读操作如下:

  1. Linux内核试图在页高速缓存中找到需要的数据。调用page = find_get_page(mapping, index),index是文件中以页面为单位的位置。
  2. 如果没有在高速缓存中,find_get_page返回一个NULL,并且内核分配一个新页面,将之前的页加入页高速缓存中。
  3. 从页高速缓存中数据,然后返回给用户。

对于写操作,当页被修改了,仅需调用SetPageDirty将page设置为脏,内核会在晚些时候通过writepage()将页写出。

任何IO操作前,内核都需要检查页是否已经在页高速缓存中,如果这种检查不够高效和迅速,搜索和检查页高速缓存带来的开销,会抵消使用页高速缓存所带来的好处,目前Linux内核使用xarray保存所有缓存的页,只需要给定文件address_space和对应页面的index,就可以从xarray中快速查找到所需要的页。

回写线程

flush线程负责脏页回写磁盘。

读写文件流程

借助分析读写一个文件的流程,加深对I/O系统的理解。

推荐阅读陈义全大佬的Linux内核读文件过程

Linux内核设计与实现总结(8) —— I/O子系统

https://lyq.blogd.club/2022/08/19/LKD-conclusion-8/

Author

lyq1996

Posted on

2022-08-19

Updated on

2022-09-09

Licensed under

Comments