第四章 文件和文件系统

作者:Allen B. Downey

原文:Chapter 4 Files and file systems

译者:飞龙

协议:CC BY-NC-SA 4.0

当一个进程运行完毕(或崩溃)时,任何储存在主存的数据都会丢失。但是储存在机械硬盘(HDD)或固态硬盘(SSD)的数据是“持久”的。也就是说,它在进程结束之后,甚至关机之后仍旧存在。

机械硬盘比较复杂。数据存储在块内,它们布局在扇区中,扇区又组成磁道。磁道在盘片上以同心圆的形式排列。

固态硬盘稍微简单一些,因为块按顺序被标号。但是这会产生另一种困难,每个块在变得不可靠之前,只能被读写有限的次数。

作为一个程序员,你并不需要处理这些难题。你只需要硬件持久化存储的一个适当抽象。最普遍的抽象叫做“文件系统”。

在抽象上:

  • “文件系统”将每个文件的名称映射到它的内容。如果你认为名称是键,内容是值,文件系统就是一种键值对的数据库
  • “文件”就是一组字节序列。

文件名通常是字符串,并且通常是分层的。这就是说,这个字符串指定了顶级目录(或文件夹)的路径,通过一系列子目录,到达特定的文件。

这个抽象和底层机制的根本不同,就是文件是基于字节的,而持久化储存器是基于块的。操作系统将C标准库中基于字节的文件操作翻译成基于块的储存设备操作。每个块的典型大小是1~8KiB。

例如,下面的代码打开文件并读取首个字节:

  1. FILE *fp = fopen("/home/downey/file.txt", "r");
  2. char c = fgetc(fp);
  3. fclose(fp);

当这段代码执行时:

  1. fopen使用文件名来寻找顶级目录,叫做/,子目录/home,和二级子目录downey
  2. 它找到了名为file.txt的文件,并且“打开”它以便读取。意思是它创建了一个数据结构来表示将要读取的文件。除此之外,这个数据结构还跟踪了文件读取了多少字节,称为“文件位置”。
  3. 当我们调用fgetc时,操作系统检查下个字节是否已经在内存里了。如果是的话,它会读取下一个字节,向前移动文件位置,并返回结果、
  4. 如果下一个字节不在内存中,操作系统产生IO请求来获取下一个块。硬盘非常慢,所以一个等待磁盘块的进程通常会被中断,直到数据到达之前,都在运行另一个进程。
  5. IO操作完成时,新的数据块会储存在内存中,进程也会恢复运行。它读取第一个字节并把它储存在局部变量中。
  6. 当进程关闭文件时,操作系统完成或取消任何等待中的操作,移除内存中的数据,并且释放OpenFileTableEntry

写入文件的过程与之相似,但是有一些额外的步骤。下面是一个例子,打开文件用于读取,并且修改首个字节:

  1. FILE *fp = fopen("/home/downey/file.txt", "w");
  2. fputc('b', fp);
  3. fclose(fp);

当这段代码执行时:

  1. 同样,fopen使用文件名来寻找文件。如果它不存在,就会创建新的文件,并向父目录/home/downey添加条目。
  2. 操作系统创建OpenFileTableEntry,表示这个文件已打开等待写入,并将文件位置设置为0。
  3. fputc尝试写入(或者覆写)文件的第一个字节。如果文件已经存在,操作系统需要将第一个块加载到内存中。否则它会在内存中分配新的块,并且在磁盘上请求新的块。
  4. 在内存中的块被修改之后,可能不会立即复制回磁盘。通常,写到文件中的数据是“被缓冲的”,意思是它储存在内存中,只在至少有一个块需要写入时才写回磁盘。
  5. 文件关闭时,任何缓冲的数据都会写到磁盘,并且OpenFileTableEntry会被释放。

总之,C标准库提供了文件系统的抽象,将文件名称映射到字节流。这个抽象建立在实际以块组织的储存设备之上。

4.1 磁盘性能

我之前提到过,磁盘驱动器非常慢。在当前的HDD上,从磁盘读取一个块到内存的时间为2~6毫秒。SSD要快一些,读取4KiB的块需要25微秒,写入需要250微秒(请见http://en.wikipedia.org/wiki/Ssd#Controller)。

为了正确看待这些数据,让我们将其与CPU的时钟周期进行比较。一个拥有2GHZ时钟频率的处理器,每0.5纳秒就会完成一个时钟周期。从内存获取一个字节到CPU的时间通常为100纳秒。如果处理器每个时钟周期完成一条指令,在等待来自内存的一个字节时,它可以完成200条指令。

在一微秒内,它可以完成2000条指令,所以在等待来自SSD的一个字节时,它可以完成50000条。

在一毫秒内,它可以完成2,000,000条指令,所以在等待来自HDD的一个字节时,它可以完成一千万条。如果CPU在等待期间没有什么事情要做,就会闲置。这就是操作系统在等待来自磁盘的数据时,通常会切换到另一个进程的原因。

主存和持久化储存器的性能间隔是计算机系统的主要挑战之一。操作系统和硬件提供了一些特性来“填补”这一间隔。

  • 块的传输:从磁盘加载一个字节的时间是5毫秒。相比之下,加载一个8KiB的块所需的时间是微不足道的。如果处理器在每个块上都要花费5毫秒,就有可能使处理器保持忙碌。
  • 预取:有时操作系统可以预测到进程会读取某个块,并且在它请求之前就开始加载了。例如,如果你打开一个文件并读取首个块,操作系统可能会在请求之前开始加载额外的块。
  • 缓冲:像我提到过的那样,当你写入一个文件时,操作系统会先把数据放在内存中,并且稍后写到磁盘。如果某个块在内存中时,你对其做数次修改,系统只需要写到磁盘一次。

这些特性中一部分实现在硬件上。例如,一些硬盘驱动器提供了缓存功能来储存最近所使用的块。许多磁盘驱动器也会一次读取多个块,即使只请求了一个块。

这些机制通常改进了程序的性能,但是它们并不改变行为。通常程序员不需要考虑它们,除了两个例外:(1)如果程序的性能十分差劲,你可能需要了解这些机制来判断问题所在。或者(2)当数据被缓冲时,调试程序就变得很困难。例如,如果程序打印出一个值,然后崩溃。这个值就可能不会出现,因为它可能位于缓冲区中。与此相似,如果一个程序向磁盘写入数据,之后计算机没电了。如果数据位于缓存中,还没有写到磁盘,就可能会丢失。

4.2 磁盘元数据

组成文件的块可能在磁盘上是连续排列的,如果它们是这样,文件系统的性能会高一些。但是大多数操作系统并不需要连续的分配,它们可以将某个块放在磁盘上的任意位置,并且使用各种数据结构来跟踪这些块。

在许多Unix文件系统中,这些数据结构叫做inode,它代表“索引节点”(index node)。更通常来说,关于文件的信息,包括所包含的块的位置,叫做“元数据”。(文件内容就是数据,所以关于文件内容的数据就是数据的数据,所以为“元数据”。)

由于inode和其余数据一样位于磁盘上,它们被设计来巧妙地整合进磁盘块中。Unix的inode包含关于文件的信息,这包括:文件拥有者的用户ID,表明谁可以读写或执行的权限位,以及表明最后修改和访问时间的时间戳。另外,对于组成文件的前12个块,它包含了块的数量。

如果每个块的大小是8KiB,前12个块合计96KiB。在大多数系统中,这对于大多数文件就足够了,但是,这对于所有文件明显不一定够用。这就是inode同时也包含一个指向“间接块”指针的原因,间接块包含了指向其它块的指针。

间接块的指针数量取决于块的数量和大小,它通常是1024。如果有1024个块,每个块是8KiB,那么一个间接块可以编址8MiB。这对于大多数大文件就够了,但对于所有大文件还是不够。

这就是inode同时含有“二级间接块”指针的原因,二级间接块含有指向间接块的指针。我们可以使用1024个间接块来编址8GiB。

如果这样还是不够大,最后有一个三级间接块,它含有指向二级间接块指针,支持最大8TiB的文件大小。Unix的inode在设计时,它似乎在很长一段时间内都是够大的。但是那是很久之前了。

作为间接块的替代,一些文件系统,例如FAT,使用了一张文件分配表,它为每个块包含一个条目,在这个上下文中叫做“簇”。根目录包含指向每个文件第一个簇的指针。FAT上每个簇的条目指向文件中的下一个簇,就像链表那样。更多请见文件分配表的维基百科

4.3 块的分配

操作系统需要跟踪哪些块属于每个文件,它们也需要跟踪哪些块可供使用。当新的文件创建时,文件系统会寻找可用的块并且分配它。当文件删除时,文件系统会释放它的块用于再次分配。

块分配系统的目标是:

  • 速度:块的分配和释放应该很快。
  • 最小的空间开销:用于分配器的数据结构应尽可能小,把尽可能多的空间留给数据。
  • 最少的碎片:如果一些块没有被使用,或者只是部分使用,没有使用的空间被称为“碎片”。
  • 最大的连续性:可能同时使用的数据应尽可能物理连续,以便提高性能。

设计一个满足以上所有目标的文件系统很困难,尤其是由于文件系统的性能取决于“工作负载的特征”,包括文件大小、访问模式以及其它。对于某种工作负载表现良好的文件系统,可能对于其它工作负载的表现并不好。

由于这种因素,大多数操作系统支持多种文件系统,并且文件系统的设计是一个活跃的研究和发展领域。近十年中,Linux系统由ext2迁移到ext3。前者是一种传统的Unix文件系统,而后者是一种用于提高速度和连续性的日志文件系统。最近它迁移到了ext4,它可以处理更大的文件和文件系统。在几年之内,可能又会迁移到基于B树的文件系统,Btrfs。

4.4 任何东西都是文件吗?

文件抽象实际上是“字节流”的抽象,这对于很多事情都很实用,不仅仅是文件系统。

一个例子是Unix管道,它是进程间通信的一个简单形式。可以建立这样一些进程,使一个进程的输出用作另一个进程的输入。对于第一个进程,管道表现为打开用于写入的文件,所以它可以使用C标准库类似fputsfprintf的函数。对于第二个进程,管道表现为打开用于读取的文件,所以它可以使用fgetsfscanf

网络通信也使用了字节流的抽象。Unix套接字是一个数据结构,它(通常)表示两个不同电脑上的进程之间的信道。同样,进程可以使用“文件”处理函数从套接字读取数据和向套接字写入数据。

复用文件抽象使程序员的工作变得容易,因为他们只需要了解一套API(应用程序接口)。这也使程序具有多种功能,因为一个需要处理文件的程序还可以处理来自管道和其它来源的数据。