操作系统导论 第40章 文件系统实现
本章将介绍一个简单的文件系统实现,称为VSFS(Very Simple File System,简单文件系统)。它是典型 UNIX 文件系统的简化版本,因此可用于介绍一些基本磁盘结构、访问方法和各种策略,可以在当今许多文件系统中看到。
文件系统是纯软件。与 CPU 和 内存虚拟化 的开发不同,不会添加硬件功能来使文件系统的某些方面更好地工作(但我们需要注意设备特性,以确保文件系统运行良好)。由于在构建文件系统方面具有很大的灵活性,因此人们构建了许多不同的文件系统,从 AFS (Andrew 文件系统)到 ZFS(Sun 的Zettabyte 文件系统)。所有这些文件系统都有不同的数据结构,在某些方面优于或逊于同类系统。因此,我们学习文件系统的方式是通过案例研究:首先,通过本章中的简单文件系统(VSFS)介绍大多数概念。然后,对真实文件系统进行一系列研究,以了解它们在实践中有何区别。
关键问题:如何实现简单的文件系统
如何构建一个简单的文件系统?磁盘上需要什么结构?它们需要记录什么?它们如何访问?
一、思考方式
考虑文件系统时,通常建议考虑它们的两个不同方面。如果理解了这两个方面,可能就理解了文件系统基本工作原理。
第一个方面是文件系统的数据结构(data structure)。换言之,文件系统在磁盘上使用哪些类型的结构来组织其数据和元数据?即将看到的第一个文件系统(包括下面的 VSFS)使用简单的结构,如块或其他对象的数组,而更复杂的文件系统(如SGI的XFS) 使用更复杂的基于树的结构
文件系统的第二个方面是访问方法(access method)。如何将进程发出的调用,如open()、 read()、write()等,映射到它的结构上?在执行特定系统调用期间读取哪些结构?改写哪些结构?所有这些步骤的执行效率如何?
二、整体组织
现在来开发 VSFS 文件系统在磁盘上的数据结构的整体组织。我们需要做的第一件事是将磁盘分成块(block)。简单的文件系统只使用一种块大小,这里正是这样做的。选择常用的4KB。
对构建文件系统的磁盘分区的看法很简单:一系列块,每块大小为 4KB。 在大小为 N个4KB块的分区中,这些块的地址为从 0 到 N−1。假设有一个非常小的磁盘,只有64块:
文件系统必须记录每个文件的信息。该信息是元数据 (metadata)的关键部分,并且记录诸如文件包含哪些数据块(在数据区域中)、文件的大小, 其所有者和访问权限、访问和修改时间以及其他类似信息的事情。为了存储这些信息,文件系统通常有一个名为inode的结构。
为了存放 inode,还需要在磁盘上留出一些空间。我们将这部分磁盘称为 inode表 (inode table),它只是保存了一个磁盘上 inode 的数组。因此,假设将 64个块中的 5块用于inode,磁盘映像现在看起来如下:
inode 通常不是那么大,例如,只有 128 或 256 字节。假设每个 inode 有 256 字节,一个4KB 块 可以容纳 16 个inode,而我们上面的文件系统则包含 80 个 inode。 在我们简单的文件系统中,建立在一个小小的64块分区上,这个数字表示文件系统中可以拥有的最大文件数量。但是请注意,建立在更大磁盘上的相同文件系统可以简单地分配更大的inode表,从而容纳更多文件。
到目前为止,文件系统有了数据块(D)和 inode(I),但还缺一些东西。你可能已经猜到,还需要某种方法来记录 inode 或 数据块 是空闲还是已分配。因此,这种分配结构(allocation structure)是所有文件系统中必需的部分。
当然,可能有许多分配记录方法。例如,我们可以用一个空闲列表(free list),指向第一个空闲块,然后它又指向下一个空闲块,依此类推。我们选择一种简单而流行的结构, 称为位图(bitmap),一种用于数据区域(数据位图,data bitmap),另一种用于inode表(inode 位图,inode bitmap)。位图是一种简单的结构:每个位用于指示相应的对象/块是空闲(0) 还是正在使用(1)。因此新的磁盘布局如下,包含inode位图(i)和数据位图(d):
在极简文件系统的磁盘结构设计中,还有一块。将它保留给超级块(superblock),在下图中用 S 表示。超级块包含关于该特定文件系统的信息, 包括例如文件系统中有多少个inode和数据块(在这个例子中分别为80和56)、inode表的开始位置(块3)等等。它可能还包括一些幻数,来标识文件系统类型(在本例中为VSFS)。
三、文件组织:inode
文件系统最重要的磁盘结构之一是 inode,几乎所有的文件系统都有类似的结构。名称 inode 是 index node(索引节点)的缩写,这些节点最初放在一个数组中,在访问特定 inode 时会用到该数组的索引。
每个 inode 都由一个数字(称为inumber)隐式引用,我们之前称之为文件的低级名称 (low-level name)。在 VSFS(和其他简单的文件系统)中,给定一个 inumber,能够直接计算磁盘上相应节点的位置。例如,如上所述,获取 VSFS 的 inode 表:大小为 20KB (5个4KB块),因此由80个inode(假设每个 inode 为 256 字节)组成。进一步假设 inode 区域从 12KB 开始(即超级块从 0KB 开始,inode 位图在 4KB 地址,数据位图在 8 KB,因此 inode 表紧随其后)。因此,在 VSFS 中,为文件系统分区的开头提供了以下布局(特写视图):
要读取 inode 号32,文件系统首先会计算 inode 区域的偏移量(32×inode的大小,即 8192),将它加上磁盘 inode 表的起始地址(inodeStartAddr = 12KB),从而得到希望的 inode 块的正确字节地址:20KB。回想一下,磁盘不是按字节可寻址的,而是由大量可寻址扇区 组成,通常是 512 字节。因此,为了获取包含索引节点 32 的索引节点块,文件系统将向节点(即40)发出一个读取请求,取得期望的inode块。更一般地说,inode 块的扇区地址 iaddr 可以计算如下:
blk = (inumber * sizeof(inode_t)) / blockSize;
sector = ((blk * blockSize) + inodeStartAddr) / sectorSize;
在每个 inode 中,实际上是所有关于文件的信息:文件类型(例如,常规文件、目录等)、 大小、分配给它的块数、保护信息(如谁拥有该文件以及谁可以访问它)、一些时间信息(包括文件创建、修改或上次访问的时间文件下),以及有关其数据块驻留在磁盘上的位置的信息(如某种类型的指针)。将所有关于文件的信息称为元数据(metadata)。实际上,文件系统中除了纯粹的用户数据外,其他任何信息通常都称为元数据。表40.1所示的是ext2 [P09]的 inode 的例子。
设计 inode 时,最重要的决定之一是它如何引用数据块的位置。一种简单的方法是在 inode 中有一个或多个直接指针(磁盘地址)。每个指针指向属于该文件的一个磁盘块。这种方法有局限:例如,如果你想要一个非常大的文件(例如,大于块的大小乘以直接指针数),那就不走运了
多级索引
为了支持更大的文件,文件系统设计者必须在 inode 中引入不同的结构。一个常见的思路是有一个称为间接指针(indirect pointer)的特殊指针。它不是指向包含用户数据的块, 而是指向包含更多指针的块,每个指针指向用户数据。因此,inode可以有一些固定数量(例如 12 个)的直接指针和一个间接指针。如果文件变得足够大,则会分配一个间接块(来自磁盘的数据块区域),并将 inode 的间接指针设置为指向它。假设一个块是4KB,磁盘地址是 4 字节,那就增加了 1024个指针。文件可以增长到(12 + 1024)×4KB,即4144KB。
在这种方法中,你可能希望支持更大的文件。为此,只需添加另一个指向 inode 的指针:双重间接指针(double indirect pointer)。该指针指的是一个包含间接块指针的块。因此,每个间接块都包含指向数据块的指针,双重间接块提供了可能性,允许使用额外的 1024×1024 个 4 KB块来增长文件,换言之,支持超过4GB大小的文件。不过,你 可能想要更多,我们打赌你知道怎么办:三重间接指针(triple indirect pointer)。
总之,这种不平衡树被称为指向文件块的多级索引(multi-level index)方法。来看一个例子,它有 12 个直接指针,以及一个间接块和一个双重间接块。假设块大小为 4KB,并且指针为 4字节,则该结构可以容纳一个刚好超过4GB的文件,即(12 + 1024 + 1024^2)× 4KB。增加一个三重间接块,你是否能弄清楚支持多大的文件?(提示:很大)
使用少量的直接指针(12是一个典型的数字),inode 可以直接指向 48KB 的数据,需要一个 (或多个)间接块来处理较大的文件。参见Agrawal等人最近的研究[A+07]。表40.2总结了 这些结果。
四、目录组织
在VSFS中(像许多文件系统一样),目录的组织很简单。一个目录基本上只包含一个二元组(条目名称,inode号)的列表。对于给定目录中的每个文件或目录,目录的数据块中都有一个字符串和一个数字。对于每个字符串,可能还有一个长度(假定采用可变大小的名称)。
例如,假设目录 dir(inode号是5)中有 3 个文件(foo、bar和foobar),它们的 inode 号分别为12、13 和 24。dir 在磁盘上的数据可能如下所示:
在这个例子中,每个条目都有一个 inode 号,记录长度(名称的总字节数加上所有的剩余空间),字符串长度(名称的实际长度),最后是条目的名称。请注意,每个目录有两个 额外的条目:.(点)和..(点点)。点目录就是当前目录(在本例中为 dir),而点点是父目录(在本例中是根目录)
删除一个文件(例如调用 unlink())会在目录中间留下一段空白空间,因此应该有一些方法来标记它(例如,用一个保留的 inode 号,比如0)。这种删除是使用记录长度的一个原因:新条目可能会重复使用旧的、更大的条目,从而在其中留有额外的空间。
确切的目录存储在哪里。通常,文件系统将目录视为特殊类型的文件。 因此,目录有一个inode,位于 inode 表中的某处(inode 表中的 inode 标记为“目录”的类型字段,而不是“常规文件”)。该目录具有由 inode 指向的数据块(也可能是间接块)。这些数据块存在于我们的简单文件系统的数据块区域中。我们的磁盘结构因此保持不变。
再次指出,这个简单的线性目录列表并不是存储这些信息的唯一方法。像以前一样,任何数据结构都是可能的。例如,XFS [S+96]以 B 树形式存储目录,使文件创建操作(必须确保文件名在创建之前未被使用)快于使用简单列表的系统,因为后者必须扫描其中的条目。
五、空闲空间管理
文件系统必须记录哪些 inode 和 数据块是空闲的,哪些不是,这样在分配新文件或目录时,就可以为它找到空间。因此,空闲空间管理(free space management)对于所有文件系 统都很重要。在VSFS中,用两个简单的位图来完成这个任务。
例如,当我们创建一个文件时,我们必须为该文件分配一个 inode。文件系统将通过位图搜索一个空闲的内容,并将其分配给该文件。文件系统必须将 inode 标记为已使用(用1),并最终用正确的信息更新磁盘上的位图。分配数据块时会发生类似的一组活动。
为新文件分配数据块时,还可能会考虑其他一些注意事项。例如,一些 Linux 文件系统 (如ext2 和 ext3)在创建新文件并需要数据块时,会寻找一系列空闲块(如8块)。通过找到这样一系列空闲块,然后将它们分配给新创建的文件,文件系统保证文件的一部分将在磁盘上并且是连续的,从而提高性能。因此,这种预分配(pre-allocation)策略,是为数据块分配空间时的常用启发式方法。
六、访问路径:读取和写入
现在已经知道文件和目录如何存储在磁盘上,我们应该能够明白读取或写入文件的操作过程。理解这个访问路径(access path)上发生的事情,是开发人员理解文件系统如何工作的第二个关键。请注意!
对于下面的例子,假设文件系统已挂载,因此超级块已经在内存中。其他所有内容(如 inode、目录)仍在磁盘上。
从磁盘读取文件 open ("/foo/bar", O_RDONLY)
在这个简单的例子中,让我们先假设你只是想打开一个文件(例如/foo/bar,读取它, 然后关闭它)。对于这个简单的例子,假设文件的大小只有 4KB(即1块)。
当你发出一个open ("/foo/bar", O_RDONLY) 调用时,文件系统首先需要找到文件 bar 的 inode,从而获取关于该文件的一些基本信息(权限信息、文件大小等等)。为此,文件系统必须能够找到 inode,但它现在只有完整的路径名。文件系统必须遍历(traverse)路径名, 从而找到所需的 inode。
所有遍历都从文件系统的根开始,即根目录(root directory),它就记为 / 。因此,文件系统的第一次磁盘读取是根目录的 inode。但是这个 inode 在哪里?要找到 inode,我们必须知道它的 i-number。通常,我们在其父目录中找到文件或目录的 i-number。根没有父目录(根据定义)。因此,根的 inode 号必须是“众所周知的”。在挂载文件系统时,文件系统必须知道它是什么。在大多数UNIX文件系统中,根的 inode 号为2。因此,要开始该过程,文件系统会读入inode号2的块(第一个inode块)。
一旦 inode 被读入,文件系统可以在其中查找指向数据块的指针,数据块包含根目录的内容。因此,文件系统将使用这些磁盘上的指针来读取目录,在这个例子中,寻找 foo 的条目。通过读入一个或多个目录数据块,它将找到 foo 的条目。一旦找到,文件系统也会找到下一个需要的foo的 inode 号(假定是44)。
下一步是递归遍历路径名,直到找到所需的 inode。在这个例子中,文件系统读取包含 foo 的 inode 及其目录数据的块,最后找到 bar 的 inode 号。open() 的最后一步是将 bar 的 inode 读入内存。然后文件系统进行最后的权限检查,在每个进程的打开文件表中,为此进程分配一个文件描述符,并将它返回给用户。
打开后,程序可以发出 read() 系统调用,从文件中读取。第一次读取(除非 lseek() 已被调用,则在偏移量0处)将在文件的第一个块中读取,查阅 inode 以查找这个块的位置。它也会用新的最后访问时间更新 inode。读取将进一步更新此文件描述符在内存中的打开文件表,更新文件偏移量,以便下一次读取会读取第二个文件块,等等。
在某个时候,文件将被关闭。这里要做的工作要少得多。很明显,文件描述符应该被释放,但现在,这就是FS真正要做的。没有磁盘I/O发生。
整个过程如表40.3所示(向下时间递增)。在该表中,打开导致了多次读取,以便最终找到文件的 inode。之后,读取每个块需要文件系统首先查询 inode,然后读取该块,再使用写入更新inode 的最后访问时间字段。花一些时间,试着理解发生了什么。
另外请注意,open 导致的 I/O 量与路径名的长度成正比。对于路径中的每个增加的目录,我们都必须读取它的 inode 及其数据。更糟糕的是,会出现大型目录。在这里,我们只需要读取一个块来获取目录的内容,而对于大型目录,我们可能需要读取很多数据块才能找到所需的条目。是的,读取文件时生活会变得非常糟糕。你会发现,写入一个文件(尤其是创建一个新文件)更糟糕
写入磁盘
写入文件是一个类似的过程。首先,文件必须打开(如上所述)。其次,应用程序可以发出write() 调用以用新内容更新文件。最后,关闭该文件。
与读取不同,写入文件也可能会分配(allocate)一个块(除非块被覆写)。当写入一个新文件时,每次写入操作不仅需要将数据写入磁盘,还必须首先决定将哪个块分配给文件, 从而相应地更新磁盘的其他结构(例如 数据位图 和 inode)。因此,每次写入文件在逻辑上会导致5个I/O:一个读取数据位图(然后更新以标记新分配的块被使用) 一个写入位图(将它的新状态存入磁盘),再是两次读取,然后写入 inode(用新块的位置更新),最后一次写入真正的数据块本身
考虑简单和常见的操作(例如文件创建),写入的工作量更大。要创建一个文件,文件系统不仅要分配一个 inode,还要在包含新文件的目录中分配空间。这样做的 I/O 工作总量非常大:一个读取 inode 位图(查找空闲inode),一个写入 inode 位图(将其标记为已分配), 一个写入新的 inode 本身(初始化它),一个写入目录的数据(将文件的高级名称链接到它的inode 号),以及一个读写目录 inode 以便更新它。如果目录需要增长以容纳新条目,则还需要额外的I/O(即数据位图和新目录块)。所有这些只是为了创建一个文件。
来看一个具体的例子,其中创建了 file/foo/bar,并且向它写入了 3 个块。表 40.4 展示了在 open() (创建文件)期间和在 3 个 4KB 写入期间发生的情况。
在表中,对磁盘的读取和写入放在导致它们发生的系统调用之下,它们可能发生的大致顺序从表的顶部到底部依次进行。你可以看到创建该文件需要多少工作:在这种情况下,有10次I/O,用于遍历路径名,然后创建文件。还可以看到每个分配写入需要5次 I/O:一对读取和更新inode,另一对读取和更新数据位图,最后写入数据本身。文件系统如何以合理的效率完成这些任务
七、缓存和缓冲
上面的例子所示,读取和写入文件可能是昂贵的,会导致(慢速)磁盘的许多I/O。 这显然是一个巨大的性能问题,为了弥补,大多数文件系统积极使用系统内存(DRAM) 来缓存重要的块
想象一下上面的打开示例:没有缓存,每个打开的文件都需要对目录层次结构中的每个级别至少进行两次读取(一次读取相关目录的inode,并且至少有一次读取其数据)。使用长路径名(例如,/1/2/3/…/100/file.txt),文件系统只是为了打开文件,就要执行数百次读取!
早期的文件系统因此引入了一个固定大小的缓存(fixed-size cache)来保存常用的块。 正如在讨论虚拟内存时一样,LRU及不同变体策略会决定哪些块保留在缓存中。这个固定大小的缓存通常会在启动时分配,大约占总内存的 10%。
然而,这种静态的内存划分(static partitioning)可能导致浪费。如果文件系统在给定的时间点不需要 10% 的内存,该怎么办?使用上述固定大小的方法,文件高速缓存中的未使用页面不能被重新用于其他一些用途,因此导致浪费。
相比之下,现代系统采用动态划分(dynamic partitioning)方法。具体来说,许多现代操作系统将虚拟内存页面和文件系统页面集成到统一页面缓存中(unified page cache)[S00]。 通过这种方式,可以在虚拟内存和文件系统之间更灵活地分配内存,具体取决于在给定时间哪种内存需要更多的内存。
现在想象一下有缓存的文件打开的例子。第一次打开可能会产生很多 I/O 流量,来读取目录的inode 和数据,但是随后文件打开的同一文件(或同一目录中的文件),大部分会命中缓存,因此不需要I/O。
也考虑一下缓存对写入的影响。尽管可以通过足够大的缓存完全避免读取I/O, 但写入流量必须进入磁盘,才能实现持久。因此,高速缓存不能减少写入流量,像对读取那样。虽然这么说,写缓冲(write buffering)肯定有许多优点。首先,通过延迟写入,文件系统可以将一些更新编成一批(batch),放入一组较小的I/O中。 例如,如果在创建一个文件时,inode 位图被更新,稍后在创建另一个文件时又被更新, 则文件系统会在第一次更新后延迟写入,从而节省一次I/O。其次,通过将一些写入缓冲在内存中,系统可以调度(schedule)后续的 I/O,从而提高性能。最后,一些写入可以通过拖延来完全避免。例如,如果应用程序创建文件并将其删除,则将文件创建延迟写入磁盘,可以完全避免(avoid)写入。在这种情况下,懒惰(在将块写入磁盘时)是一种 美德。
由于上述原因,大多数现代文件系统将写入在内存中缓冲 5~30s,这代表了另一种折中:如果系统在更新传递到磁盘之前崩溃,更新就会丢失。但是,将内存写入时间延长,则可以通过批处理、调度甚至避免写入,提高性能。
某些应用程序(如数据库)不喜欢这种折中。因此,为了避免由于写入缓冲导致的意外数据丢失,它们就强制写入磁盘,通过调用fsync(),使用绕过缓存的直接I/O(direct I/O)接口,或者使用原始磁盘(raw disk)接口并完全避免使用文件系统。虽然大多数应用程序能接受文件系统的折中,但是如果默认设置不能令人满意,那么有足够的控制可以让系统按照你的要求进行操作。