什么是文件
程序的运行离不开CPU的参与,就如何提取CPU处理各项任务,抽象出了进程(理解进程与线程);就如何保证各进程如期运行,如何运行,也就是如何提取物理存储器,抽象出了(虚拟)地址空间 (理解内存管理);那么,就如何管理长期存储介质(如磁盘)存储的信息,抽象出了文件这一个概念。
那么,为什么要有长期存储的需求呢?对于程序的运行,必要的信息将随着时间,载入载出内存。当程序终止时,这些信息也将从内存中丢失。有很多的信息,它的存储需求,是独立于程序之外的。比如你的学习笔记,你的工作记事,你的用户数据等等。在从前,存储信息的介质是石板、竹简、纸张,对于计算机来说,是磁盘。那么,对于绝大部分的程序来说,需要磁盘上存储的长期数据,才能完成它的设计目的。
在这里,大可以把磁盘当作线性的,一种由许许多多固定的块组成的介质,那么文件,也就变成了如何组织这其中的某些块,即:
- 在哪里块中找到信息
- 如何防止一个用户读取到另一个用户的信息
- 如何知道那些块是空闲的,可做它用
因此,文件是对长期存储介质的抽象。
文件的基础概念
既然要做抽象,要对磁盘中的块做建模,文件作为进程创建的信息逻辑单元,也将由种种规则构成,如此,程序才能正确地识别文件,并从中提取需要的信息,做正确的操作。
- 文件名:给予文件命名,在不同的系统中受限的规则是不同的,对于计算机来说,这个信息不重要,更多地是用来提醒操作者,协助操作者进行记忆。
- 拓展名:文件名后面,往往能看到以 ".***" 结尾,这部分内容也称为拓展名,做标识用。标识文件的类型,以让程序能知道要使用哪一种规则去使用文件。比如,“.jpg” 代表着文件是图片,也指示了它的编解码类型;如".c",说明是C源程序文件,诸如此类。简言之,代表一种约定。
- 文件类型:按照文件主要功能的不同,文件分为字符特殊文件、块特殊文件、目录和普通文件,之后会提及。
文件结构
在操作系统的眼里,文件是一段段的字节序列,那么按照何种规则,或者说以何种结构组织这些序列信息,就变得很重要。
以一个常见可执行文件看,其结构可能如上图。它以魔数为开头,作为一种文件类型的标识确认,以防止文件意外被执行。比如Java的.class文件,以0xCAFEBABE作为魔术,当期望执行.class文件的程序发现魔数不对时,将遇到异常。魔数之后,就是对个各个段(正文、数据、重定位位、符号表等)的描述,然后是标识位、各段的具体内容。
此例为抛砖引玉,以说明文件是一种规则约定,产生该类型文件的程序将按照这个约定组织文件,而使用该类型文件的程序也将按照这个规则使用文件,如此才能正确地识别文件。
文件属性
除了文件名和数据外,文件还具有属性来对文件本身做更具体的描述,这类信息也称为元数据。这些属性会存于文件结构中的某些区域中,具体要视该文件的类型而定。一些具有代表性的属性为:
- 创建时间、修改时间、存取时间、文件大小、当前大小、所有者等
- 保护:对文件的访问限制,谁可以访问文件
- 口令:访问文件需要的密码
- 只读标志、隐藏标志、系统标志(普通文件或系统文件)、加锁标志、存档标志
- 最大长度:文件可能增长到的字节数
其中,标志使用一些位或者短字段,用来控制或启用一些特殊属性,如存档标志,可以记录文件是否被备份。文件的属性提供必要的信息,以声明对文件的使用应处于何种限制。
文件操作
当然,文件系统大多都会提供如下的文件操作:
- create():创建不包含任何数据的文件。
- delte():删除该文件以释放磁盘空间。
- open():在使用文件之前,先打开文件,目的是把文件属性和磁盘地址表装入内存,以便后续调用的快速访问。
- close():文件访问结束之后,关闭文件并释放内部空间表空间。
- write():向文件写数据,如果当前位置是文件尾,那么数据长度会增加;如果当前位置是其中某个位置,那么写入位置的数据将会被覆盖。
- append():相当于write()在尾部添加数据。
- seek():随机访问文件,需要指定相对于文件数据开始的位置。
- getAttribute():读取文件的属性。
- setAttribute():设置文件的属性。
- rename():重命名文件。
一般来说,打开文件将会获得一个文件描述符(一个小整数),通过文件描述符,就可以操作文件。
目录
文件是信息的集合,是对存储介质的抽象,那么要如何找到他们呢?这也是目录(也可称文件夹)存在的意义。目录本身也是文件,用来帮助记录文件的位置。就如xx省yy市mm县nn街,按图索骥,就能找到需要的文件,也能帮助归类文件。
现在的文件目录结构,大多采用如上图的结构。如寻找文件2,那么路径为 A -> B -> 2。这里的路径,用路径名表示,分为绝对路径名和相对路径名,路径每往下一层,使用分隔符进行分隔。分隔符视系统而定,如上面的路径
> Windows:\A\B\2
> Linux: /A/B/2
> MULTICS: >A>B>2
相对路径名表示的是,从当前位置,到达指定文件要经过的路径。如从上图D到E,要经过的路径表示可能为 ../E(Linux中,使用".."表示向上一层)。绝对路径名表示的是,从根目录开始,到达指定文件需要通过的路径,同样的例子,表示可能为 /A/C/E。在当前文件不知道自己处于什么位置时,使用绝对路径名,能使得正常工作。
对于目录,也提供了相应的操作:
- create(): 创建目录
- delete(): 删除目录
- opendir(): 打开目录
- closedir(): 关闭目录,释放内部表空间
- readdir(): 返回一个目录项,在内存中,以目录项来表达一个目录
- rename(): 重命名
- link(): 链接一个文件,之后能使同一个文件,能通过多个文件路径访问到(后续会提到)
- unlink(): 解除文件的链接
文件系统如何实现
以上内容,从概念上介绍了文件所具有的基本含义,是以用户(即使用者)的角度看待文件。那么,从文件系统实现者的角度来看,文件系统应该如何实现?
上图是一个可能的文件系统布局。
文件系统存放在磁盘上,磁盘被划分为一个或者多个分区,每个分区中有一个独立的文件系统。磁盘的0号扇区称为主引导记录(Master Boot Record, MBR),用来引导计算机。紧接着,是分区表,用来标记每个分区的起始和结束位置,表中的一个分区被标记为活动分区。
当计算机被引导时,BIOS读入并执行MBR。MBR会确定活动分区,读入并执行它的引导块(boot block)。引导块中的程序将装载该分区中的系统。超级块(superblock)包含文件系统的所有关键参数,会被读入内存,其中的信用可用来确定文件系统的魔数、文件系统中的块的数量和其他重要的管理信息。
空闲块的信息可以用位图或者指针的形式指出,随后跟随的是一组i节点(一种数据结构,用来说明文件的方方面面)。最后,存放了所有其他的文件和目录。
MBR、分区表、引导块、超级块的存储是非常稳定的。而与文件和目录相关的存储,发生变化是常态。可以说,实现文件系统的关键问题是,记录哪些文件使用了哪些磁盘块。
连续分配
最简单的方式,是将每个文件当作连续数据块存储到磁盘上,然后记录它们连续的起始位置和结束位置。
优点在于:
- 实现简单,只需记住开始位置,文件的块数。
- 读操作性能好,单个操作就能从磁盘中读出整个文件。只需要一次寻找,之后不再有寻道和旋转延迟。
缺点在于:
- 尾部会浪费一些空间,以块为存储单位时,无论是否完全使用完块大小,都会占用整个块
- 需要知道文件的最终大小,然后,在维护的连续空闲表找到合适的位置存入文件,并且知道文件的最终大小这一问题不可回避。
- 随时间迁移,将会产生大量碎片块,使磁盘变得零碎,当一个较大的新文件要加入时,将找不到合适的连续位置,此时要压缩磁盘空间(把文件复制到新位置,以得到更多的连续空间),代价极高。
新建文件时要知道文件的最终大小,使得这一存储方式难以应用于实际场景,但是在一些情形下是可行的。如在CD-ROM、DVD、BD(蓝光光盘)上,在它们的场景里,所有的文件的大小是事先知道的,并且在后续的使用中,这些文件的大小也不会改变。
链表分配
可以为每个文件构造磁盘块链表,每一块的第一个字指向下块,块的其他部分存放数据。
优点为:
- 不会因为磁盘碎片浪费存储空间,充分利用磁盘块。
- 只需要第一块的磁盘地址,就能找到其他的块。
缺点为:
- 随机方法访问很慢,访问块n总是要先访问其面的n-1块。
- 指向下一块的指针占据了一些字节,每个磁盘块存储的字节数不再是2的整次幂,因大多数程序以长度为2的整次幂来读写磁盘块,降低了实际系统的运行效率。
- 要读出一个完成的文件块时,要从两个磁盘块中获得和拼接信息,带来额外的开销
采用内存中的表进行链表分配
弥补链表分配的不足,可以在内存中建立磁盘块的指针表。如文件块的顺序为4、7、2、3。表项中,使用一个特殊的标记(如-1)表示结束。这样,沿着表就可以文件的所有块。这样的表也称为 FAT(File Allocation Table) 。
优点:
- 整个块都可以存放数据。
- 随机访问也简单得多,虽然仍要顺着链表寻找偏移量(但减少了可能的寻到和旋转延迟)
- 不需要磁盘引用
缺点为: 必须把整个表都放在内存中。假设1TB的磁盘和1KB的块大小,则表需要10亿项,每一项至少要3个字节,则表大小要占用2.4GB内存。因此FAT的应用场景有限,不太实用。
i节点
最普遍的方式,是为每个文件创建一个中数据结构以表示此文件的关键描述,这种数据结构也称为i节点(index-node),文章后续用inode表示i节点。
一个可能的inode结构如上图,记录了文件属性,以及每个文件块对应的磁盘块地址,并能指向其他的inode来记录更多的存储数据。
这样的机制有很大的优势:
- 给定inode,就能找到所有的文件块。
- 只有在文件打开时,inode才会存在于内存中,如果每个i节点有n字节,那么k个文件同时打开,inode总共占据kn字节,只需提前保留这么多的空间即可。也就是说,内存中inode占据的大小和文件大小无关,只和同时使用的文件数有关。
- 如果一个inode不够存储一个文件占用的所有磁盘块,可以通过最后一个地址之前其他inode得以解决。甚至可以指向其他存放地址的磁盘的磁盘块。
目录的实现
上面解决的内容是,如何记录文件的存储。当使用一个文件时,需要解决的问题时,如何找到它。
方式一
与其他文件不同的是,目录文件的内存,存放的是一个一个的目录项,每一项代表一个文件。然后,将一个文件的文件名、文件属性的结构体、说明磁盘块位置的磁盘地址存放在目录项中。
方式二
对于使用inode的系统,可以将文件相关属性,存放到inode中。
目录项中,需要针对存储的文件名作处理,因对文件名做出变动是普遍不过的场景。不同的系统,对于文件名的长度限制是不同的。假设一个系统对文件名的长度限制为255个字符,如果目录项中,为文件名预留255个字符,必然浪费大量的目录空间。
文件项可如上图两种方式组织。
左图 每个目录项按照项长度、文件属性、文件名组织成一项。然后,每个项依次紧凑。 这里的问题在于,移走文件后,将产生长度不定的空袭。随着时间,将长生大量的空隙,当有一个文件进来时可能找不到合适的空隙,将发生紧凑操作。
右图 每个目录项有固定长度,每个项中有文件属性和一个指向文件名的指针。每个项依次紧凑,文件名存储在堆中,指向文件名的指针指向它。 当有文件移走时,新的文件总能合适这个空隙。文件名也不再需要填充字符(左图中的文件名,要保证从边界读取才能保证读取正确)。当然,需要对堆进行管理。
通过线性地按照目录项,查找一个文件在不在目录中是比较慢的。可以通过维护一个散列表,如果文件名的散列指在表中,则可快速地通过散列表表项,找到属于此文件的目录项。这么做会使管理更复杂,因此只有预测到系统中的目录将有很多的文件时,才会考虑使用。 另一个加快查找的方式时,将之前的查找结果存入高速缓存。如果查找时,存在于高速缓存中,直接使用即可。
处理共享文件
考虑一个场景,几人在同一项目工作时,常需共享文件,如果这个共享文件均能出现在各人的目录中,那么使用起来将会很方便。
上图为将目录C下文件2进行共享的两种方法。
方法一:硬链接
目录B与文件2建立了链接(link),指向了文件2的inode。此方法的缺点为:
- 添加链接不会改变inode的所有者关系,通过计数来指名有多少目录指向它。在inode即将无效时,系统没有办法通过计数找到所有指向此inode的目录,然后删除对应的目录项。也能将指针信息存在inode,因为可能会有无数个指向它的指针。
- 如果目录C删除了这个文件,系统清理了文件2的inode,那么目录B将指向一个无效的inode。如果这个inode在之后分配给了别的文件,那么目录B将指向一个错误的文件。
方法二:符号链接
在目录B中建立类型为LINK的新文件,此文件中只包含它所链接的文件路径名,读到此文件时,会通过包含的路径名去寻找文件。也因此,仅有目录C有指向文件2的inode的指针。如果文件2被删除,读取LINK文件将导致访问文件2失败,因为找不到该文件。
缺点为,符号链接需要额外的开销,必须读取文件路径,找到inode,因此需要多次的额外的磁盘访问。
文件系统小结
目前为止,了解到的文件系统可能如上图:
- 文件系统存在于磁盘上,通过0号扇区的 MBR 来引导计算机。BIOS读入并执行MBR,MBR 会通过分区表确定活跃分区。
- 在每一个分区里,引导块中的程序将装载此分区的系统,通过超级块,可以拿到此分区文件系统的关键参数,以此来确认并保证文件系统如期运行。
- 余下的空间,均会根据实际文件系统的不同而分布不同。其中,空闲空间管理占据一定的空间,以对磁盘中可分配的空间进行管理。无论是以位图、链表、inode或其他的管理方式,均是为了在一定的场景下,更合理、更有效率地实现文件存储。
- 而根目录的位置,可按照文件系统的规则或是在超级块中寻找。通过文件路径名,就可从根目录开始,顺藤摸瓜,就可以找到对应的文件。
- 目录是一种特殊的文件,它存储的信息是一条条代表着子文件的目录项,无论目录项是直接存储子文件的属性信息,还是记录代表子文件的inode,均可达到找到子文件的目的。
文件系统的实现,是非常灵活的,对空闲空间的管理实现,对目录的实现,大程度上就决定文件系统的不同。
虚拟文件系统
综合上述,不同的文件系统的关键组成部分是相似的:引导装载系统的程序、关键参数的存储区域、分配空间的管理机制、寻找文件的机制等。而各个部分的具体实现,针对目标场景的不同而不同。
基于多文件系统共存于实际应用的需要,就可以通过虚拟文件系统(Virtual File System, VFS)来屏蔽下层实现,用户进程通过上层接口访问,就可以使用到不同的文件系统。
VFS尝试将文件系统的公共部分抽象出来。用户进程通过 VSF 的上层接口访问,只看到一个文件系统层次,并不知道也不需知道实际访问的文件系统是什么。真正的文件系统,实现 VFS 的下层接口,并被VFS装载以便后续的使用。
现在,来看VFS如何工作:
- 在系统启动时,根文件系统在VFS中进行注册,在VFS转载文件系统后,记录下文件系统的各个功能的函数地址列表,并在超块表中,记录下文件系统的超级块。
- 在发生一个文件调用时,从超快表中搜索并确定超级块,拿到文件系统的根目录。紧接着VFS会创建一个vnode来表示这个文件,并从实际的文件系统中拿到inode,将inode的信息和操作inode的函数表指针,复制到vnode中。此时,VFS文件描述符表中将有一个新的表项指向vnode,返回此文件描述符。
- 在之后对文件的操作中,就可以通过文件描述符找到vnode,进而找到inode的函数地址,调用实际的文件系统。
VFS并不知道文件系统来自本地,或者远端服务器,通过这样的方式,要加入一个新的文件系统就变得非常直接,设计者实现VFS所需的功能表,就能装载使用。
文件系统面对的问题
虽然了解了文件系统的概念以及如何实现,但是要使文件系统在特定的生产环境经有效率地工作,还需要考虑更多的问题。
考虑1:磁盘空间管理
文件通常存放在磁盘上,存储方式将有两种策略:1,分配一个连续的磁盘空间;2,分配多个连续的磁盘空间。前者实现简单,但是当文件扩大时,可能要将整个文件移动,非常耗时;后者管理复杂,但文件扩大将更简单快速。因此,几乎所有的文件系统将文件分割成固定大小的块来存储,各块之间不一定相邻。
块大小
块大小将直接影响此消彼长的两个指标空间利用率和磁盘数据率。空间利用率时指 实际使用的空间 / 占用的磁盘空间,磁盘数据率是指 传输时间 / 总耗费时间。 越大的块,将有越多的空间被浪费,因为文件的最后一块并不总能填满,因此空间利用率越低;越小的块,那么意味访问文件将发生更多的寻道和旋转延迟,传输时间占比更少,数据率变得更低。 因此不同的文件系统,都需要取得各自块大小的平衡点。
空闲块记录管理
确定了块大小,需要考虑空闲块如何记录,两种常用的方式用来记录空闲块。下面假设块大小为 1KB,磁盘块号为32位
链表记录 链表的每一个记录指向一个磁盘块号,链表本身要占据空间,一个磁盘块 1KB = 2^2 B * 2^8 , 因次一个磁盘块可以表示255个磁盘块号,1个位置指向下一个记录链表的磁盘块。对于1TB的磁盘,有10亿个磁盘块,为了记录所有空闲空间,就需要大约400万KB 粗算400MB的链表进行记录。
位图记录 位图记录是使用一个bit代表一个磁盘块,因此位图记录了所有的空间。对于同样的情况,位图需要10亿位进行记录,约 130,000KB。
链表记录占用空间大小,是随着磁盘空闲块数量减少而减少的,位图记录占用空间大小始终保持不变。因此有临界点,使链表记录占用的空间小于位图记录。位图实现简单,但查找慢;链表查找快,但可能占据更大的空间。
文件备份
文件备份也是要考虑在内的情况,无论人为原因、程序原因、灾祸、存储介质自然损坏等,都会导致已存储的文件信息不可寻。因此文件备份需要穿插在平日中,毕竟无法预知意外何时到来。
文件备份需要考虑的因素包括:
- 灾害原因:这些原因虽然不常见,但防患于未然总有必要。
- 用户意外删除:这个场景出现得非常频繁,为此专门设计了“回收站”,文件删除后会放到特殊的文件夹,以便还原。
- 海量数据:做转储时,如果把文件直接写入将占用大量的空间。那么在写入前进行压缩变得很有必要。但是对于压缩算法,存储介质上的单点损坏将会破坏算法,甚至整个磁盘带无法读取。所以是否要使用压缩算法,还需仔细权衡。
- 文件一致性:如果对正在活动中的文件进行备份,过程中再对文件进行修改,就很可能导致备份的文件不一致。因此要考虑在脱机备份,或者抓取快照闲时存储。
将文件从磁盘转储到磁带,有两种方法,物理转储和逻辑转储。
物理转储是指,从磁盘的第0块开始,将全部的磁盘块按顺序输出到磁带上,直到最后一块复制完毕。它的优点为简单、快速(几乎以磁盘的速度运行)。缺点为,不能跳过选定的目录,无法增量转储,不能恢复个人文件。
更普遍的形式是,逻辑转储。逻辑转储是指,从一个或几个置顶的目录开始,递归地转储自给定基础日期后所更改的全部文件和目录。
转储过程如上:
- 一阶段:扫描出所有修改过的文件,也记录所有目录。
- 二阶段:再次扫描,去除所有未包含被修改的子文件的目录。
- 三阶段:在二阶段的基础上,标记所有目录,被标记的目录本身被修改过,或目录含有被修改的子文件,因为在做恢复时,需要知道这些目录的信息。被转储的目录为:2、7、8、A。
- 四阶段:在二三阶段的基础上,标出所有被修改的文件,被转储的文件为:3、9、B。到此转储结束。
考虑2:文件系统的一致性
试想一下,对文件进行修改后,需将所有的修改都写回磁盘,如果在所有的磁盘块都写回磁盘前,系统崩溃,一些被修改的信息可能未被写回磁盘,那么文件系统将不一致。因此,文件系统一般都有独立的程序可检查其一致性。
一致性检查分为两种块的一致性检查和文件的一致性检查
块的一致性检查
系统为块检查,建立两张表,一张记录使用的块的使用表,一张记录空闲的块的空闲表,0表示未被该表标记,1表示被该表标记。对于同一块,将出现下面几种情况(下面所有项的表示顺序为使用表、空闲表):
- 10 或 01 :此时,块一致,块仅在某一张表中有记录。
- 00 :块没有被任何一张表记录,因块丢失而造成了空间浪费,只要把它重新加入空闲表中即可。
- 11 :块不一致,但已不可追因,采取的措施是,将此块的内容分配到一个新块中,当前块的记录重新为01。虽然,此块的内容大概率是有误的,但是保证了文件系统的一致性,也因此,需要向用户报告错误,由用户做出进一步的处理。
文件的一致性检查
建立一张计数器表记录文件的链接情况,扫描时,遇到硬链接进行计数,遇到符号链接不计数。扫描完后,得到一张inode的链接计数表,然后将此表与各inode进行比较:
- inode记录的链接数更大:需要更新inode记录的数值,否则当所有的硬链接解除后,inode的记录的数值不为0,inode将不会被释放。
- inode记录的链接数更小:也需要更新inode记录的数值,否则inode记录的数值可能先到达0,然后,被分配记录其他的文件,将导致余下的硬链接访问到错误的文件。
考虑3:文件系统的性能
磁盘访问的速度比内存访问的速度慢得多,如果每次读文件时,都从磁盘块中读取,那么IO时间将是大大拖累程序效率。
高速缓存
与内存管理类似,可以建立高速缓存记录哪些文件块已经存在与内存中,不需要去访问磁盘。实现上,可以以 LRU 或 FIFO 等来作为缓存区满时,淘汰哪些块记录的算法基础,以散列表来记录一个磁盘块是否存在于内存中。
块预读
块预读是指:在需要用到块之前,视图将其写入高速缓存,从而提高命中率。对于一些按顺序读取的文件,此功能将工作良好,而对于一些随机访问的件,此功能将帮倒忙。因此,文件系统可以跟踪文件的行为而做合理猜测来决定是否预读,如果偶尔发生一次预测错误,也不会有严重后果,只是让某一次慢一些罢了。
休息区
文章到此,已阐明要理解文件系统所需的方方面面的概念和实现,已可以直接跳转到文章结尾。也可以继续进一步地看文件系统的实例。
LFS(Log-structure File Systen,日志结构文件系统)
大多数文件系统中,写操作零碎。一个50μm的写操作之前通常需要10ms的寻道时间和4ms的旋转延迟,因此零碎的写操作极其没有效率。除了对文件的修改造成的零碎写操作,inode节点的更新、新建也会产生零碎的写操作,这是因为系统为了避免写操作延迟过程中,因死机、崩溃的原因造成inode未被写入而导致的文件系统严重不一致。
并且随着CPU的运行速度越来越开,RAM内存容量更大,同时磁盘高速缓存也迅速地增加。进而,不需要磁盘访问操作,就有可能满足直接来自文件系统高速缓存的很大一部分请求。此外,如果能把零碎的写操作合并,一次性写入更多的连续数据(更少的磁盘搜索),将能有效地提高文件系统效率。
LFS因此出现,并针对解决两个问题:
- 随机输入输出的性能和序列输入输出性能相差很大
- 磁盘搜索和旋转延迟比较大
虽然LFS因没有兼容到大多数的已存文件系统而没有得到广泛应用,但是它表达的想法足够有吸引力,各文件系统都借鉴其想法,也得到了实际应用。因此,学习LFS,有助于了解各大文件系统的设计。
LFS的核心为:把所有的更新,缓存到内存中,以Segment为单位,当Segment填满之后,把Segment写入磁盘。以Segment来保证连续性地写入。
1. 文件如何写入
LFS面对的第一问题是,如何将Segment收集到的零碎写操作写入到磁盘,并保证文件是正确的。
(1)一个数据块如何写入磁盘
产生一个起始地址为A0的数据块,将指向它的inode于它相邻(也可以是多个数据块),这么做的原因是,如果inode和数据块的距离很远而产生磁盘搜索,就达不到提高写入效率的目的,因此旨在保证序列化地写入。此时,inode中blk[0]就记录了数据块的位置。
(2)如何知道inode的精确地址
不同其他文件系统中inode会保存在固定的磁盘位置,从inode的序号就能计算出inode的磁盘地址。LFS中,inode的分布是任意的,并且在数据更新时,不覆盖原先的数据,因此更新后的inode位置也将不断变化。那么就需要记录inode的位置,这一职责就由imap负责。上图就由map[k] 记录了inode-k 的地址A1.
(3)如何知道imap的位置
与inode同样的问题,imap也需要任意分散,固定位置将导致每次访问imap位置时,可能增加的磁盘读写时间。因此,LFS以 CR(check-point region)来记录每一个imap的位置,而CR存储在磁盘固定位置,并周期性地更新。
(4)目录
得益于 CR-iamp-inode的设计,目录就变成了(name, inode-number)的集合。目录记录了文件的inode的序号就始终能找到子文件。
在磁盘上创建一个文件时,LFS写入一个新的inode和新的数据,目录指向这个文件的数据和inode也将更新。假设一个文件的inode被更新后,它在磁盘上的位置就改变了,如果在其他文件系统中,还需要更新指向它的目录,且沿着文件系统树向上更新。而对LFS而言,这样的行为,更新imap即可,不需反映到目录中。
2.如何清理
LFS更新数据时,不会覆盖旧数据,会在新的磁盘位置写入数据,那么磁盘写满了怎么办,大量的老版本的数据怎么办。
LFS老版本数据产生的原因如下:
- 文件更新:新的数据块和新的inode产生,更新imap的信息。磁盘里留下了就的数据块和指向它的inode。
- 文件新增:在文件中增加了新的数据,LFS将在磁盘上分配新的数据块记录新的数据,并产生新的inode指向旧的数据块和新的数据块。此时,旧的数据块同时被新的inode和旧的inode指向。这种情况下,旧的inode可以被用来做老版本恢复。
!
磁盘在LFS中被抽象为一个环形数组结构。回收时,从清除起始位置开始,以Segment为单位,整合出有效的数据,形成新的Segment,插入写入起始位置。于此同时,清除起始位置和写入起始位置也对应地后移。如若到达了结束位置,又重新从起始位置开始。
至于何时清理数据,就变得简单了,分别为:
- 定期清理
- 空闲时清理
- 磁盘满了,不得不清理
3.如何确认老版本数据
要清理,就需要弄清什么数据可以清理。在Segment的头部中,有称为 Segment Summary Block 的数据结构,记录着哪一个磁盘块属于哪一个文件和offset(是文件的第几个磁盘块),然后比较inode中得到的地址,如果不一样,那么这个磁盘块就老版本数据。
还有另一种方法是,文件更新时,LFS增加这个文件的版本号,记录在imap,同时也会有在Segment中。通过比较磁盘块的版本号和imap中的版本号,就能判断磁盘块是否是老版本。
4.处理崩溃
CR中记录了所有的imap信息,如果CR中的数据有误,将导致文件系统的不一致。为了保证CR数据的原子性,LFS保存了两个CR在磁盘的两端,更新时交替写入:
- 首先,写入一个header(时间戳)。
- 向CR写入要更新的内容,当写入最后一个磁盘块数据时,写入时间戳。
- 如果是原子写入的,那么两次的时间戳就是一致的。
- 如果更新CR时发生了崩溃,LFS通过观察不一样的时间戳就能检测到出现过系统崩溃,LFS就会采用离奔溃时间最近的时间戳的CR作为使用
5.确定Segment的大小
现在剩下的问题是,如何确定Segment的大小,才能让文件系统有效率。
用 Tp 表示写操作前磁盘旋转和寻道的开销时间,Rp 表示磁盘传输速率,得到传输数据 D 需要的时间 Tw 为:
> Tw = Tp + D / Rp
REffective 为写效率,为:
> REffective = D / Tw = D / (Tp + D / Rp)
写效率以传输速率为基准,传输速率用 F 表示,取值范围在 0~100% 之间,即 0<F<1:
> Reffective = D / (Tp + D / Rp) = F * Rp
> D = F * Rp * (Tp + D / Rp)
> 变换得
> D = (F / (1-F)) * Rp * Tp
假设要得到 90% 的写效率,假设旋转和寻道开销为0.01秒,磁盘传输效率为 100MS/s,解得缓存大小(Segment)的大小为 9MB。
LFS小结
LFS展示了很强的鲁棒性,以Segment为粒度,收集内存中零碎的写操作并写入磁盘中。为了保证序列性地写入,LFS以 CR-imap-inode 之间的映射记录管理,达到了目的,同时也保证了文件数据的正常存储。虽然LFS的管理更复杂,但是较于它解决了零碎写操作、序列性写入而言是值得的。
从此例子也可以看出,文件数据的存储机制,往往决定了文件系统的不同,也代表了对某一类场景所表达的解决方案。
Linux文件系统
Linux中的文件是一个长度为0或多个字节的序列,可以包含任意的信息。ASCII文件、二进制文件和其他类型的文件是不加区别的。文件中的各个位含义完全由文件所有者确定。
根目录为“/”。访问文件的第一种方法是绝对路径。也可以是相对路径(允许把当前工作的目录标示为工作目录)。当两个用户共享一个文件时,使用绝对路径会很长也很麻烦,因此Linux提供一种指向已存在文件的目录项,称作链接。
除了普通文件之外,Linux还存在字符特殊文件和块特殊文件,前者用来模拟串行I/O设备,后者用来直接向磁盘分区中读取和写入内容,而不需要考虑文件系统。如果块特殊文件进行一个偏移为k的read()操作,将直接向对应分区的第k个自己开始读取,完全忽略inode和文件的结构。原始块设备常被一些建立(如mkfs)或修复(如fsck)文件系统的程序用来进行分页和交换。
处理多磁盘
计算机是允许存在更多的磁盘的,因此当安装了多个磁盘的时候,就需要处理它们。
一个方法是,每个磁盘上安装各自包含的文件系统,他们之间相互独立,如果要访问其他磁盘的文件,使用者就必须指定设备和文件。
Linux允许一个磁盘挂载到另一个磁盘的目录树上,之后就能像访问其他文件一样对挂载的文件进行访问,对于用户来说,就不需要关心文件在那个设备上。
加锁
Linux中的加锁更有趣。当多个进程访问同一文件时,可能产生的竞争条件虽然可以使用临界区做解决,可当进程属于不同独立用户时,极不方便。如果使用信号量可以解决互斥的问题,但是却让整个文件或整个目录都不能被访问。Linux提供了更灵活的锁:共享锁和互斥锁。
与其他文件系统不同的是,Linux的枷锁操作小到一个字节,大到整个文件。加锁机制要求加锁者标识要加锁的文件、开始位置和加锁的字节数。操作成功时,系统会记录被锁住的区域。
互斥锁:如果对被锁住的区域上互斥锁,那么在锁住的区域被解锁前,是不会成功的。
共享锁:如果被锁住的区域被共享锁锁住,那么再对其上共享锁是可以成功的,对于上图,如果有C要访问最深色区域,那要等待A和B解锁后才能访问。
管道(pipe)
pipe系统调用将创建一个shell管线,创建一种伪文件(pseudo-file) 来缓冲管道通行的数据,并给缓冲区的读写都返回文件描述符。如命令:
sort <in | head -30
在执行sort的进程中,文件描述符1(标准输出)被设置为写入管道,执行head的进程中,文件描述符0(标准输入)被设置为从管道中读取。 双方的读写均没有察觉到被重定向了。此例子清晰地表明了如何使用一个简单的概念(重定向)和一个简单的实现(文件描述符0和1)来实现一个强大的工具(以任意方式连接程序,而不需要去修改他们)。
实现
Linux支持VFS,以此屏蔽各个文件系统的实现细节,依此使各个文件系统能运行在Linux上,也使前文所说的挂载得以实现。
接下来,以 ext2、ext4、NFS来一窥文件系统的实现与不同。
ext2
- 引导块:块0不被Linux使用,通常用来存放启动计算机的代码。
- 超级块:包含此文件系统的信息,包括inode的个数,磁盘块数以及空闲块链表的起始位置(通常有几百个项)。
- 组描述符:存放了位图(bitmap)的位置,空闲块数、组中inode、以及组中目录数等信息,此信息很重要,ext2试图把目录均匀地分散存储到磁盘上。
- 每个i节点被编号为1到某个最大值,每个i节点为128字节,恰好描述一个文件,也包含了统计信息(stat操作会从i节点读取信息),包含了此文件的文件数据块的磁盘块的位置。
- 如果一个文件或目录包含了多个磁盘块,这些磁盘块不需要连续。
- 如果空间足够,ext2会把普通文件组织到父目录相同的块组上,而把同一块上的数据文件组织成初始文件i节点。
- ext2在分配文件时,会与分配许多额外的数据块,以减少将来向该文件写入数据时产生的文件碎片。此策略在整个磁盘上实现了文件系统负载均衡,由于减少了排列和缩减,使性能也更好。
在ext2中,目录文件不允许超过255个字符的文件名,每一个目录由整个磁盘块组成,这样就可以将目录整体写入磁盘块。目录中的目录项是没有排序的,一个紧挨着一个,并且不能跨磁盘,因此每个磁盘块未卜都会有未使用的字节。
为了避免目录的线性查找,系统要维护一个近期访问过的缓存来加快查找。当要查找一个文件,将查找到它的目录项,并从记录的inode序号,通过inode表找到对应的inode结构存储位置,进而访问到文件。其中inode结构为
- Mode(2B): 文件类型、保护位、setuid和setgid。
- Ninks(2B): 指向该inode的目录项的树木。
- Uid(2B): 文件所属主的UID。
- Gid(2B): 文件所属主的GID。
- Size(4B): 文件大小。
- Addr(60B): 12磁盘块及其后面3个间接块的地址,间接块用于分级存储,指向其他的磁盘块,以能记录大文件的数据存储位置。
- Gen(1B): generation数,inode被重用时增加。
- Atime(4B): 最近文件访问时间。
- Mtime(4B): 最近文件修改时间。
- Ctime(4B): 最近改变inode时间。
ext2需要解决的一个问题是,相关联的进程如何访问操作一个文件。
进程访问文件时,会拿到文件描述符,进程中有文件描述符表来进行记录。文件描述符表和inode之间,加入了一张 打开文件描述表 来记录正在访问的inode。文件描述符指向打开文件描述表的一个描述,而不是i节点。当创建子进程时,将自动继承父进程对文件的读写位置。这样,父子进程读到同一个项,就能操作最新的文件读写位置。而其他的进程虽然也访问同样的inode,但因指向的打开文件描述不同而不会对读写位置造成影响。
ext4
ext4是对ext3的改进,而ext3是在ext2的基础上,增加了日志文件管理,使变成了日志文件系统,增强了健壮性。ext4改了ext3的采用的块寻址方案,从而支持更大的文件和更大的整体文件系统,其主要实现为:
- 日志是以环形缓冲器形式组织的文件,可以存储在当前或其他设备上。
- 日志操作本身不被日志记录,使用一个独立的日志块设备(Journaling Block Device, JBD)来执行日志的读写操作。
- JBD主要结构为:日志记录、原子操作处理、事务。
- 一个事务中日志记录是连续存储的。仅当一个事务中的所有日志记录都被安全地提交到磁盘后,才允许日志文件的相应部分被丢弃。
- 可以配置为,保存所有的磁盘改动,或仅仅保存文件系统元数据(i节点、超级块等)的改动以使系统开销更小,性能更好。
- 使用了盘区,以代表连续的存储块。
- 不强制对每个存储块进行元数据操作,为大型文件减少了碎片。结果是,提供了更快的文件系统操作,支持更大的文件和文件系统。
NFS (网络文件系统)
文件系统不一定要运行在本地。既然Linux支持了VFS,那么只要接口被明确定义,那么文件系统在远端,对于用户来说也是透明的。
NFS的基本思想是,允许任意选的一些客户端和服务器共享一个公共文件系统。也就是NFS支持异构系统,将其本身运行于不同的操作系统。
对于Linux来说,只需将NFS挂载到目录树中,就可以对NFS进行访问。剩下的问题是,如何对挂载NFS,如何对NFS进行访问。为实现这一目的,NFS定义了两个客户端-服务端协议来达成目的。
第一个协议 处理挂载。客户端可以向服务端发送路径名,请求服务器许可将目录挂载到自己的目录层次的某个地方。由于服务器并不关心目录将被挂载到何处,因此请求消息中并不包含挂载地址。若路径名合法并且该目录已经被导出,那么服务器会向客户端返回一个文件句柄。这个文件句柄中的域唯一地标识来文件系统类型、磁盘、目录的i结点以及安全信息等。之后对于此目录的访问均通过此文件句柄。
第二个协议 访问目录和文件。 客户端可以向服务端发送消息访问文件。支持大多数文件调用操作,但是如open()、close()不支持。服务器不维护打开的文件的状态信息,即是无状态的,如此的好处是,如果服务器崩溃后恢复,因不需要记住关于已打开的连接的信息,所有关于已打开的文件的信息都不会丢失。 open()可由lookup()操作替代。但是,这样的方法使得一些精确的语义难以实现,如加锁。因此需要其他机制来进行处理。此外为了防止伪装,可以建立安全密钥来配合请求访问。
实现如下:
- VFS层维护一个表,每一个表项表示每个打开的表项。表项记录一个vnode(虚拟节点),此vnode要不指向本地inode,要不指向一个rnode(远程inode)。
- 如果客户端打开一个文件,发现是挂载了远程文件系统的目录,会让NFS客户端想服务器发送消息,要求打开此文件。服务器查询到文件后,返回一个句柄给NFS客户端。NFS客户端创建一个rnode并将句柄保存其中,而VFS创建一个新的vnode指向这个rnode。
为了保证效率,NFS还需做如下工作:
- 即使传输的数据很少,数据传输也是用大数据块,通常为8192字节。
- 客户端收到服务端的数据后,会自动地发出下一个块数据的请求。如此当需要下一个块时将更快地得到。这个特性称为预读。
- 建立两个缓存,客户端维护文件属性(i-node)和文件数据的缓存,服务端缓存文件数据
- 为避免读到脏数据,客户端缓存会关联定时器,定时器到期时,将缓存的块丢弃。并且在打开一个缓存的文件时,会向服务端发送消息确认文件未被修改过。虽然这样降低了的整体效率,但是却使VFS高度可用。
NTFS(New Technology File System)
Windows支持若干种文件系统,其中最重要的是FAT系列和NTFS。这里只看NFTS,因为它带有有趣的特性和创新的设计。
基础概念
在NTFS中,文件名最多为255字符,全路径名最多为2^15 个字符。并且文件名采用Unicode编码,这样就能支持到跟更多的语言。
NTFS文件与其他文件系统不同的是,它的文件并不只是字节的线性序列的,它由很多的属性组成,每个属性由一个字节流表示,每一个流都有一个由文件名、冒号、流名字组成的名字,如 foo:steam1。每一个流有自己的大小,可以被独立锁定。 NTFS大部分文件都包含一个短字节流和一个包含数据的未命名的长字节流,当然也可以有多个数据流。多数据流还可以表示文件的元数据,但是多数据流是脆弱的,会因为其他工具忽略了它们而导致在网络传输中、传输到其他文件系统中丢失。别的文件系统是对文件内容进行读写,而NTFS是对包含文件的属性进行读写。
NFST中,使用簇作为数据尺寸的最小单位,因为它将所有的数据和文件系统管理的数据都作为文件进行管理。所以NTFS文件系统中的所有扇区都被分配簇序号,从0开始对所有的簇进行编号。文件系统的0号扇区为0号簇的起始位置。
MFT
在NFST中,核心为 MFT(Master File table, 主文件分配表),NTFS每个卷(如磁盘分区)所有的文件信息都包含在 MFT 中。MFT的每一个MFT项大小固定为1KB,每一个文件和目录都由一个或者多个MFT项来记录文件的属性。MTF本身也是一个文件,可以被放在卷中的任何位置。
MFT中,前16项用来记录特殊的文件,这些文件也被称为元数据,并以“$”符号开头。从0~15分别为:
- $Mit: 是整个MFT,也就是将整个MFT看作是一个文件。
- $MitMir: MTF的前几个MFT项的副本,防止MFT坏掉。
- $LogFile: 日志文件,当对系统文件作出结构性的改变时,此操作记录在这个文件里,以发生意外时(如崩溃),可做正确的恢复。
- $Volume: 卷信息。
- $AttrDef: 属性定义列表,定义各种属性的名字和类型。
- $: 根目录文件。
- $Bitmap: 位图文件,跟踪卷里的剩余空间,它的数据属性的每一个bit对应文件系统中的一个蔟,通过它就能知道蔟的分配情况。
- $Boot: 引导文件,因来引导装载程序。
- $BadClus: 记录所有的损坏的簇,确保没有文件会使用到它们。
- $Secure: 安全信息。
- $Upcase: 大小写转换表。
- $Extend: 一些杂项信息,如磁盘配额、对象标识符、重解析点等。
- 12 ~ 15: 保留,已备将来使用。
除去上面的固定MFT项,余下的项就提供给其他的文件使用。对于每一项MTF,由一个纪录头和一组属性对(每对为属性头,值)构成。可以使用的属性都在$AttrDef中进行了定义,属性可以重复出现,但是必须按照固定顺序出现。常用的属性包括:
- 标准信息:标志位,时间戳等。
- 文件名:Unicode编码的文件名。
- 属性列表:额外的MFT记录的位置,如果需要的话。
- 对象ID:此卷的唯一64位文件标识符。
- 重解析点:用于加载和符号链接。
- 索引根:用于目录。
- 索引分配:用于很大的目录。
- 位图:用于很大的目录。
- 日志工具流:控制记录日志是否记录到$LogFile。
- 数据:数据流。
因为在MTF项中的属性包含值的,就存在存不下的情况,因此属性又分为常驻属性和非常驻属性。
常驻属性 属性内容很小,MTF项可以容纳它得下它的全部内容,直接存放在项中而不再分配额外的簇空间。
非常驻属性 属性内容非常大,无法完全存放在MTF项中,如文件的数据属性,需要再MFT之外分配足够的空间进行存储。
存储分配
对于存储的数据,为了让效率更高,会要求磁盘块尽可能地连续分配。
上图是一个可能的短流MTF项记录。文件的数据存放位置,由数据头后多个对标出,给出了磁盘地址和持续长度。
块区域,表示了余下的数据,属于文件逻辑上的,第0块往后的共9个数据块。块串1为逻辑上的第0~第3块的数据块,在磁盘位置为第20块~第21块;块串2为逻辑上的第4~第5块的数据块,在磁盘位置为第64块~第65块;块串3为逻辑上的第6~第8块的数据块,在磁盘位置为第80块~第82块。
因为磁盘块尽可能要求连续分配,但不一定能一次性找到合适的区域,因此分配多个连续块来存储数据。
当然,也可能出现一条MTF项记录无法记录所有信息的情况,那么就可以像上图一样,使用多个MTF项来进行记录。
文件压缩
与此同时,NTFS支持同名的文件压缩,一个文件能够以压缩方式来创建。当NTFS写一个有压缩标志的文件到磁盘时,它检查这个文件的前16个逻辑块,如果能放到更少的块中(块尽量连续),则以压缩形式写入,否则不以压缩形式写入。然后继续下一16个逻辑块,以此类推。
日志
NTFS支持两种让程序探测卷上文件和目录变化的机制。
方式一:调用 NiNotifyChangeDirectory File 的I/O操作
传递一个缓冲区给系统,当系统探测到目录或者子目录树变化时,该操作返回。此I/O操作会在缓冲区里填上变化记录的一个列表。如果缓冲区不够大,记录会被丢弃。
方式二:NTFS变化日志
变化记录保存到一个特殊文件中,程序可以使用特殊文件系统控制来读取。日志文件通常很大,日志中的项在被检查之前重用的可能性非常小。
总结
文章到此已结束。此间描述了什么是文件系统——对存储介质管理的抽象:
- 首先,文件系统需要确定一个文件的结构以及它所应包含的属性,以此确定文件是否正确,具有什么样的功能。比如,以魔数是否合法来确定,文件系统能否正确地从中读取信息。并提供对文件进行的操作。
- 然后,就如何才能迅速地查找访问到指定文件,文件系统需要提供一套索引机制来对文件进行归类和查找,其表现为对目录的设计。因此目录具体实现的不同,可以影响到不同场景下查找文件效率的不同。
- 紧接着,针对文件数据这一占据绝大多数容量的内容,提供存储管理机制,这将决定数据在存储介质中如何分布,决定了文件读写的效率,决定了对数据整合时的代价。
- 在以上条件具备之后,磁盘需要有MBR和超级块等,来引导计算机正确地装载文件系统。
- 总体来看,文件系统需要的关键部分是相似的,因此尝试抽象出了VFS,对用户屏蔽文件系统的不同,也使支持VFS的系统,可以运行不同的实现了VFS的文件系统。
- 最后,以LFS、Linux、NTFS等文件系统实例为说明,文件系统在设计关键部分时,如何权衡,以适应不同的场景。虽不能代表所有的文件系统,但足以窥见文件系统在设计时需要的考量。
文件系统一直在进步,但所要解决的核心是不变的,即有效率地管理存储数据。
本文大多内容来自《现代操作系统 第四版》,错误之处,望请指出。
参考
《现在操作系统 第四版》第4章、第10章、第11章
LFS(the Log-structured File System)系统详解