快速掌握 - 文件系统操作指南

发表时间: 2023-12-20 11:36


文件系统可以理解为内存的另一个极端。单凭内存来支撑程序的运行,我们会遇到3个问题:

  1. 当一个程序运行时,它可以将一些信息保存到内存里面。但是,内存的大小是有限制的。
  2. 当进程结束运行的时候,它在内存中的信息会被释放掉。但是有些信息是需要保存下来的。
  3. 多个进程需要同时访问某些信息,而虚拟内存会将进程隔离。

所以,我们需要一种能够长期储存信息的介质,它的要求如下:

  1. 必须储存大量的信息。
  2. 在进程终止时,能将信息保存下来。
  3. 多个进程可以并发的访问这些信息。

解决所有这些问题的常用方法就是把信息以文件为单位,储存在磁盘上。

文件是由操作系统管理的,在一个操作系统中,负责处理与文件有关的各种事情的那一部分,就称为文件系统

文件类型

大多数操作系统都支持多种类型的文件。

  • 普通文件(文本文件与二进制文件)
  • 目录
  • 套接字,(神特么翻译成套接字,谁知道是什么鬼东西),是用来与另一个进程进行跨网络通信的文件。

在 Linux 中,内核将所有文件组织成一个树形结构:


操作系统视角的文件

操作系统将文件看成简单的字节数组,这归功于VFS,下面我们会说到。有了VFS,操作系统不关心文件的内容是什么,也不关心它在磁盘上是如何储存的,它只看到了字节。

文件系统的实现

主要还是讨论 linux 的文件系统

磁盘的物理结构这里不讨论,而且现在几乎都是SSD,只需要把磁盘当成一个字节储存介质就行。操作系统为了很好的管理与使用磁盘,将磁盘划分为一个一个的 block 与 虚拟内存的页对应,大小都是4KB(常见)。

为啥要进行 block 的划分呢?其实道理和虚拟内存的页表一样,假设我们没有划分 block,每个文件都是一个一个 byte 进行储存,那么由于我们需要管理磁盘内容,我们需要知道磁盘的那些字节是被分配的,那些字节是空闲的。要做这个管理映射本身就会浪费很多的磁盘空间。而且即使忽略这个问题,文件是按照 byte 储存的,那么一个1GB 的文件,它会分散到磁盘上的各个位置。假设你是一个磁盘,os告诉你我要读取一个文件,你去找的时候,发现该文件的第一个字节在 800 的位置,第二个字节在 800000 的位置,第3个字节在 801 的位置,那岂不是日了狗。而以 block 来储存的时候,由于局部性原理,一次性读取4KB的内容会好很多。

一个磁盘(系统盘)有很多的 block。第一个 block 叫做 boot block,主引导记录通过它将保存在该分区中的操作系统读取出来,装入内存运行。我们暂时不关心这个 block。

第二个 block 是 super block,它里面包含了关于文件系统的所有关键参数。记录的信息主要有(并不需要背下来):

  • block 与inode 的总量;
  • 未使用与已使用的inode / block 数量;
  • block 与inode 的大小(block 为1, 2, 4K,inode 为128bytes 或256bytes);
  • filesystem 的挂载时间、最近一次写入资料的时间、最近一次检验磁盘(fsck) 的时间等档案系统的相关信息;
  • 一个valid bit 数值,若此档案系统已被挂载,则valid bit 为0 ,若未被挂载,则valid bit 为1 。

我们注意到上面出现了一个没听说的东西:I-Node,这个是什么东西呢?要理解 inode,要从文件是如何储存的说起。

inode

在Linux系统中,文件由元数据(描述数据的数据)数据block组成。一个文件对应磁盘上一个 inode。

inode是用来存储文件相关属性信息的(属于元数据),所以也会消耗硬盘空间。具体包含的信息主要有该文件占据了哪些 data block,文件的字节数等等。


inode 里面储存的最重要的就是指向 data block 的编号。

假如一个文件只有 4KB,那么它只占用一个 block,我们可以直接将这个 block 的编码储存到 inode entry里面。但是如果这个文件很大,占了 100000 个 block,而inode entry的空间是有限的(格式化的时候就确定了),储存不了 100000 block 的编号,怎么办呢?

inode 的最后两个位置,一个是储存“二级指针”,一个是储存“三级指针”,都指向的是一个 data block,而这个 data block 里面没有储存文件数据,储存的是文件存放的 block 的编号。通过这样的间接方式,一个 inode 就可以描述一个非常大的文件所占用的每一个 block 的编码。

文件系统布局

有了以上的知识,我们看一个典型的 linux 文件系统的布局(磁盘上储存的东西):

boot block 与 super block 说过了。

后面两个 inode block 与 data block,显然,i-nodes 是描述磁盘上所有使用与未使用的 inode。

data 是描述磁盘所有使用与未使用的 block,具体的文件内容储存在这个区域。

那么 i-nodes bitmap 与 zone bitamap 是干啥的呢?

前面的文章,我们说过了虚拟内存,它是使用链表等方式来管理空闲 block 的,这里的 bitmap 是同样的道理。将一个 block 看成一个 bit,我们就可以用一段连续的二进制来描述每个 block 的状态,比如:000111,表示第1-3个data block 是空闲的,第4-6个 data block 是被使用的。

inodes bitmap 也是如此。这里我们注意到一个隐藏的事实,就是 inodes 占据的磁盘是固定的,也就是说 inode 的个数是有限的。一个文件对应着一个 inode,代表着我们在磁盘上储存的文件是有限的,当 inode 耗光,即使磁盘还有空闲的 block,都无法创建文件。

VFS

Linux 所支持的各种文件系统,其实现细节均不相同,比如,文件块的分配方式,目录的组织方式。如果每个应用程序都需要理解各种文件系统的具体细节,那么编程这个玩意也就太蛋疼了。像一个Android 开发程序员访问一个文件,根本不需要知道文件系统是啥。这个就是因为VFS帮助我们隐藏了很多东西。

VFS 的原理很直白:

VFS针对文件系统定义了一套通用的接口。所有与文件交互的程序都会按照这套接口来操作。

各自的文件系统都会提供VFS接口的实现。

这个与以前出现的 ImageLoader 的作用非常相似。它搞了一套接口,实现类就是 Picasso、Glide及Fresco 等等库。

读取文件的过程

有了上面的知识,我们来看一下,文件系统是如何读取一个文件内容的。

在这之前,我们需要先了解一下目录是如何储存的。上面我们说过 inode 储存了文件的很多信息,但是却不包括文件名。这是因为文件名储存在目录那里。在Linux中,和一个文件一样,一个目录有一个inode。不过,一个目录的 inode 指向的 data block 储存的是目录结构。目录结构包含的关于文件的信息量有限。它只保存文件的inode号、名称和名称的长度。


了解了目录的储存方式,我们看一个具体的例子:

假设我们的文件是 /home/xxx/workspace/csapp/src/test/files/x.txt,它里面的内容是字符串1234

操作系统拿这个路径后,首先,它需要找到 / 目录对应的 inode,那么这个inode在哪呢?由于 /是最顶级的目录,没有别的东西能包含它,所以只能将它的 inode 号当成一个常识,一般情况下它都是 2,当一个磁盘挂载的时候,os 会确定这个 inode 的值。

至于为啥一般是 2 呢?可以看这个回答,简单来说,0 不使用,1 是用来追踪 bad block 的。

有了 / 的 inode 号之后,在inodes里面找到编号对应的项,根据项里面储存的 datablock 的编号,找到其 data block 位置,在这个 data block 里面储存该目录下的所有文件的 inode。根据文件名匹配到 home,拿到它的 inode,然后再找到 home 的 data block,一直递归下去,知道找到 x.txt 的data block,这个时候,终于可以获取文件里面的内容了,磁盘就可以向内核传输数据了。

可以看到,文件的目录越深,磁盘在读取文件内容之前需要干的活就越多。

从内核的视角看文件

上面我们都是从文件系统的视角看文件,接下来我们看一下内核是如何表示一个打开的文件的(没有打开的也不用关系)。

内核用3个相关的数据结构来表示打开的文件:

  • 描述符表(discriptor table)。每个进程都有一个自己独立的描述符表,每个表项指向文件表的一个项。
  • 文件表(open file table)。内核维护一张表(所有进程共享),每个打开的文件都在这个表项里面。一个表项包含打开文件的位置,引用计数,以及 v-node 表的表项。
  • v-node table,该表也是所有进程共享,它其实就是 i-node 的一个抽象(虚拟对象)。

我们看一张经典的图:


看一个例子:

int main(){    char line[4];    printf("pid = %d\n", getpid());    int fd = open("./files/test_fd.txt", O_RDONLY, S_IRUSR);    read(fd, line, 4);    close(fd);    return 0;}

这里我们打开了一个文件,我们使用 GDB 看一下该进程描述符的变化情况。

先断点到 open 那一行:


发现只有3个默认的文件(标准输入/输出/错误流文件)。

gdb 继续走一行,走到read,发现多了一个 fd:

这里的 3 就是我们打开的文件,表示它占据描述符表的第3项。

继续走到 close 之后,发现 fd 少了一个 :


说明,close 会删除一个描述符表项,导致表项指向的文件表项(如果引用计数归0)也会被删除,再导致vnode表项被删除。

一般来说,一个打开的文件对应一个 fd,而 fd 对应一个 file table entry。但是 discriptor table 与 open file table 与 vnode table 会出现各种奇奇怪怪的组合。

举个例子,前面我们讨论过 fork,fork 会 copy 父进程的某些数据结构,而 discriptor table 正是其中之一。所以,会出现下面的情况:


fork 时,child 的 descriptor table 复制了 parent 的 descriptor table,所以他们的 descriptor table 里面的 entry 都指向 open file table 里面的同一项。

而这种行为会导致很多问题,open file table 里面维护了一个 pos 变量,它表示当前访问到文件的内容位置。这里其实就是一个并发问题,在这里例子里面,多进程可以简化为多线程情况。多个线程访问同一个文件肯定会出现各种奇葩问题。

还有一种情况,就是我们打开一个文件两次,情况会是什么样呢:

int main(){    char line[4];    printf("pid = %d\n", getpid());    int fd = open("./files/test_fd.txt", O_RDONLY, S_IRUSR);    read(fd, line, 4);    printf("read line1 -> %s\n", line);    int fd2 = open("./files/test_fd.txt", O_RDONLY, S_IRUSR);    read(fd2, line, 4);    printf("read line2 -> %s\n", line);    close(fd);    close(fd2);    return 0;}

在这种情况下,内核会维护两个 open file table entry,但是由于我们打开的是同一个文件,所以只有一个 v node entry。

也就是说,上面的例子中,line1 与 line2 的输出是一样的。因为他们有各自对应的 pos 位置。但是 vnode 只有一个,意味着如果一个 fd 对文件做了操作,另一个 fd 也是能看到这个变化的。

内核缓冲区

一个事实是,磁盘空间大,之所以能搞这么大的原因,是因为它慢。但是它太慢了,如果我们每次执行 read 与 write 操作,都是直接操作的磁盘,那么将会非常的蛋疼。所以我们需要在 Performance 与 Consistency 之间做出新的选择。

出于速度和效率考虑,我们选择放弃一定的 Consistency(也就是说当程序断电时,可能会丢失一部分数据),而选择提升一定的 Performance。实现这个提升的做法,就是加一层缓存。

read()和 write()系统调用在操作磁盘文件时不会直接发起磁盘访问,而是仅仅在用户空间缓冲区与内核缓冲区高速缓存(kernel buffer cache)之间复制数据。

例如,如下调用将 3 个字节的数据从用户空间内存传递到内核空间的缓冲区中:

write(fd, "abc", 3);

write()随即返回。在后续某个时刻,内核会将其缓冲区中的数据写入(刷新至)磁盘。(因此,可以说系统调用与磁盘操作并不同步。)如果在此期间,另一进程试图读取该文件的这几个字节,那么内核将自动从缓冲区高速缓存中提供这些数据,而不是从文件中读取过期的内容。

同样的,read() 操作将从内核缓存区中读取数据,直至把缓冲区中的数据取完,这时,内核会将文件的下一段内容读入缓冲区高速缓存(这里的描述有所简化。对于序列化的文件访问,内核通常会尝试执行预读,以确保在需要之前就将文件的下一数据块读入缓冲区高速缓存中。)。

有了缓冲区的概念,我们再来看 write/read 函数自身的 buffer 会对 IO 性能有何影响(注意函数自身的 buffer 与 内核缓冲不是同一个东西)。

前面的文章,我们说过,write 与 read 它们是系统调用,会导致程序从用户空间切换到内核空间,这个过程也是要做很多操作的,而且很可能会导致进程调度。

假设我们需要复制的文件大小为100MB,当我们设置不同的 buffer 时,消耗的时间如下图:


当 buffer 大小为 1 的时候,我们需要调用 read/write 1亿次,而当 buffer 的大小为 4096 时,只需要调用 read/write 24000 次左右,几乎达到了最优的性能。提升这么显著,就是因为我们省去了很多次系统调用,从而避免了可能的进程切换

那么为啥 buffer 从4096 再往上,提升就不明显了,这是因为,从 4096 开始,虽然我们节省了系统调用,但是这些耗时与真正的磁盘 IO 相比,就显得太小了。

我们需要传输的文件大小是一样的,内核缓存区也是一样大的,那么当内核缓存区满了之后,会写入到磁盘,这个才是主要的耗时。我们可以节省的是 read/write 操作次数。因为此时系统调用的次数相对较少,所以它们所花费的时间相对于总耗时和 CPU 时间可以忽略不计。

总之,如果与文件发生大量的数据传输,通过采用大块空间缓冲数据,以及执行更少的系统调用,可以极大地提高 I / O 性能。

文件操作API

这里就只说一个函数 dup2 。它的作用就是将 descriptor table 中的 entry 复制一下,比如:

dup2(4, 1);

这行代码会导致下图的效果:

可以看到,fd4 与 fd1 指向了同一个 open file entry。

那么这个函数有啥用呢?shell 中的重定向符号 >< 都是使用该函数实现的。比如,我们有这样的一个命令:

ls > 1.txt

本来 ls 命令的输出是在屏幕上,也就是 fd1 所对应的文件。执行 dup2 函数后,fd1 的内容被 fd4 给覆盖了,所以现在输出的内容会走到 fd4 所对应的文件,也就是 1.txt。

软/硬链接

了解了 inode,其实就很好理解 软/硬链接 是个啥了。

看一个例子:

ln 命令为文件 abc 创建了一个硬链接 xyz。

回顾文件 i-node 中存储的信息列表,会发现其中并未包含文件名,而仅通过目录列表内的一个映射来定义文件名称。其妙用在于,能够在相同或者不同目录中创建多个名称,每个均指向相同的 i-node 节点。也将这些名称称为链接,有时也称之为硬链接。

ls -li 会列出该文件的 inode 号,最后的输出展示了 abc 与 xyz 的 inode 号是一眼的。现在硬连接是个什么东西就很明显了。我们只需要在目录的结构里面增加一项,其文件名是 xyz,inode 号是 abc 的 inode 号即可。然后将 inode 的引用计数加1(引用计数法出现了)。

ln 命令可以添加-s选项创建一个软连接。

符号链接,有时也称为软链接,是一种特殊的文件类型。符号链接的地位不如硬链接。尤其是,文件的链接计数中并未将符号链接计算在内。因此,如果移除了符号链接所指向的文件,符号链接本身还将继续存在,但是无法再对其进行解引用(下溯)操作,也将此类链接称之为悬空链接。


可以看到软链接里面储存的是文件名,通过文件名,我们能访问到该文件的内容。

参考书籍

《computer systems: a programmer’s perspective》

《the linux programming interface》

《operating system design and implementation》

《understading the linux kernel》

《operating systems: three easy pieces》