[搬运工]VFS虚拟文件系统

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[搬运工]VFS虚拟文件系统相关的知识,希望对你有一定的参考价值。

参考技术A 上文 Linux文件系统 中简单提了下为什么要有VFS?但并不知道VFS是什么原理?仅仅知道VFS是用户进程与各种类型的Linux文件系统之间的一个抽象接口层。

1) VFS(一)虚拟文件系统概述

2) VFS(二)读文件的过程中发生了什么

Linux下一切皆文件,共有七种文件类型,使用ls -l命令查看文件类型,输出的第一个字符代表文件类型。

-:普通文件        d:目录

c:字符设备      b:块设备

s:套接字          p:管道          l:软链

不同类型的文件底层解释方式并不相同。 比如普通文件和套接字的读写逻辑就不一样。

而相同类型的文件的底层解释方式也有可能不同。 比如都是普通文件/var/log/message和/proc/1/cmdline的读写逻辑也不一样等。 因为这些文件使用不同的文件系统实例来管理,比如/var/log/message可能是由ext4来管理的,而/proc/1/cmdline是由procfs管理,而socket是由sockfs来管理。 所以不同文件的解释权在于特定的文件系统实例 。

为了支持各种各样文件系统,Linux在用户进程和文件系统实例中间引入了一个抽象层,对不同文件系统的访问都使用相同的方法,并提供了文件的统一视图。

不同的文件系统的底层实现方式可能有很大的差异,但VFS并不关心这些。通过提供公共组件和统一框架,VFS对上层系统调用屏蔽了具体文件系统实现之间的差异性,为所有文件的访问提供了相同的API,并遵循相同的调用语义。

在内核源码fs目录下,可以找到很多常用的文件系统实现,比如ext4,xfs,sysfs等。

这张图清晰的画出, 用户进程在用户空间userspace,VFS在内核空间kernelspace,同时文件系统也在内核空间 。

在操作文件的时候,在用户态看到的是 文件描述符fd (一个整数)。

应用程序(进程)使用标准库的open函数,每打开一个文件,都会分配返回一个新的fd,之后通过read/write/close/ioctl等函数来对fd进行操作。fd只在当前进程内有效。

在x86_64架构上open函数会执行syscall指令从用户态转换到内核态,调用VFS层,并最终调用到do_sys_open函数。

1.1) do_sys_open的流程

1.2) do_filp_open的流程

在Linux中读文件时,先通过open()函数打开文件得到文件描述符fd,然后使用read()系统调用来读取文件的内容。

read()系统调用有三个参数:文件描述符,保存数据的缓冲区,读取长度,函数为ssize_t read(int fd, void *buf, size_t count)

read()系统调用在 内核中对应的入口是sys_read ,定义在fs/read_write.c中:SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)

sys_read会调用vfs_read,进入VFS层。读取文件的流程如下:

每个task都有文件描述符表fdt。索引是各个文件描述符,值是file指针。这个表在单进程多线程中是共享的,在多进程中也可以共享,也可以不共享。

和很多内核对象一样,fdt用了一个引用计数来表明有多少个task共享它。在访问fdt时,需要进行同步。每个file也有一个引用计数,因为file也是共享的。

当一个task在读文件时,另外一个task可以写这个文件,第三个task可以同时执行关闭这个文件。使用引用计数可以防止在读写文件时,文件不会被错误的关闭掉。

文件打开/读写时,将引用计数加1,关闭/读写完成时,将引用计数减1。引用计数为0时,才真正执行关闭动作。

在读写文件获取file指针时,fdt使用RCU锁来保护。这里还有个优化,就是fdt由单个task独占时,获取file指针就不需要锁了。

vfs_read会调用file->f_op->read_iter函数。

file->f_op在文件打开时,在do_dentry_open中赋值为inode->i__fop。而inode->i_fop在初始化inode时,在ext4_iget中赋值为&ext4_file_operations。

file_operations的结构如下:

struct file_operations

...

ssize_t (* read_iter ) (struct kiocb *, struct iov_iter *);

ssize_t (* write_iter ) (struct kiocb *, struct iov_iter *);

int (* iopoll )(struct kiocb *kiocb, bool spin);

...

int (* mmap ) (struct file *, struct vm_area_struct *);

unsigned long mmap_supported_flags;

int (* open ) (struct inode *, struct file *);

int (* flush ) (struct file *, fl_owner_t id);

int (* release ) (struct inode *, struct file *);

        ...   

__randomize_layout;

read_iter 函数指针负责读取文件数据。大部分文件系统的读取过程,都将read_iter置为generic_file_read_iter来实现的。或者在read_iter中做一些简单的处理,然后再调用generic_file_read_iter。

对大部分文件系统来说,读取文件数据的流程相差无几,所以VFS提供了一些通用的文件操作函数。

generic_file_read_iter是通用文件操作函数中的一个读函数,generic_file_buffered_read的包装。generic_file_buffered_read从页缓存中获取数据,如果页缓存中没有,就去块设备中读取。

从块设备中读取数据是异步的,但在没有获取到数据前,task会进入睡眠,释放CPU。数据读取完毕后,会唤醒task,将数据拷贝到用户态的buffer。

generic_file_buffered_read在一个大的循环中,将线性的文件读转换为page读。

1).将文件的读写位置和读取长度转化为page tree的index。

2).根据index,使用find_get_page找到对应的page。

2.1).如果page不存在,就进行同步预读。同步预读成功后,再次使用find_get_page得到page。

2.2).如果预读关闭或者block拥塞,导致同步预读失败,那么会转向使用mapping->a_ops->readpage进行单页读取。

3).如果page设置了PG_readahead标记,则启动一个异步预读。

4).如果page在同步预读中分配的,那么会锁住page,并阻塞在和page关联的waitqueue上(page_wait_table的一个bucket)。

异步的块层IO结束后,IO完成处理函数会解锁该page,并唤醒之前在waitqueue上睡眠的task。但这里可能会唤醒多个task (thundering herd)。因为多个page (PageLocked pages and PageWriteback pages)可以在一个waitqueue上等待。

5).如果page是之前已经读取过的,那么判断page是否是最新的。如果不是,则使用mapping->a_ops->readpage再次读取。

6).拷贝page数据到用户空间。如果拷贝了足够的字节数,或者发生错误,或者收到kill signal,这里就不再循环,而是返回已经拷贝到用户空间的字节数。

7).循环读取page,回到第1)步继续执行。

文件预读机制,假设文件会被顺序读取 。

如果程序打开文件读入第一页,那么它接下来会有很大概率会继续读取后面的页。而且文件系统也会为相邻的数据尽量分配相邻的块儿,所以 顺序读 能从中受益。大量的顺序读,通过预读,只产生少量的和底层硬件的交互,从而降低延迟。

但对于 随机读 ,事情就变得不确定了。这时候预读可能就没什么帮助,甚至会引发性能下降。因为都进来的数据可能根本用不到,还占内存。

内核提供了一个参数,/sys/block /<devname>/queue/read_ahead_kb,用来 控制设备预读的最大KB数 。在顺序读场景中可以调大,在随机读的场景调小一些,然后根据反馈来做进一步的优化。

用户态的文件读和mmap映射文件导致的缺页处理中,都要调用预读函数,预读函数最终会汇总到ondemand_readahead上。在ondemand_readahead中,拿到和file上关联的一个file_ra_state结构,来进行预读控制。

文件进行预读时,会形成一个预读窗口start, size, asyn_size。

start 指定窗口中开始预读的位置。 size 指定预读页数。 async_size 指定一个阈值,预读窗口剩余这么多页时,就开始异步预读。

ra_pages是窗口可能的最大值,和 /sys/block/<devname>/queue/read_ahead_kb的值对应。如果后者是4096,那么ra_pages就是1024。

如果程序从0开始顺序读文件,每次4k。那么在ondemand_readahead中,首先会调用get_init_ra_size初始化一个小的窗口,读入一定量的数据。

如果程序是顺序读,后续的顺序4k读会慢慢的扩大窗口,读入更多的数据,直到窗口达到最大值。

如果程序是随机读,导致窗口失效,那么就要重新初始化。如果遇到预读标记,但和之前的预读窗口不符,那么也要重新设置,以适应并发的随机读取。

程序从0开始顺序读文件,一共读5次,每次读4k:

1).第1次读4k,page不在cache中,进行同步预读,预读窗口初始化为 0, 4, 3,读4页(0-3),第1页设置readahead标志

2).第2次读4k,page在cache中,命中readahead,进行异步预读,预读窗口扩大为 4, 8, 8,读8页(4-11),第4页设置readahead标志

3).第3/4次读4k,page在cache中,不命中readahead

4).第5次读4k,page在cache中, 命中readahead,进行异步预读,预读窗口扩大为 12, 16, 16,读16页(12-27),第12页设置readahead标志。

ondemand_readahead会调用pagecache层的关键函数mapping->a_ops->readpages(在ext4中是ext4_readpages,进一步会调用ext4_mpage_readpages)。

在ext4_mpage_readpages中将page读转化为文件的block读。函数通过BIO来标识IO请求的多个段(通过bi_io_vec数组)。

每个biovec的数组项包含用于IO的page(bv_page),页内偏移(bv_offset)和IO大小(bv_len)。这些pages可以是不连续的,这简化了DMA的scatter/gather操作。

submit_bio是向块层提交bio的关键,最终该函数会使用make_request_fn将bio加入块设备的请求队列上。同时,IO调度层的工作也会在这里完成,通过指定的调度算法对IO进行排序和合并。

在IO完成后,块设备通过中断通知cpu。在中断处理函数中,触发BLOCK_SOFTIRQ。在软中断处理中,回调最终会触发bio->bi_end_io(对ext4来说是mpage_end_io),解锁之前在锁定的页面。

至此之前在该page的waitqueue上阻塞的task就可以继续执行了,从而是read函数返回,整个调用流程也就全部结束了。

虚拟文件系统(VFS)

原文链接:http://www.orlion.ga/1008/

linux在不同的文件系统之上做了一个抽象层,使得文件、目录、读写访问等概念都成为抽象层概念,这个抽象层被称为虚拟文件系统(VFS)。

linux内核的VFS子系统如下:

技术分享

    每个进程在PCB(Process Control Block)中都保存着一份文件描述符表,文件描述符就是这个表的索引,每个表项都有一个指向已打开文件的指针,一打开的文件在内核中用file结构体表示,文件描述符表中的指针指向file结构体。

    在file结构体中维护File Status Flag(file结构体的成员f_flags)和当前读写位置(file结构体的成员f_pos)。在上图中,进程1和进程2都打开同一文件,但是对应不同的file结构体,因此可以有不同的File Status Flag和读写位置。file结构体还有一个成员f_count,表示引用计数(Reference Count),如果有两个文件描述符指向同一个file结构体,那它的引用计数就是2,当close()一个文件描述符时并不会释放file结构体而是将引用计数减到1,再close一个文件描述符引用计数就会变成0同时再释放file结构体。真正的关闭文件。

   每个file结构体都指向了一个file_operations结构体,这个结构体的成员都是函数指针,指向实现各种文件操作的内核函数。例在用户程序中read一个文件描述符,read通过系统调用进入内核,然后找到这个文件描述符指向的file结构体,找到file结构体所指向的file_operations结构体,调用它的read成员所指向的内核函数以完成用户请求。对于同一文件系统上打开的常规文件来说,read、weite等文件操作的步骤和方法应该是一样的,调用的函数应该是相同的,所以图中三个打开文件的file结构体指向同一个file_operation结构体,如果打开的是非常规文件那就不一样了。每个file结构体都有一个指向dentry结构体的指针,“dentry”是directory entry(目录项)的缩写。我们传给open、stat等函数的参数是一个路径,例如/home/orlion/a,需要根据路径找到文件的inode。为了减少读盘次数,内核缓存了目录的树状结构,称为dentry cache,其中每个节点是一个dentry结构体,只要沿着路径各部分的dentry搜索即可,从根目录/找到home目录,然后找到orlion目录,然后找到文件a。dentry cache只保存最近访问过的目录项,如果要找的目录项在cache中没有,就要从磁盘中读到内存中。

    每个dentry结构体都有一个指针指向inode结构体。inode结构体保存着从磁盘inode读上来的信息。上图中有两个dentry,分别表示/home/akaedu/a和/home/akaedu/b,它们都指向同一个inode,说明这两个文件互为硬链接。inode结构体中保存着从磁盘分区的inode读上来信息,例如所有者、文件大小、文件类型和权限位等。每个inode结构体都有一个指向inode_operations结构体的指针,后者也是一组函数指针指向一些完成文件目录操作的内核函数。和file_operations不同,inode_operations所指向的不是针对某一个文件进行操作的函数,而是影响文件和目录布局的函数,例如添加删除文件和目录、跟踪符号链接等,属于同一文件系统的个inode结构体可以指向同一个inode_operation结构体。

    inode结构体有一个指向super_block结构体的指针。super_block结构体保存着从磁盘分区的超级快上读来的信息,例如文件系统类型,块大小等。super_block结构体的s_root成员是一个指向dentry的指针,表示这个文件系统的根目录被mount到哪里。

    file、dentry、inode、super_block这几个结构体组成了VFS的核心概念。

以上是关于[搬运工]VFS虚拟文件系统的主要内容,如果未能解决你的问题,请参考以下文章

VFS - 虚拟文件系统的加载和导出

VFS - 虚拟文件系统的加载和导出

虚拟文件系统(VFS)

Linux 虚拟文件系统(VFS)介绍

Linux虚拟文件系统(VFS)

linux内核源码分析之虚拟文件系统VFS