进程打开及读写文件的过程

1 进程关于 VFS 的几个内核数据结构

这里只总结一下其中关于打开文件的几个内核数据结构。如图所示:

1.png-132.5kB

1.1 file_struct 结构体

每个进程在 Linux内核中都有一个被称为进程描述符的 task_struct 结构体(也即操作系统中的 PCB),内核通过这个结构体来管理进程。

task_struct 中有一个指针(struct files_struct *files;)指向 files_struct 对象,这个结构体保存了这个进程所有打开的文件的相关信息。其中主要有:

  • fd_set open_fds_init;。open_fds_init 是一个 fd_set 对象,它是一该进程中关于所有 fd 的位图;
  • struct fdtable fdtab;。fdtab 是一个 fdtable 对象,用来管理该进程的所有 fd;
  • 以及 struct file *fd_array[NR_OPEN_DEFAULT];。fd_array 是一个 file 类型的指针数组,每个元素指向一个 file 对象。

一个进程只有一个 files_struct 对象:

  • 当 fork 出一个子进程时,会拷贝父进程的 task_sturct 对象,当然也就会拷贝 task_struct 对象中的 files_struct 对象。但由于 files_struct 对象中的 fd_array 是一个指针数组(其中每个元素为 file 指针),因此子进程和父进程会共享每个 file 对象;
  • 当用 dup 或 dup2 时(假设是复制 fd1 到 fd2),会标记应用进程中关于 fd 位图中 fd2 为已用状态,然后修改 fd_array 中 fd2 对应的指针,使其和 fd1 指向相同的 file 对象。

1.2 fdtable 结构体

fdtable 对象用来管理一个进程的所有 fd。

其中主要有两个元素:

  • fd_set *open_fds;。open_fds 指向着该进程中 files_struct 对象中的 open_fds_init;
  • struct file **fd,fd 指向着 fd_array 的首元素(是一个 struct file* 类型)。给定一个位图中正在使用的 fd,即可计算出在 fd_array 这个数组中这个 fd 对应的 file 对象的指针,进而获取到这个 file 对象。

1.3 file 结构体

file 对象是由 open 系统调用生成的,当一个文件被多个进程打开时,这个文件则对应着多个 file 对象。

file 结构体中定义了一个 struct address_space *f_mapping。f_mapping 指向一个 address_space 对象,这个对象主要用来实现页高速缓存,从而降低磁盘 I/O 次数。关于这个对象会在后边详细说明。

file 对象中定义了引用计数。

  • 当存在一个 fd 指向一个目录项时,就使这个文件表项的引用计数加 1;
  • 当 close 指向这个文件表项的描述符时,会使得引用计数减 1;
  • 当引用计数减为 0 时,才会真正释放这个文件表项所占内存空间。

1.4 dentry 结构体及 dentry cache

file 结构体中还定义了一个 struct dentry *f_dentry。f_dentry 指向一个 dentry 对象。每个 dentry (director entry,目录项) 结构体(与进程无关)中定义了一个 struct qstr d_name 以及 struct inode *d_inode。前者表示目录项的文件名(可能是目录文件,也可能是个其他文件),后者指向一个 inode 对象。

所有的 dentry 对象组成一个双向链表,并与 hash 表配合构成 dentry cache(通过两者实现 LRU 缓存),称为目录项高速缓存。它维护着所有近期打开的文件(包括目录文件)的路径以及对应的 inode 对象地址。

每个 dentry 结构体还定义了一个引用计数 d_count。

  • 当进程打开一个文件时,会建立一个新的 file 对象(此时它的引用计数为 1)。
  • 如果在 dentry cache 中该文件的 dentry 处于使用中状态(也即 inuse 状态),则这个 file 对象所指向的 dentry 对象中的引用计数加 1;
  • 如果处于未使用状态(也即 unuse 状态),则需要进行适当处理,然后置这个 file 对象所指向的 dentry 对象中的引用计数为 1。
  • 当 close 一个文件描述符时,假设导致了这个文件描述符指向的文件表项所占内存空间被释放(引用计数减为 0),此时就会对这个 file 对象所指向的 dentry 对象中的引用计数减 1。
  • 当 dentry 对象的引用计数减为 0 时,会将这个 dentry 置为 unuse 状态。当某个处于 unuse 状态的 dentry 对象因 LRU 策略从 dentry cache 中删除时,才会释放它所指向的 inode 对象所占的内存空间。

这里写得比较糙,因为还有负状态没有考虑到。

1.5 inode 结构体

inode 结构体(与进程无关)对应的对象存储的是从磁盘中某个文件的 inode 节点中读取的数据。一个磁盘上的文件对应一个 inode 对象。

同上,

  • 在打开一个文件时,只有当 dentry cache 中没有这个文件对应的 dentry 对象,或者存在对应的 dentry 对象但是该对象中的 d_inode 指针为 NULL 时,才会读取该文件在磁盘中的 inode 节点中的数据到内存中(建立 inode 对象)。
  • 当某个处于 unuse 状态的 dentry 对象因 LRU 策略从链表中 dentry cache 中删除时,才会真正释放这个 inode 对象所占的内存空间。

2 进程打开文件的过程

  1. 首先根据给定的路径,到 dentry cache 中查找是否有相应目录项的 dentry 对象。若不存在,或者存在但其中的 d_inode 指针为 NULL,则需要先从磁盘中读取这个文件的 inode 节点到一个新建的 inode 对象中,同时更新 d_inode 指针。每次查找完一个目录项的 dentry 对象之后,结合该目录项的权限字段,文件所有者和文件所属用户等字段,以及进程的 有效用户ID 或 有效用户组ID,来判定该进程是否对这个目录项具有 执行权限。如果没有,则文件打开失败。

    一般来说,所有的目录文件的权限字段中,对应的三个位上都应该具有 x 权限。因为这样才能正确地根据路径对文件进行操作。

    r 权限表示读取有权读取该目录下的所有目录项,比如用 ls 命令。

  2. 如果进程对路径上的所有目录项都具有执行权限。接着,在 dentry cache 中查找是否存在所要操作的文件对应的 dentry 对象。若不存在,或者存在但其中的 d_inode 指针为 NULL,则需要先从磁盘中读取这个文件的 inode 节点到一个新建的 inode 对象中,同时更新 d_inode 指针。

  3. 根据所要操作的文件对应的 dentry 对象中 d_inode 指针所指向的 inode 对象,读取其中的权限字段,及文件所有者和文件所属用户等字段。然后根据这些字段以及进程的 有效用户ID 或 有效用户组ID 来判定该进程对该文件是否具有相应的权限。如果不具有相应权限,则文件打开失败;
  4. 若成功打开文件。首先建立一个文件表项(即 file 对象),同时在这个进程的 task_struct 对象中的 file_struct 对象中的 fd 数组中获取一个可用的 fd)。然后置 file 对象里的 dentry 指针指向这个文件对应的 dentry 对象。

3 进程读写文件的过程

3.1 不带缓冲的 I/O

即用 open 函数打开文件,用 read 和 write 函数进行读写。

不带缓冲的 I/O,是指不会在进程的用户空间中增设缓冲,并不是指 I/O 不用缓冲。事实上,在内核空间中设有缓冲区,叫做磁盘高速缓冲或者页高速缓冲。而 VFS 正是与页高速缓冲直接进行交互,而非和磁盘进行交互。

inode 结构体中除了保存磁盘中 inode 节点的信息外,还有一个指针 i_mapping 指向一个 address_space 对象。

address_space 对象缓存的是物理内存页面,建立在磁盘与内存之间,从而减少 I/O 的次数。

一个磁盘上的文件对应一个 address_space 对象,也就是说,一个 inode 对象对应于一个 address_space 对象。当有多个进程打开同一个文件时,内核中针对该文件只有一个 address_space 对象。

当打开文件时设置了 O_DIRECT 标志时,读写的数据不会进入到内核的页高速缓存中,而是直接到用户进程或者磁盘中。在一些数据库软件中设置 O_DIRECT 标志是常用的做法。

address_space 结构体中有一个 radix_tree_root 对象(名为 page_tree),它对应着一棵 radix tree。该树的每个叶节点代表一个 page 对象,它对应于一个页面(存储于内存中,通过磁盘 I/O 读取逻辑块,一般一次 I/O 传输数据的大小为一个逻辑块)。逻辑块是针对文件来讲的,物理块是针对磁盘等块设备来讲的,而页面是针对内存来讲的。一个页面对应于整数倍数个物理块。一个文件的逻辑块之间是连续的(每个文件的逻辑块编号都从 0 开始),一个文件所占物理块之间的地址可能不连续,页面之间的地址也可能不连续。

内核中某个文件的所有缓存的页的内存空间,在该文件对应的 inode 对象或者 address_space 对象所占内存空间被释放后,也会被释。

3.1.1 读文件过程

读文件时,主要分为 两个阶段:数据准备阶段;数据从内核空间中的页高速缓存中的相应页中拷贝到用户空间中。具体步骤如下:

  1. 在 inode 对象中,通过当前文件偏移量计算出要读取的页(具体地,就是计算出页号以及页内偏移量);
  2. 通过 inode 对象中的 address_space 对象中的 radix tree,查找该页是否存在;
  3. 若不存在,则创建一个页,同时根据 inode 中的逻辑地址到物理地址的映射,找到文件中对应物理块(可能包含多个物理块),然后读取相应的数据到该页中。重新进行第 2 步查找页缓存。
  4. 有时候,可能要读取多个页(所要读取的字节存在于多个页中),此时重复执行 2 步骤即可。所需要的数据所在的所有页都准备好后,就从内核空间中的页高速缓存的相应页中将需要的数据拷贝到进程的用户空间中。读取操作完全结束后,根据读取的字节数增加当前文件偏移量。

3.1.2 写文件过程

写文件时,主要分为 三个阶段:数据准备阶段;数据从用户空间中拷贝到内核空间中的页高速缓存的相应页中;将此次操作所写数据对应的脏页写入到设备中。按如下步骤进行:

  1. 在 inode 对象中,通过当前文件偏移量计算出要读取的页(具体地,就是计算出页号以及页内偏移量);
  2. 通过 inode 对象中的 address_space 对象中的 radix tree,查找该页是否存在;
  3. 若不存在,则创建一个页,同时根据 inode 中的逻辑地址到物理地址的映射,找到文件中对应物理块(可能包含多个物理块),然后读取相应的数据到该页中。重新进行第 2 步查找页缓存。
  4. 有时候,可能要写数据到多个页中,此时重复执行该步骤 2 即可。所需要的数据所在的页都准备好后,就将数据从用户空间拷贝到内核空间中的页高速缓存的相应的页中(如果要写入的位置存在数据,则直接覆盖)。此次操作的所有数据写入到页高速缓存中后,根据写入的字节数增加当前文件偏移量。
  5. 将此次操作所写数据对应的脏页写入到设备中。

注意:VFS 只是和页缓存直接进行交互,磁盘对 VFS 透明。而完整的 I/O 操作与文件打开时设置的 mode 有关,即阻塞与非阻塞(O_NONBLOCK 是否被置位),同步与异步(O_SYNC 与 O_ASYNC 是否被置位)。

一般情况下,open 默认并未设置 O_NONBLOCK,O_SYNC 与 O_ASYNC。此时,read 在数据准备阶段,以及从内核空间拷贝数据到用户空间阶段都会阻塞,直至所需数据读取到内存空间后才返回。write 函数也会在数据准备阶段,以及从用户空间拷贝数据到内核空间阶段阻塞,直至所需要的页都存在于页高速缓存中才返回,并不会在将脏页写回设备阶段阻塞。如果设置了 O_SYNC,则 write 函数在也会在将脏页写回设备阶段阻塞,直至数据均写到设备中才返回。如果设置了 O_ASYNC,则 read 和 write 函数在所有阶段均不会阻塞,而是直接返回。当所有读取都结束后,内核通过发送信号告知进程。

详见:《文件读写阻塞与非阻塞,同步与异步》

3.1.3 lseek

该函数可以用来设定当前文件偏移量。具体地,就是通过函数参数中的 whence 值和 offset 值来计算出新的当前文件偏移量。

另外,lseek 除了设定当前文件偏移量外,不会执行其他任何操作。

3.2 带缓冲的 I/O

也即标准 I/O,涉及到的函数有 fopen,fread,fwrite,fget,fput,fgets,fputs,fscanf,fprintf 等等。

带缓冲的 I/O 与不带缓冲的 I/O 的最大区别在于,前者在用户空间中设置了缓冲(在用 fopen 打开文件时会自动调用 malloc 语句分配内存空间),而后者没有。带缓冲的 I/O 会尽可能减少调用 read 和 write 的次数,从而减少用户态和内核态之间的切换次数

缓存有两种类型,一种是全缓存,一种是行缓存,它们决定着读写缓存的时机。

3.2.1 读文件过程

首先根据当前文件偏移量(用户空间中的缓存的当前文件偏移量,而非内空间中的缓存的当前文件偏移量,见后面小节)判定出读取的首字节的位置是否在缓存中。

至于如何确定的,还不清楚。不过我猜测,应该有一个缓存中最后一个字节所在位置的指针,假设称为缓存末尾位置指针。这样就可以知道缓存中末尾位置字节在文件中的文件偏移量(也即内核空间中的当前文件偏移量),以及通过将缓存中末尾位置字节在文件中的文件偏移量减去缓存末尾位置指针的值,计算出缓存中首字节在文件中的文件偏移量。然后判定用户空间的当前文件偏移量是否在这个范围内,从而来判定所要读取的首字节的位置是否在缓存中。

若读取的首字节的位置在该缓存中,则从读取位置(用户空间的文件偏移量减去缓存中首字节在文件中的偏移量得到的差值)处开始读取,然后增加当前文件偏移量(此时,标准 I/O 读取函数可能还需要读取其他数据)。否则,如果读取某个字节时超出了这个范围:

  • 对于全缓存。用 read 函数读取数据到缓存中,直至缓存被读满(此时可能会读取一些标准 I/O 读取函数用不到的数据到缓存中),或者读取到了文件末尾。然后,重置缓存末尾位置指针为最后一个字节处。紧接着,标准 I/O 读取函数继续从读取位置指针处开始读取。读取结束后,增加当前文件偏移量(此时,标准 I/O 读取函数可能还需要读取其他数据)。

    全缓存的本质在于,一次读尽量多的数据到用户空间的缓存中,从而尽可能减少调用 read 和 write 的次数。

  • 对于行缓存。用 read 函数读取数据到缓存中,直至 read 函数读取到一个 ‘\n’,或者缓存被读满(未遇到 ‘\n’),或者读到了文件末尾。然后,重置缓存末尾位置指针为最后一个字节处(为 ‘\n’)。紧接着,标准 I/O 读取函数继续从读取位置指针处开始进行读取。读取结束后,增加当前文件偏移量(此时,标准 I/O 读取函数可能还需要读取其他数据)。

当读取完缓存中的数据之后,标准 I/O 读取函数还需要继续读取其他数据,此时就可能需要调用多次 read 函数来完成。重复上述过程,直至标准 I/O 读取函数所需要的的所有数据都被读取完。

3.2.2 写文件过程

  • 对于全缓存。当缓存被写满时,或者 fflush 输出流时,或者执行 exit 函数时,则用 write 函数将其写入设备,然后清空缓存。
  • 对于行缓存。当写入一个 ‘\n’ 时,或者 执行标准 I/O 读取函数 时,或者 fflush 输出流时,或者执行 exit 函数时,则用 write 函数将缓存里 ‘\n’ 前面的内容写入设备。

注意:fflush 函数只对写操作产生预期的效果,也即调用 write 操作将数据写入到设备,然后清理缓存。但对于 读取操作,由于平台的不同,故可能不会达到清理缓存的效果(比如 Linux 上)。

3.2.2 fseek

另外,标准 I/O 的当前文件偏移量是针对用户空间的缓存的(这里指的是 ftell 函数的返回值。当然,也存在针对内核空间的缓存的当前文件偏移量),而无缓冲的 I/O 的当前文件偏移量则是针对内核空间的缓存的。

  • fseek 根据函数参数中的 whence 值和 offset 值来计算出新的当前文件偏移量。
  • fseek 只是用来设定用户空间缓存的当前文件偏移量,不会执行其他任何的操作。
  • 对于标准 I/O 读写函数,每次都会

Reference