分享

快速了解操作系统的文件系统设计(在Linux中内核将所有文件组织成一个树形结构)

 山峰云绕 2023-07-31 发布于贵州

https://www.toutiao.com/article/7255663767042966051/?log_from=b9ed644082bb7_1690771038022


(在Linux中内核将所有文件组织成一个树形结构)有了VFS操作系统不关心文件的内容是什么也不关心它在磁盘上是如何储存的它只看到了字节


前言

文件系统可以理解为内存的另一个极端。单凭内存来支撑程序的运行,我们会遇到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,要从文件是如何储存的说起。

相关视频推荐

需要C/C++ Linux服务器架构师学习资料加qun812855908获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享

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选项创建一个软连接。

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

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

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多