这章我们会学习一个简单的文件系统实现,称为VSFS(Very Simple File System,简单文件系统)。它是典型UNIX文件系统的简化版本,因此可用于介绍一些基本磁盘结构、访问方法和各种策略,你可以在当今许多文件系统中看到。
文件系统是纯软件。与CPU和内存虚拟化的开发不同,我们不会添加硬件功能来使文件系统的某些方面更好地工作(但我们需要注意设备特性,以确保文件系统运行良好)。由于在构建文件系统方面具有很大的灵活性,因此人们构建了许多不同的文件系统,从AFS(Andrew文件系统)到ZFS(Sun的Zettabyte文件系统)。所有这些文件系统都有不同的数据结构,在某些方面优于或逊于同类系统。因此,我们学习文件系统的方式是通过案例研究:首先,通过本章中的简单文件系统(VSFS)介绍大多数概念。然后,对真实文件系统进行一系列研究,以了解它们在实践中有何区别。
思考方式
考虑文件系统时,我们通常建议考虑它们的两个不同方面。如果你理解了这两个方面,就基本理解了文件系统的基本工作原理。
1. 文件系统的数据结构
第一个方面就是文件系统的数据结构(data structure)。换言之,文件系统在磁盘上使用哪些类型的数据结构来组织其数据和元数据?我们即将看到的第一个文件系统(包括下面的VSFS)使用简单的结构,如块或其他对象的数组,而更复杂的文件系统(如SGI的XFS)使用更复杂的基于树的结构。
补充:文件系统的心智模型
正如我们之前讨论的那样,心智模型就是你在学习系统时真正想要发展的东西。对于文件系统,你的心智模型最终应该包含以下问题的答案:磁盘上的哪些结构存储文件系统的数据和元数据?当一个进程打开一个文件时会发生什么?在读取或写入期间访问哪那些磁盘结构?通过研究和改进心智模型,你可以对发生的事情有一个抽象的理解,而不是试图理解某些些文件系统代码的细节(当然这也是有用的!)。
2. 文件系统的访问方法
文件系统同的第二个方面是访问方法(access method)。如果将进程发出的调用,如open()、read()、write()等,映射到它的结构上?在执行特定系统调用期间读取哪些解构?改写哪些解构?所有这些步骤的执行效率如何?
如果你理解了文件系统的数据结构和访问方法,就形成了一个关于它如何工作的良好心智模型,这是系统思维的一个关键部分。在深入研究我们的第一个实现时,请尝试建立你的心智模型。
整体组织
我们首先来开发VSFS文件系统在磁盘上的数据结构和整体组织。我们需要做的第一件事是将磁盘分成块(block)。简单的文件系统只使用一种块大小,我们也是这样做的。我们选择常用的4KB。
因此,我们对构建文件系统的磁盘分区看法很简单:它有一系列块,每个块是4KB大小(4KB的块可以存储8个磁盘扇区,每个扇区有512字节)。在大小为N的4KB块的分区中,这些块的地址为0到N-1。假设我们有一个非常小的磁盘,只有64块:
基本组织
现在我们来思考一下,为了构建文件系统,需要在这些块中存储什么。当然,首先想到的是用户的数据。实际上,任何文件系统中大多数空间都是(并且应该是)用户的数据。我们将用于存放用户数据的磁盘区域称为数据区域(data region),简单起见,会将磁盘的一些空间固定留给这些块,在我们这个例子中,是64的块中的最后56个:
还记得我们上一章学习的内容吗,文件系统会记录每个文件的信息。该信息是元数据(metadata)的关键部分,并且还会记录诸如文件包含哪些数据块(在数据区域中)、文件的大小,其所有者和访问权限、访问的修改时间以及其它类似信息的事情。为了存储这些信息,文件系统通常会有一个inode的结构(后面会详细的介绍inode)。
为了存放inode,我们需要在磁盘中留出一些空间。这部分空间称为inode表(inode table),它只是保存了一个磁盘上inode的数据。因此,假设我们将64块中的5个块用来存放inode,那么磁盘现在看起来如下:
在这里我们需要先了解,inode通常不会很大,例如,只有128或者256字节。假设每个inode有256个字节,那么一个4KB的块就可以存放16个inode,在我们这个系统中有5个块,也就是说可以包含80个inode。我们的这个文件系统建立在一个小小的64块分区上,这个数字(80)是文件系统可以拥有的最大文件数量。如果这个文件系统建立在更大的磁盘上,那么这个inode表可以分的更大一些,从而容纳更多文件。
到目前为止,现在我们的文件系统有了56个数据块(D)和80个inode(I),但是我们还缺少一些东西,我们需要某种方式来表示这些数据块和inode的是空闲还是已分配。因此,这种分配结构(allocation structure)是所有文件系统中必需的部分。
当然,可能有很多种分配记录方法。例如,我们可以用一个空闲列表(free list),它指向第一个空闲块,然后它又指向下一个空闲块,以此类推。我们选择一种简单而流行的结构,称为位图(bitmap),我们一共需要两个位图,第一个是用于数据区域(数据位图, data bitmap),另一种用于inode表(inode位图, inode bitmap)。位图是一种简单的数据结构:它是由一组二进制位组成的序列,每个二进制位表示一个对象的状态,用0和1来表示相应的对象/块是空闲还是正在使用。因此新的磁盘布局如下,包含inode位图(i)和数据位图(d):
你可能会疑惑,对于我们这个系统来说,使用整个4KB块是不是有些杀鸡用牛刀。因为一个4KB的块可以表示32,768(32KB,一字节等于8个bit,4086*8 =32,768)个对象是否分配。而我们只有个80个inode和56个块。但是,简单起见,我们就为每个位图使用整个4KB块。
到此时,我们会发现,在我们这个只有64个块的磁盘中,还剩一个块没有被使用。这个块我们将它保留给超级块(superblock),在下图中用S表示。超级块包含关于该特定文件系统的信息,包括例入文件系统中有多少个inode和数据块(在这个例子中分别为80和56),inode表的开始位置(块3)等等。它可能还包括一些幻数,来表示文件系统类型(在本例中未VSFS)。
因此,在挂载文件系统时,操作系统将首先读取超级块,初始化各种参数,然后将该卷添加到文件系统树中。当卷中的文件被访问时,系统就会知道在哪里查找所需的磁盘上的结构。
文件组织:inode
文件系统最重要的磁盘结构之一是inode,几乎所有的文件系统都有类似的结构。名称inode是index node(索引节点)的缩写,它是有UNIX开发人员Ken Thompson给出的历史性名称,因为这些节点最开始放在一个数组中,在访问特定inode时会用到该数组的索引。
每个inode都由一个数字(称为inumber)隐式引用,我们之前称之为文件的低级名称(low-level name)。在VSFS(和其他简单的文件系统中),给定一个inumber,你应该能够计算磁盘上相应节点的位置。列入,如上所述,获取VSFS的inode表:大小为20KB(5个4KB块),因此一共由80个inode组成(每个inode是256字节)。对于我们这个例子来说,inode表是从12KB开始(即超级块0KB开始,inode位图在4KB地址,数据位图在8KB地址,因此inode表紧随其后)。因此,在VSFS中,我们为文件系统分区的开头提供了以下布局(特写视图)
假设我们现在要读取inode号32,文件系统会先计算inode区域的偏移量(32 * inode的大小,即32*256 = 8192),然后再加上磁盘inode的起始地址(inodeStartAddr = 12kb),从而得到32这个inode的正确字节地址:20KB。
回想一下,在磁盘中寻址的话,是不可以按字节寻址的,磁盘是由大量可寻址扇区组成的,每个扇区通常是512字节,一般在4KB大小的块中,会有8个扇区。因此我们需要计算一下,它会使用如下方式计算:
因此,我们为了获取inode 32的数据,我们会对磁盘扇区40发发出一个读取请求,取得包含inode号为32的inode块。因为每个inode块包含16个inode,所以inode号为32的inode位于第3个inode块中(第一个inode块包含inode号为0到15的inode,第二个inode块包含inode号为16到31的inode
因为inode块的大小为4KB,每个inode块包含16个inode,所以inode号为32的inode位于第3个inode块中(第一个inode块包含inode号为0到15的inode,第二个inode块包含inode号为16到31的inode)即块号为2。根据之前的计算,可以得到第3个inode块在磁盘上的扇区号为40。因此,文件系统会向扇区号为40的扇区发出一个读取请求,取得包含inode号为32的inode块。
在每个文件inode中,实际上是所有关于文件的信息:文件类型(例如,常规文件、目录等)、大小、分配给它的块数、保护信息(如谁拥有该文件以及谁可以访问它)、一些时间信息(包括文件创建、修改或上次访问的时间文件下),以及有关其数据块驻留在磁盘上的位置的信息(如某种类型的指针)。我们将所有关于文件的信息称为元数据(metadata)。实际上,文件系统中除了纯粹的用户数据外,其他任何信息通常都称为元数据。如下表是ext2的inode例子。
设计inode时,最重要的就是决定之一就是它如何引用数据块的位置。一种简单的方法是在inode中有一个或多个直接指针(磁盘地址)。每个指针指向属于该文件的一个磁盘块。这种方法有局限:例如,如果你想要一个非常大的文件(例如,大于块的大小乘以直接指针数(直接指针数一般有多个,)),那就很棘手了。
如果是在etx3中,一个文件系统中的块大小为4KB,每个块的磁盘地址占用4个字节,那么在一个inode中,可以有12个直接指针和一个间接指针。每个间接指针指向一个包含1024个指向用户数据块的指针的块。因此,一个文件可以最多包含12个直接指针指向的块和1024个间接指针指向的块,即(12+1024)个块。因为每个块的大小为4KB,所以文件最大可以增长到(12+1024)×4KB,即4144KB(一个间接块支持4MB大小的文件)。
补充:数据结构--inode
inode是许多文件系统中使用的通用名称,用于描述保存给定又文件的元数据的结构,例如其长度、权限以及其组成块的位置。这个名称至少可以追溯到UNIX(如果不是早期的系统,可能还会追溯到Multics)。它是index node(索引节点)的缩写,因为inodle号用于索引磁盘上的inode数组,以便查找该inode号对应的inode。我们将看到,inode的设计是文件牛系统设计的一个关键部分。大多数现代系统对于它们记录的每个文件都有这样的结构,但也许用了不同同的名字(如dnodes、fnodes等)。
多级索引
为了支持更大的文件,文件系统设计者必须在inode中引入不同的结构。一个常见的思路是使用间接指针(indirect pointer)的特殊指针。它不会直接指向包含用户数据的块,它会指向另一个块,这个块里面包含更多的指针(相当于一个二级的指针块),每个指针都指向用户的数据块。因此,inode一般设计成有一些固定数量的直接指针和一个间接指针(etx3中,有12个直接指针和1个间接指针)。因此,如果一个文件足够大(12个直接指针不够),则会分配一个间接块(来自磁盘的数据块区域),并将inode的间接指针设置为指向它。假设一个块是4KB,磁盘地址是4字节,那么一个间接块就可以增加1024个指针。那么一个最大文件可以增长到(12+1024),即4114KB。
很显然,你可能会想,可不可以多个间接块相连接,刚刚我举得例子是一个间接块。当我们的文件使用一个间接块还不够大的时候,我们可以考虑再加一个间接块:双重间接指针(double indirect pointer)。它指向的是一个间接块,而这个间接块中存储了多个直接指针,每个直接指针又指向一个数据块。通过双重间接指针,可以间接地寻址更多的数据块,从而支持更大的文件。对于双重指针来说,它可以支持4GB的文件:一个间接块支持4MB的大小(2014*4kb = 4MB),刚刚我们提到一个间接块可以增加1024个地址,那么再增加一个间接块(1024 * 4MB = 4GB ),可以支持4GB的文件。如果对于更大的文件,我觉得你可以想到解决方案:三重间接指针(triple indirect pointer)。
总之,这种不平衡树被称为指向文件块的多级索引(multi-level index)方法,刚刚我们已经知道双重间接块可以最大表示4GB大小的文件了,那么三重间接块最大可以表示 4GB * 1024 = 4TB。
很多文件系统都是使用的多级索引,包括常用的文件系统,如Linux ext2和3,NetApp的WAFL,以及原始的UNIX系统。其它文件系统,如SGI XFS和Linux ext4使用的是范围,而不是简单的指针(它有点类似于讨论虚拟内存时的段)。
你可能会好奇:为什么要使用这种不平衡树?为什么不采用不同的方法?好吧,事实证明,许多研究人员已经研究过文件系统以及它们的使用方式,几乎每次他们都发现了某种”真相“,几十年来一直如此。其中一个真相是:大多数文件很小。这种不平衡的设计反映了这样的现实。如果大多数文件确实很小,那么为这种情况优化是有意义的。因此,使用少量的指针(12个是一个典型的数据,etx3),inode可以之际指向48KB(12 * 4KB = 48KB)的数据,当有大文件时,需要一个或多个间接块来处理这些大文件。如下是Agrawal等人最近的研究结果
大多数文件很小 | 大约2KB是常见大小 |
平均文件大小在增长 | 几乎平均增长200KB |
大多数字节保存在大文件中 | 少数大文件使用了大部分空间 |
文件系统包含许多文件 | 几乎平均100KB |
文件系统大约一半是满的 | 尽管磁盘在增长,文件系统仍保持约50%是满的 |
目录通常很小 | 许多只有少量条目,大多数少于20个条目 |
基于范围的方法
在传统的文件系统中,为了支持大文件,使用了多级缩影的方式来管理数据块。这种方式需要使用大量的索引节点,会占用大量的内存和磁盘空间,同时也降低文件系统的性能。基于范围的方式会将一个文件分成多个连续的范围(类似于虚拟内存中的段),每个范围是一个磁盘指针加一个长度(以块为单位)。
基于范围的方式可以减少索引节点的数量,从而减少文件系统的开销,提高文件系统的性能。
基于范围的方式还可以提高文件系统的可靠性,因为它可以减少碎片和文件系统的内部碎片。在传统的文件系统中,由于文件的大小不一,可能会出现一些空洞,即文件中的一些数据块没有被使用。这些空洞会占用磁盘空间,导致文件系统的内部碎片。而基于范围的方式可以将文件分成多个连续的范围,从而减少了空洞的数量,减少了文件系统的内部碎片。
基于范围的方式已经成为现代文件系统的一个重要特性,被广泛应用于各种操作系统和存储设备中。
Linux ext4
文件系统中不再使用多级索引来支持大文件,采用了基于范围的方式。除此之外,还有一些其他的文件系统使用了基于范围的方式,包括:- XFS:XFS是一种高性能的文件系统,也使用了基于范围的方式来支持大文件。它将一个文件分成多个连续的范围,每个范围包含一定数量的数据块。
- ZFS:ZFS是一种先进的文件系统,也使用了基于范围的方式来支持大文件。它将一个文件分成多个连续的范围,每个范围包含一定数量的数据块。ZFS还支持快照、压缩、多设备、在线扩容等特性。
- Btrfs:Btrfs是一种新型的文件系统,也使用了基于范围的方式来支持大文件。它将一个文件分成多个连续的范围,每个范围包含一定数量的数据块。Btrfs还支持快照、压缩、多设备、在线扩容等特性。
在Windows中,NTFS文件系统支持范围的方式,称为“文件记录段(File Record Segment)”。NTFS将一个文件分成多个连续的文件记录段,每个文件记录段包含一定数量的数据块。这种方式可以提高文件系统的性能和可靠性。
在macOS中,HFS+文件系统也支持范围的方式,称为“扩展文件记录(Extended File Record)”。HFS+将一个文件分成多个连续的扩展文件记录,每个扩展文件记录包含一定数量的数据块。这种方式可以提高文件系统的性能和可靠性。
最新的macOS版本(10.13及以上)已经开始支持APFS(Apple File System),它是一种新型的文件系统,也使用了基于范围的方式来支持大文件。APFS将一个文件分成多个连续的范围,每个范围包含一定数量的数据块。这种方式可以提高文件系统的性能和可靠性。
补充:基于链接的方法
设计inode有另一个更简单的方法,即使用链表(linked list)。这样,在一个inode中,不是有多个指针,只需要一个,指向文件的第一个块。要处理较大的文件,就在该数据块的末尾添加另一个指针等,这样就可以支持大文件。
你可能已经猜到,链接式文件分配对于某些工作负载表现不不佳。例如,考虑读取文件的最后一个块,或者就是进行随机访问。因此,为了使链接式分配更好地工作,一些系统在内存中保留链接信息表,而不是将下一个指针与数据块本身一起存储。该表用数据块D的地地址来索引,一个条目的内容就是D的下一个指针,即D后面的文件中的下一个块的地址。那里也可以是空值(表示文件结束),或用其他标记来表示一个特定的块是空闲的。拥有这样的下一块指针表,使得链接分配机制可以有效地进行随机文件访问,只需首先扫描(在内存中)表来查找所需的的块,然后直接访问(在磁盘上)。
这样的表听起来很熟悉吗?我们描述的是所谓的文件分配表(File Allocation Table,FAT)--文件系统的基本结构。是的,在NTFS [C94]之前,这款经典的旧Wiindows文件系统基于简单的基于链接的分配方案。它与标准UNIX文件系统还有其他不同之处。例如,本身没有inode,而是存储关于文件的元数据的目录条目,并且直接指向所述文件的第一个块,这导致不可能创建硬链接。参见Brouwer的著作[B02],了解更多不够优雅的细节。
当然,在inode设计的空间中,存在许多其它可能性。毕竟,inode只是一个数据结构,任何存储相关信息并可以有效查询的数据结构就足够了。由于文件系统软件很容易改变(如etx3和 etx4),如果工作负载或技术发生变了,你应该愿意探索不同的设计。
目录组织
在VSFS中(像许多文件系统一样),目录的组织很简单。一个目录基本上质保函一个二元组(条目名称,inode号)的列表。对于给定目录的每个文件或目录,目录的数据块中都有一个字符串和一个数字。对于每个字符串,可能还有一个长度(假定采用可变大小的名称)。
例如,假设目录dir(inode号是5)中有3个文件(foo、bar和foobar),它们的inode号分别为12、13和24。dir在磁盘上的数据可能如下所示:
inum | reclen(记录长度) | strlen(实际长度) | name |
5 | 4 | 2 | . |
2 | 4 | 3 | .. |
12 | 4 | 4 | foo |
13 | 4 | 5 | bar |
24 | 8 | 7 | foobar |
在这个例子中,每个条目都有一个inode号,记录长度(名称总字节数+所有剩下的剩余空间),字符串长度(名称的实际长度),最后是条目的名称。请注意,每个目录有两个额外的条目:.(点)和 ..(点点),点目录就是档期那目录(本例中为dir),而点点是父目录(在本例中是根目录)。
删除一个文件(例如调用unlink())会在目录中间留下一段空白空间,因此应该有一些方法来标记它(例如,用一个保留inode号,比如0)。这种删除是使用记录长度的一个原因:新条目可能会重复使用旧的、更大的条目,从而在其中留有额外的空间。
你可能想知道确切的目录存储在哪里。通常,文件系统将目录视为特殊类型的文件。因此,目录有一个inode,位于inode表中的某处(inode表中的inode标记为”目录“的类型字段,而不是”常规文件“)。该目录具有由inode指向的数据库(也可能是间接块)。这些数据库存在于我们的简单文件系统的数据块区域中。我们的磁盘结构因此保持不变。
我们还应该指出,这个简单的线性目录猎豹并不是存储这些信息的唯一方法。像以前一样,任何数据结构都是有可能的。列入,XFS以B数形式存储目录,是文件创建操作(必须确保文件名在创建之前未被使用)快于使用简单列表的系统,因为后者必须扫描其中的条目。
空闲空间管理
文件系统必须记录哪些inode和数据块是空闲的,哪些不是,这样在分配新文件或目录时,就可以为它找到空间。因此,空闲空间管理(free space management)对于所有文件系统都很重要。在VSFS中,我们用两个简单的位图来完成这个任务。
例如,当我们创建一个文件时,我们必须为该文件分配一个inode。文件系统将通过位图搜索一个空闲的内容,并将其分配给该文件。文件系统必须将inode标记为已使用(用1),并最终用正确的信息更新磁盘上的位图。分配数据块时会发生类似的一组活动。
为新文件分配数据块时,还可能会考虑其它一些注意事项。例如,一些Linux文件系统(ext2和ext3)在创建文件并需要数据块时,汇讯要一系列空闲块(如8块)。通过找到这样一系列空闲块。然后将它们分配给新创建的文件,文件系哦天哪通过保证文件的一部分将在磁盘上并且是连续的,从而提高性能。因此,这种预分配(pre-allocation)策略,是为了数据块分配空间时的常用启发式方法。
访问路径:读取和写入
现在我们已经知道文件和目录如何存储在磁盘上,我们应该能够明白读取和写入文件的操作过程。理解这个访问路径(access path)上发生的事情,是开发人员理解文件系统如何工作的第二个步骤。请注意!
对于下面的例子,我们假设文件系统已经挂载,因此超级块已经在内存中。其他所有内容(如inode、目录)仍在磁盘上。
从磁盘读取文件
在这个简单的例子中,让我们先假设你只是想打开一个文件(例如 /foo/bar,读取它,然后关闭它)。对于这个简单的例子,假设文件的大小只有4KB(即1块)。
当你发出一个open(”/foo/bar“, O_RDONLY)调用时,文件系统首先需要找到文件bar的inode,从而获取关于该文件的一些基本信息(权限信息、文件大小等等)。为此,文件系统必须能够找到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发生。
整个过程如下表所示(向下时间递增)。在该表中,打开导致了多次读取,以便最终找到文件的inode。之后,读取每个块需要文件系统首先查询inode,然后读取该块,再使用写入更新inode的最后访问时间字段。花一些时间,试着理解发生了什么
另外,请注意,open花费的I/O量和路径名的长度正相关。路径中每增加一个目录,我们都需要读取它的inode及其数据。更糟糕的是,有可能会出现大型目录(目录中包含大量文件或目录)。在这个例子中,我们只需要读取一个块来获取目录的内容,而对于大型目录,我们可能需要读取很多数据块才能找到所需的条目。是的,读取文件时会让情况变得非常糟糕。你会发现,写入一个文件(尤其是创建一个新文件)更糟糕。
写入文件到磁盘
写入文件是一个类似的过程。首先,文件必须打开(如上所述)。其次,应用程序可以发出write()调用以用新内容更新文件。最后,关闭该文件。
与读取不同,写入文件也可能会分配(allocate)一个块(除非块被覆写)。当写入一个新文件时,每次写入操作不仅需要将数据写入磁盘,还必须首先决定哪个块分配给文件,从而相应地更新磁盘的其他结构(例如数据位图和inode)。因此,每次写入文件在逻辑上会导致5个I/O:一个读取数据位图(然后更新以标记新分配的块被使用),一个写入位图(将它的新状态存入磁盘),再是读取inode,然后写入inode(用新块的位置更新),最后一次写入真正的数据块本身。
考虑简单和常见的操作(例如文件创建),写入的工作量会更大。要创建一个文件,文件系统不仅要分配一个inode位图,还要在包含新文件的目录中分配空间。这样做的I/O工作总量非常大:一个读取inode位图(查找空闲inode),一个写入inode位图(将其标记为已分配),一个写入新的inode本身(初始化它),一个写入目录的数据(将文件的高级名称链接到它的inode号),以及一个读写目录inode以便更新它。如果目录需要增长以容纳新条目,则还需要额外的I/O(即数据位图和新目录块)。所有这些只是为了创建一个文件!
我们来看一个简单的例子,其中创建了file/foo/bar,并且向它写入了3个块。下表展示了在open()创建文件期间和在3个4KB写入期间发生的情况。
在这个表中,对磁盘的读取和写入放在导致它们发生的系统调用之下,它们可能发生的大致顺序从表的顶部从底部依次进行,然后创建文件。你还可以看到每个分配写入需要5次I/O:一队读取和更新inode,另一对读取和更新数据位图,最后写入数据本身。文件系统如何以合理的效率完成这些任务?
关键问题:如何降低文件系统I/O成本
即使是最简单的操作,如打开、读取或写入文件,也会产生大量的I/O操作,分散在磁盘上。文件系统可以做些什么,来降低执行如此多I/O的高成本。
缓存和缓冲
如上面的例子所示,读取和写入文件可能是昂贵的,会导致(慢速)磁盘的许多I/O。这显然是一个巨大的性能问题,为了弥补,大多数文件系统积极使用系统内存(DRAM)来缓存重要的块。
想象一下上面的打开示例:没有缓存,每个打开的文件都需要对目录层次结构中每个级别知道进行两次读取(一次读取相关目录的inode,并且至少有一次读取其数据)。使用长路径名(例如,/1/2/3…/100/file.txt),文件系统只是为了打开文件,就要执行数百次读取!
早期的文件系统因此引入了一个固定大小的策略(fixed-size cache)来保存常用的块。正如我们在讨论虚拟内存时一样,LRU及不同变体策略会决定哪些块保留在缓存中。这个固定大小的缓存通常会在启动时分配,大约占总内存的10%。
然而,这种静态的内存划分(static partitioning)可能导致浪费。如果文件系统在给定的时间点用不到10%的内存,该怎么办?使用上述固定大小的方法,文件高速缓存中未使用页面不能分配给其它程序使用,因此导致浪费。
相比之下,现代系统采用动态划分(dynamic partitioning)方法。具体来说,许多现代操作系统将虚拟内存页面和文件系统页面继承到统一页面缓存中(unified page cache),通过这种方式,可以再虚拟内存和文件系统之间更灵活地分配内存,具体取决于在给定时间哪种内存需要更多的内存。
现在想象一下有缓存的文件打开的例子,第一次打开可能会产生很多I/O流量,来读取目录的inode数据,但是随后文件打开的同一文件(或同一目录中的文件),大部分会命中缓存(第一次打开时,会将数据都缓存到内存中),因此不需要I/O。
我们也来考虑一下缓存对写入的影响。尽管可以通过足够大的缓存完全避免读取I/O,但写入流量必须进入磁盘,才能实现持久。因此高速缓存没法减少写入的流量,像对读取一样。虽然这么说,写缓冲(,人们有时这么说)肯定有许多优点。首先,通过延迟写入,文件系统可以将一些更新批处理,放入一组较小的I/O中。例如,如果在创建一个文件时,inode位图被更新,稍后在创建另一个文件时又被更新,则文件系统会在第一次更新后延迟写入,从而节省一次I/O。其次,通过将一些写入缓冲在内存中,系统可以调度(schedule)后续的I/O,从而提高性能。
最后,一些写入可以通过拖延来完全避免。例如,如果应用程序创建文件并将其删除,则将文件创建延迟写入磁盘,可以完全避免(avoid)写入。再想想我们刚刚的例子,创建一个文件时,会发生多次I/O。在这种情况下,懒惰(在将块写入磁盘时)是一种美德。
提示:理解静态划分与动态划分
在不同客户端/用户之间划分资源时,可以使用静态划分(static partitioning)或动态划分(dynamic partitioning)。
静态方法简单地将资源一次分成固定的比例。例如,如果有两个可能的内存用户,则可以给一个用户固定的内存部分,其余的则分配给另一个用户。
动态方法更加灵活,随着时间的推移提供不同数量的资源。例如,一个用户在某个时间段可能会占用很高的磁盘带宽百分比,但是之后,系统可能会切换,将给其它用户提供更大比例的可用磁盘带宽。
每种方法都有其优点。静态划分可确保每个用户共享一些资源,通常提供更可预测的性能,也更易于实现。动态划分可以实现更好的利用率(通过让资源匮乏的用户占用其它空闲资源),但实现起来可能会更复杂,并且可能导致空闲资源被其它用户占用,然后再需要时花费很长时间收回,从而导致这些用户性能很差。
像通常一样,没有最好的方法。你应该考虑手头的问题,并确定哪种方法最适合。实际上,你不是应该一直这么做吗?
由于上述原因,大多数现代文件系统(Linux, Win, MacOS)将写入在内存中缓冲
5~30s
,这代表了另一种折中;如果系统在更新传递到磁盘之前崩溃,更新就会丢失。但是,将内存写入时间延长,就可以通过批处理、调度甚至避免写入,提高性能。当然,有一些应用程序(如数据库)不喜欢这种折中。因此,为了避免由于写入缓冲导致的意外数据丢失,它们会强制写入磁盘,通过调用fsync(),使用绕过缓存的直接I/O(direct I/O)接口,或者使用原始磁盘(raw disk)接口并完全避免使用文件系统。虽然大多数应用程序能接受文件系统的折中,但是如果默认设置不能令人满意,那么有足够的控制可以让系统按照你的要求进行操作。
可以选修一门数据库课程,了解更多有关传统数据库的知识,以及它们过去对避开操作系统和自己控制一切的坚持。但要小心!有些搞数据库的人总是试图说操作系统的坏话。
提示:了解耐用性/性能权衡
存储系统通常会向用户提供耐用性/性能这种。如果用户希望写入的数据立即持久,则系统必须经全力将新写入的数据提交到磁盘,因此写入速度很慢(但很安全)。
但是,如果用户可以容忍丢失少量数据,系统可以缓冲内存中的写入一段时间,然后将其写入磁盘(在后台工作)。这样做可以使写入快速完成,从而提高感受到的性能。但是,如果发生崩溃,尚未提交到磁盘的写入操作将丢失,因此需要进行这折中。
要理解如何正确地进行这种折中,最好了解使用存储系统的应用程序需要什么。例如,虽然丢失网络浏览器下载的最后几张图像可以忍受,但如果丢失部分数据库交易、让你的银行账户不能增加资金,这不能忍。当然,除非你很有钱。如果你很有钱,为什么要特别关心积攒的每一分钱?
位图数据结构补充
位图可以看作是一个很长很长的二进制数,其中每个二进制位表示一个元素的状态。例如,如果需要表示100个元素的状态,可以使用一个长度为100的位图,其中每个二进制位表示一个元素的状态。
在位图中,每个二进制位只能取0或1两个值,因此可以使用一个比特(bit)来表示。由于计算机中的数据通常以字节(byte)为单位存储,因此位图中的每8个二进制位通常被组合成一个字节。例如,如果需要表示100个元素的状态,则可以使用一个长度为13字节的位图,其中每个字节表示8个元素的状态,最后一个字节表示剩余的4个元素的状态。
需要注意的是,位图中的二进制位是从右向左编号的,即最右边的二进制位编号为0,依次向左递增。这是因为在计算机中,二进制数的最低位通常位于右侧,而最高位位于左侧。
我们需要表示一组元素的状态时,可以使用位图(bitmap)这种数据结构。位图是一种用于表示二进制位的数据结构,通常用于表示一组元素的状态,例如文件系统中的文件或目录是否存在、内存中的页面是否被占用等。
在位图中,每个元素对应一个二进制位,通常用0或1表示。如果该元素存在,则对应的二进制位为1,否则为0。例如,在文件系统中,可以使用位图来表示每个磁盘块的状态,如果该磁盘块已被使用,则对应的二进制位为1,否则为0。
位图通常使用一个数组来表示,数组中的每个元素对应一组二进制位。例如,如果每个元素需要表示8个二进制位,则可以使用一个字节(byte)来表示,数组中的每个元素就是一个字节。如果需要表示更多的二进制位,则可以使用更大的数据类型,例如16位整数或32位整数。
使用位图可以提高数据的访问速度和存储效率。由于位图中的每个元素只需要占用一个二进制位,因此可以大大减少存储空间的使用。同时,由于位图中的元素是连续存储的,因此可以通过位运算来快速访问和修改元素的状态,从而提高数据的访问速度。
位图在垃圾回收中也有广泛的应用。在垃圾回收中,位图通常用于标记对象是否存活。具体来说,垃圾回收器会维护一个位图,其中每个对象对应一个二进制位。如果该对象存活,则对应的二进制位为1,否则为0。
在垃圾回收过程中,垃圾回收器会遍历所有的对象,并将存活的对象标记为1。然后,垃圾回收器会扫描位图,将未被标记的对象视为垃圾,并进行回收。
使用位图可以提高垃圾回收的效率和准确性。由于位图中的元素是连续存储的,因此可以通过位运算来快速访问和修改元素的状态,从而提高垃圾回收的效率。同时,由于位图中的元素只需要占用一个二进制位,因此可以大大减少存储空间的使用,从而提高垃圾回收的准确性。
需要注意的是,不同的垃圾回收算法可能会使用不同的位图实现方式。例如,标记-清除算法通常使用两个位图来表示存活对象和垃圾对象,而复制算法通常使用一个位图来表示存活对象。