Skip to content

Latest commit

 

History

History
260 lines (139 loc) · 41.8 KB

第8章 文件系统.md

File metadata and controls

260 lines (139 loc) · 41.8 KB

文件系统的目标是组织和保存数据。文件系统通常支持在用户和应用之间共享数据,以及数据持久化,即在重启之后数据仍然有效。

xv6文件系统提供了类Unix的文件、目录、路径名(见第1章)以及将数据保存在一个虚拟硬盘上的持久化(见第4章)。文件系统主要面临以下几个挑战:

  • 文件系统需要硬盘上的数据结构用于代表有名称的目录树和文件,用于记录每个文件内容的块的身份,以及记录硬盘上哪些区域是空闲的。
  • 文件系统必须支持crash恢复。就是说,如果crash(例如断电)出现,文件系统必须能够在重启后正确工作。风险在于crash可能会打断一系列的更新并遗留一些非持久的硬盘上的数据结构(例如一个块既在文件中被使用也被标记为空闲)。
  • 不同进程可能同时在文件系统进行操作,所以文件系统代码必须加以协调以维持不变性。
  • 访问硬盘的速度比访问内存要低几个数量级,所以文件系统必须在内存上维护一个常用块的缓存。

本章其余部分将解释xv6怎样应对这些挑战。

8.1 概述

xv6文件系统实现组织为7层,如图8.1所示。硬盘层在virtio硬件驱动上读写块。缓冲区缓存层缓存了硬盘块,并且同步对它们的访问,保证同一时间内只有一个内核进程可以修改保存在任何特定块内的数据。日志层允许更高的层将对几个块的修改打包在一次传输中,保证遇到crash时以原子方式更新这些块(即所有的要么都更新了要么都没更新)。inode层提供了单独的文件,每个文件由一个有着唯一的i-编号,并包含一些持有文件数据的块的inode表示。目录层将每个目录实现为一种特殊的inode,inode的内容是一系列的目录项,每个目录项包含了一个文件的名称和i-number。路径名层提供了层次式的路径名例如/usr/rtm/xv6/fs.c,并且使用递归查找解析它们。文件描述符层使用文件系统接口抽象了很多Unix资源(例如,管道,设备,文件等等),让应用程序员的生活更容易。

文件系统必须有一个计划,在硬盘哪里存放inode和内容块。为了达到这个目标,xv6将硬盘分为几个部分,如图8.2所示。文件系统不使用block0(其中存放了boot段)。Block1叫作超级块;它包含了文件系统中的metadata(以块为单位的文件系统大小,数据块数,inode数,log中的块数)。从2开始的block存放log。log之后是inode,每个block几个inode。再之后是位图块,记录了那个数据块在使用中。其余的块是数据块;每个块都处于下面两种状态中的一种:在位图块中被标记为空闲,或者存放了文件或者目录的内容。超级块被一个单独的程序mkfs构造起来,它构造了一个初始的文件系统。

本章剩下的部分讨论了每一层,从缓冲区缓存层开始。请留意精心挑选的低层抽象是怎样简化高层代码的。

8.2 缓冲区缓存层

缓冲区缓存有两个任务:(1)同步对硬盘块的访问,从而保证在内存中一个块只有一份拷贝,而且同一时间内只有一个内核线程使用这个拷贝;(2)缓存常用的块,这样使用它们时就不必从缓慢的硬盘里再次读取。代码位于bio.c。

缓冲区缓存层暴露的主要接口有bread与bwrite构成;前者获取一段包含一个块的拷贝的buffer,能够在内存中读取或改动,后者将一段改动过的buffer写入到硬盘上合适的块。内核线程操作完成后必须通过调用brelse释放buffer。缓冲区缓存层使用一个每buffer的睡眠锁来保证同一时间只有一个线程使用一个buffer(也因此使用一个硬盘块);bread返回一个加锁的buffer,brelse释放这个buffer。

我们回到缓冲区缓存。缓冲区缓存层有一个固定数量的buffer来保存内存块,这意味着如果文件系统请求一个不在缓存中的块,缓冲区缓存层必须马上回收一个当前持有其他块信息的buffer。缓冲区缓存回收近期使用最少的buffer来保存新块。其中包含的假设是,近期使用最少的buffer是近期再次被使用可能性最小的。

8.3 代码:缓冲区缓存

buffer缓存是一个buffer的双链表。main(kernel/main.c:27)调用的binit函数使用静态数组buf(kernel/bio.c:46-52)中的NBUF个buffer初始化链表。所有其他对缓冲区缓存的访问都是通过bcache.head访问链表,而不是buf数组。

buffer有两个与它相关的状态域。valid域表示buffer包含了一个块的拷贝。disk域表示buffer内容已经被提交给硬盘,这会修改buffer(例如,从硬盘向data写数据)。

bread(kernel/bio.c:93)调用bget来为给定的分段获取buffer(kernel/bio.c:97)。如果buffer需要从硬盘读取,在返回buffer前,bread调用virtio_disk_rw来做这件事。

bget(kernel/bio.c:59)使用给定的设备和分段号在buffer链表中寻找buffer(kernel/bio.c:65-73)。如果有这样的buffer,bget获取buffer的睡眠锁,然后返回加锁的buffer。

如果没有给定分段的已缓存的buffer,bget必须制造一个,可能会使用一个持有别的分段的buffer。它第二次扫描buffer链表,看看有没有未使用的buffer(b->refcnt == 0);任何一个这样的buffer都可以被使用。bget编辑buffer的metadata来记录新的设备和分段编号,然后获取它的睡眠锁。请注意b->valid = 0的赋值保证了bread能够从磁盘中读取块数据而不是错误地使用buffer中之前的内容。

每个磁盘分段最多只有一个缓存的buffer,这一点很重要,这保证了读者能够看到写入。实现这一点是因为文件系统在buffer上使用锁来同步。bget从第一个循环检查块是否被缓存开始,直到第二个循环声明块现在已经缓存为止,一致持有bache.lock,从而保证了这个不变性。这使得检查块的存在和(如果不存在的话)buffer持有块的设计成为原子的。

bget在bcache.lock的临界区外获取buffer的睡眠锁是安全的,因为非0的b->refcnt值保护了buffer不被别的硬盘块重用。睡眠锁保护了这个块的缓冲区内容的读写,而bcache.lock保护了关于缓存的是哪个块的信息。

如果所有的buffer都在使用中,那么同一时间执行操作系统调用的进程就太多了;这会造成bget panic。一种更优雅的回应可能是睡眠到有空闲buffer的时候,尽管这样可能会有死锁的可能性。

一旦bread读取了磁盘(如果需要)而且将buffer返回给了它的调用者,调用者就有了排他的buffer使用权,可以读写数据字节。如果调用者修改了buufer,它必须调用bwrite,在释放buffer之前将修改的数据写入到磁盘中。bwrite(kernel/bio.c:107)调用virtio_disk_rw来向硬盘硬件发送消息。

调用者用完buffer后,必须调用brelse来释放它。(brelse的名字是b-release的简写,晦涩难懂但值得一读:它最早出现在Unix中,后来在BSD、Linux、Solaris中都有使用。)Brelse(kernel/bio.c:117)释放睡眠锁,将buffer移动到链表前端(kernel/bio.c:128-133)。移动buffer使得链表按照被使用(释放)时间的早晚排列:链表中的第一个buffer是最近使用的,最后一个是最近使用最少的。bget中的两个循环利用了这一点:查找已存在的buffer在最坏情况下必须完整地遍历链表,但先(从bcache.head开始,顺着next指针遍历)查看最近使用的buffer会缩短查找时间。对重用buffer的挑选选的是近期使用最少的buffer,通过逆向查找链表获得(沿着prev指针)。

cryptic

8.4 日志层

crash恢复是文件系统设计中最有意思的问题之一。如果很多文件系统操作都包含向硬盘写数据,其中一部分写入刚完成时发生了crash,这将使硬盘上的文件系统处于一种不确定的状态。举例来说,假设crash发生在文件截断时(将文件长度设为0,并释放它包含的块),由于硬盘的写入顺序不同,可能出现以下情况:可能会将一个包含有内容块的inode标记为free,或者可能留下一个已分配但未引用的有内容块。

后面的情况相对温和,但前者很可能会在重启后造成严重的问题。重启后,内核可能将块分配给其他文件,这是我们就有了两个文件无意中指向同一个块。如果xv6支持多用户,这种情况就可能变成一个安全问题,因为老文件的拥有者可能可以读写新文件上的块,而这个新文件是其他用户所拥有的。

xv6使用一种简单的日志方式解决了文件系统操作中的crash问题。xv6系统调用并不直接写硬盘上的文件系统数据结构。取而代之的是,它将所有需要的磁盘操作描述在一个硬盘上的日志中。一旦系统调用记录了它的所有写入,它会给硬盘提交一个特殊的记录,表明这些日志包含了一个完整的操作。这是系统调用才将写入操作拷贝到硬盘上文件系统数据结构中。所有这些写入完成后,系统调用会把所有的日志擦除。

如果系统crash后重启,操作系统在运行任何进程之前,按下列方式从crash恢复。如果日志被标记为包含完整操作,恢复代码就把写操作拷贝到硬盘上文件系统它们本应该在的位置。如果日志没有被标记为包含完整操作,恢复代码就忽略掉这个日志。恢复代码以擦除日志结尾。

为什么xv6的log解决了文件系统操作过程中的crash问题?如果crash发生在操作提交之前,硬盘log就不会被标记为完整,恢复代码会忽略它,这样硬盘的状态就好像操作并没开始。如果crash在操作提交之后发生,恢复代码将会重新执行所有写入操作,有可能是之前已经开始写入也要重复写一遍。两种情况下,日志都将crash下的操作变成了原子操作:恢复后,要么所有的操作都已在硬盘上写好,要么所有操作都没发生。

inconsistent truncation benign unintentionally with respect to

8.5 日志设计

日志位于已知的固定位置,指定在超级块中。它由一个头块和后面的一系列更新块拷贝(“日志块”)组成。头部块包含日志块数以及一个分段数数组,每个元素对应一个日志块。硬盘上的头部块尺寸数要么就是0,表示log中没有事务,要么就是非0,表示log包含一个完整的提交的事务,包含在指定数目的日志块中。xv6在事务提交时而不是之前写入头部块,而且在将日志块拷贝到文件系统后将count设为0。因此提交中途发生的crash会导致日志的头部块中count为0,;发生在提交后的crash会产生一个非0的count。

每个系统调用的代码说明了对于crash是原子操作的写操作序列的开始和结束。为了允许不同进程同时执行文件系统操作,日志系统可以将多个系统调用中的写操作积累在一个事务中。因此一个提交可能包含多个完整的系统调用中的写操作。为了避免将一个系统调用划分到不同的事务中,日志系统仅在没有文件系统系统调用进行时提交事务。

多个事务一起提交的思想被称为组提交。组提交减少了硬盘操作的次数,因为它在多个操作之间分摊了提交的固定开销。组提交也让硬盘系统能够同时处理更多的并发写入,可能允许硬盘在一个硬盘转动中完成所有的写入操作。xv6的virtio驱动不支持这类批处理,但xv6的文件系统设计允许这一点。

xv6使用硬盘上的固定尺寸的空间来保存log。系统调用一次事务中写入的总块数必须能放在那个空间中。这导致了两个结果。首先,没有单个系统调用可以写入超过日志剩余空间的块。这对大多数系统调用来说都没问题,但有两个系统调用可能会写大量的块:write和unlink。一个大文件的写入可能写很多数据块,很多位图块以及一个inode块;释放一个大文件的连接可能写很多位图块和一个inode。xv6的write系统调用将大量的写入分割为多个小型的适合log的写入,unlink不会引发问题,因为实际上xv6的文件系统只使用一个位图块。其次,有限的log空间使得log系统不允许一个系统调用在不确定日志剩余空间是否能容纳其写入的情况下开始。

transaction underway amortize batch dedicate distinct

8.6 代码:写日志

系统调用使用日志的典型用法如下:

begin_op();
...
bp = bread();
bp->data[...] = ...;
log_write(bp);
...
end_op();

begin_op(kernel/log.c:126)开始等待,直到日志系统当前没有在提交,而且当前有足够的预留log空间来存放这次调用的写入。log.outstanding计算了已经预留了日志空间的系统调用数;总的预留空间数量是log.outstanding乘以MAXOPBLOCKS。增加log.outstanding既预留了空间,也防止了这个系统调用期间提交的发生。代码保守假设每个系统调用最多只能写MAXOPBLOCKS个不同的块。

log_write(kernel/log.c:214)扮演了bwrite代理的角色。它在内存中记录了块的扇区号,为块在硬盘日志上留了一个槽,然后将buffer放在块缓存中,防止块缓存丢弃它。块必须在缓存中呆到提交:在那之前,缓存的拷贝是这个修改的唯一记录;它只有在提交后才能被写道属于它的硬盘位置;而且同一个事务中的读操作必须能看到这个修改。log_write注意到在一个事务中,一个块何时被多次写入,然后在l日志中为块分配相同的位置。举例来说,包含几个文件inode的硬盘块会再一次事务中多次被写入,这是很常见的。通过将几个硬盘写入合并到一个,文件系统能够节约日志空间,并获得更高的性能,因为只有一个硬盘块的拷贝必须被写入硬盘。

end_op(kernel/log.c:146)首先减少未完成系统调用数。如果这个计数为0,它就调用commit(),提交当前事务。这个过程分为4步。write_log()(kernel/log.c:178)将事务中修改的每个块从buffer缓冲中拷贝到硬盘日志区的插槽上。write_head()(kernel/log.c:102)将头部块写到硬盘上:这里完成了提交,在这之后发生的crash将引起恢复中重新执行日志事务中的写入。install_trans(kernel/log.c:69)从日志中读取每个块,然后将它写入到文件系统中合适的位置。最后end_op将日志头部中的count设为0;必须在下次事务开始写日志块前完成这一步,这样如果发生crash就不会造成回复时用本次事务中的头部,但日志块用的是下一事务的。

recover_from_log(kernel/log.c:116)在initlog(kernel/log.c:55)中被调用,initlog是在开机时第一个用户进程(kernel/proc.c:539)运行之前运行的fsinit(kernel/fs.c:42)中被调用。它读取日志头部,如果其中信息说明日志中包含一个已提交的事务,则模仿end_op中执行的动作。

filewrite(kernel/file.c:135)中有一个使用日志的例子。事务如下:

begin_op();
ilock(f->ip);
r = writei(f->ip, ...);
iunlock(f->ip);
end_op();

这些代码被包装在一个循环中,循环将大段的写入分割,每次只写一小部分,来防止日志区溢出。writei的调用将许多块作为这次事务的一部分写入:文件的inode,一个或几个位图块,以及一些数据块。

reserved increment conservatively distinct blocks sector slot evict pin absorb subsequent mimic

8.7 代码:块分配

文件与目录内容存储在硬盘块上,而硬盘块必须从一个空闲池中分配。xv6的块分配器在硬盘上维护了一个位图,每个块对应一个位。0代表对应的块是空闲的,1代表块被使用。程序mkfs设置了根分段,超级块,日志块,inode块和位图块所对应的位。

块分配器提供两个函数:balloc分配一个新硬盘块,bfree释放块。balloc中(kernel/fs.c:71)的循环考虑了每个块,从block0开始到sb.size,文件系统中的总块数。它查找位图中位为0的块。一旦找到,它会更新位图并返回这个块。为了提升效率,循环被分为两个部分。外循环读取每个块的位图位,内循环查看每个位图块中的所有BPB位。由于缓冲区缓存层同一时刻只允许一个进程使用任何一个位图块,两个进程同时尝试分配一个块而引发的竞争被避免了。

bfree(kernel/fs.c:90)找到正确的位图块并清除正确的位。bread与brelse所带来的排他性再一次避免了显式地加锁。

就像本章其余代码一样,balloc与bfree必须在一个事务中调用。

remainder

8.8 Inode层

inode有两种相关联的意义。它可能指包含一个文件尺寸和数据块编号列表的硬盘数据结构。也可能指内存中的inode,它包含一个硬盘inode的拷贝,还有内核需要的其他数据。

硬盘inode被打包到一个连续的硬盘区域中,也叫inode块。每个inode有着相同的尺寸,因此很容易就能按照编号n从硬盘中找到第n个inode。实际上,这个编号n,又叫inode号或i-number,是inode在代码实现中的识别方式。

硬盘inode由dinode结构体(kernel/fs.h:32)所定义。type域区分不同的文件、目录以及特殊文件(设备)。type为0表示一个硬盘inode是空闲的。nlink域记录了指向这个inode的目录项的数目,用来识别硬盘inode与它的数据块何时能被释放。size域记录了文件内容的字节数。addrs数组记录了保存文件内容的硬盘块的块编号。

内核在内存中保存了一组活动的inode;inode结构体(kernel/file.h:17)是硬盘dinode结构体的内存拷贝。内核只在有C指针指向inode时,才将其保存在内存中。ref域记录了指向内存inode的C指针数,当引用数降至0时,内核会丢弃这个inode。iget与iput函数获取或释放指向inode的指针,同时修改引用数。指向inode的指针可以来自文件描述符、当前工作目录以及暂时的内核代码例如exec。

在xv6的inode代码中有四种锁或类锁机制。icache.lock保护了inode在最开始在缓存中的不变性,以及缓存的inode的ref记录指向缓存inode的C指针的一致性。每个内存inode都有一个lock域,包含了一个睡眠锁,它保证了对inode域(例如文件长度)以及inode的文件或目录内容的块的访问操作的排他性。如果一个inode的ref大于0,那系统会在缓存中维护这个inode,不会为另一个inode重用这个缓存项。最后,每个inode都包含一个nlink域(在硬盘上,如果已缓存,也会在内存中)记录了指向这个文件的目录项的数目;xv6不会释放link数大于0的inode。

iget()所返回的inode结构体指针保证是有效的,直到调用iput();在这期间inode不会被删除指针指向的内存也不会被另一个inode所使用。iget()提供了非排他的访问inode的方式,这样很多指针都可以指向同一个inode。文件系统代码中的很多部分都依赖于iget()的这种行为,包括持有指向inode的长时间引用(例如打开的文件和当前目录)以及在操作多个inode的代码中避免竞争和死锁(例如路径名查找)。

iget返回的inode结构体可能没有任何有用内容。为了保证它持有磁盘inode的拷贝,代码必须调用ilock。ilock给inode上锁(这样就没有别的进程能ilock它)然后从硬盘读取inode,如果它还没有被读取。iunlock释放inode上的锁。区分inode指针的获取和锁的获取有助于在某些情况下避免死锁,例如在目录查找时。多个进程可以同时持有iget所返回的指向同一个inode的C指针,但只有一个进程能给inode上锁。

inode缓存只缓存内核代码或数据结构持有C指针的inode。它的主要任务是其实是将不同进程的访问同步化;缓存是第二位的。如果一个inode经常使用,如果没有在inode缓存中保存,缓冲区缓存也很可能会将它保存在内存中。inode缓存是直写的,就是说修改一个缓存的inode后必须立即使用iupdate将它写到硬盘中。

transient write-through

8.9 代码:inode

为了分配一个新inode(例如当创建文件时),xv6调用ialloc(kernel/fs.c:196)。ialloc类似于balloc:它循环查看硬盘上的inode结构体,每次一个块,寻找被标记为空闲的块。一旦找到,它就通过把新type写在硬盘上来认领这个块,然后通过最后调用iget返回一个inode缓存项(kernel/fs.c:210)。ialloc的正确操作依赖于同一时间只有一个进程可以持有bp的引用这一事实:ialloc可以确定,别的进程不会同事看到这个inode可用然后尝试认领它。

iget(kernel/fs.c:243)在inode缓存中查找一个活动的包含目标设备和inode编号的项(ip->ref>0)。一旦找到,它会返回一个指向那个inode的新引用(kernel/fs.c:252-256)。当iget扫描时,它记录第一个空插槽的位置(kernel/fs.c:257-258),如果它需要分配缓存项时就会用到它。

代码在读写它的metadata或内容之前必须用ilock锁定inode。Ilock(kernel/fs.c:289)使用一个睡眠锁达成这个目标。一旦ilock能够排他地访问inode,如果需要,它会从硬盘(更可能是缓冲区缓存)中读取inode。函数iunlock(kernel/fs.c:317)释放睡眠锁,这可能会引起任何正在睡眠的进程被唤醒。

iput(kernel/fs.c:333)通过减少引用计数(kernel/fs.c:356),释放指向一个inode的C指针。如果这是最后一个引用,inode在inode缓存中的插槽会被释放,然后就能被不同的inode重新使用。

如果iput发现没有C指针指向一个inode,而且这个inode没有任何连接(没有目录),然后inode和它的数据块必须被释放。iput调用itrunc来将文件截断到0字节,并将数据块冻结;将inode类型设为0(未分配);然后将inode写到硬盘上(kernel/fs.c:338)。

iput中的锁协议在它释放inode时需要仔细看一下。一个危险在于一个并发的线程可能在ilock中等待使用这个inode(例如读文件或是列出目录),还未做好inode不再是分配好的的准备。这不可能发生,因为如果系统调用没有指向缓存的inode的连接而且ip->ref为1,它无法得到指向缓存inode的指针。惟一的那个引用是调用iput的线程所拥有的引用。虽然iput实在icache.lock的临界区外面检查引用计数为1的,但那时连接数是0,所以没有线程可以尝试获取一个新的引用。另一个主要的危险是一个并发的对ialloc的调用可能挑选到iput正在释放的inode。这只可能发生在iupdate写入硬盘之后,此时inode的类型是0.这个竞争是温和的;在读写inode之前,分配线程将会礼貌地等待获取inode的睡眠锁,获取到时iput已经完成读写了。

iput()能够写入磁盘,这意味着任何使用文件系统的系统调用都可能写硬盘,因为这个系统调用可能就是最后一个拥有指向文件引用的调用。即使是read()这样看起来是只读的系统调用也可能以调用iput()结尾。这也就一位置即使是只读的系统调用如果要使用文件系统的话也必须包装在事务中。

在iput()与crash之间,有个有挑战的交互。当指向文件的连接数降至0时,iput()不会直接截断文件,因为有些进程可能仍然持有指向内存中的inode的引用:一个进程可能仍然在读或写这个文件,因为它成功地打开了文件。但是,如果一个crash在最后一个进程关闭文件描述符之前发生,文件将在硬盘上被标记为已分配但没有目录项指向它。

文件系统使用下面两种方法之一处理这种情况。简单的解决方法是在重启后的恢复中,文件系统浏览整个文件系统,寻找被标记为已分配但是没有目录项指向的文件。如果有,就把这些文件释放掉。

第二种解决方法不需要浏览文件系统。这这种解决方法中,文件系统将连接数降到0但引用计数不为0的文件的inode inumber记录在磁盘上(例如超级块中)。如果文件系统在引用计数到0时删除文件,它更新硬盘上的列表,将那个inode从中删除。在恢复时,文件系统将那个列表中的所有文件都释放掉。

xv6没有实现任何一种,这一位置inode可能会被在硬盘上标记为已分配但实际上已经不再被使用。这意味着随着时间退役,xv6可能会面临硬盘空间不足的风险。

truncate

8.10 代码:inode内容

硬盘inode结构,dinode结构体,包含一个尺寸和一个块编号数组(如图8.3)。inode数据保存在dinode的addrs数组表示的块中。第一个NDIRECT数据块放在数组中的第一个NDIRECT项中;这些块被称为直接块。其后的NINDIRECT数据块不在inode中而是在一个叫做非直接块的数据块中。addrs数组中的最后一项给出了非直接数据块的地址。因此文件最开始的12kB(NDIRECT x BSIZE)字节可以从inode中列出的块中加载,其后的256kB(NINDIRECT x BSIZE)字节只能在访问非直接块之后加载。这对硬盘表示来说很好,但对用户来说很麻烦。~~bmap函数负责表示,这样我们即将看到的readi与writei之类的高级函数(作者写了半句)~~bmap返回inode ip的第bn个数据块的硬盘块编号。如果ip还没有这样的块,bmap会分配一个。

函数bmap(kernel/fs.c:378)以简单的情况开始:第一个NDIRECT块放在inode内(kernel/fs.c:383-387)。后面的NINDIRECT块在地址为ip->addrs[NDIRECT]的非直接快上。bmap读取非直接块(kernel/fs.c:394)然后从块的右侧位置读取块编号(kernel/fs.c:395)。如果块编号超过NDIRECT+NINDIRECT,bmap会产生panic;writei包含防止这种情况发生的检查(kernel/fs.c:490)。

bmap按需分配块。ip->addrs[]或非直接项为0说明块未分配。当bmap遇到0时,它用新鲜的块编号取而代之,按需分配(kernel/fs.c:384-385)(kernel/fs.c:392-393)。

itrunc释放文件的块,将inode的尺寸设为0.itrunc(kernel/fs.c:410)从释放直接块开始(kernel/fs.c:416-421),然后释放列出的非直接块,最后释放非直接块本身。

bmap让readi与writei获取inode的数据变得容易。readi(kernel/fs.c:456)以确认偏移和计数不大于文件结尾开始。读取文件结尾之后的读操作会返回错误(kernel/fs.c:461-462)。从文件结尾开始或者中间越过文件结尾的读操作会返回小于请求的字节数(kernel/fs.c:466-474)。writei(kernel/fs.c:483)与readi对应,有三种异常情况:在文件末尾或中间越过文件结尾的写操作会增大文件,最大增长到最大文件尺寸(kernel/fs.c:490-491);循环将数据拷贝到buffer中而不是out(kernel/fs.c:36);如果写操作扩大了文件,writei必须更新它的尺寸(kernel/fs.c:504-511)。

readi与writei以检查ip->type == T_DEV开始。这种情况用于处理特殊设备,它们的数据不再文件系统中;我们会在文件描述符层中回顾这种情况。

函数stati(kernel/fs.c:442)将inode的metadata拷贝到stat结构体中,通过stat系统调用暴露给用户程序。

8.11 代码:目录层

目录的内部实现大部分与文件相同。它的inode类型为T_DIR,它的数据时一系列目录项。每个项是一个dirent结构体(kernel/fs.h:56),包含一个名字和一个inode编号。名字最多DIRSIZ(14)个字节;如果更短的话,以NUL(0)字节结尾。inode编号为0的目录项是空闲的。

函数dirlookup(kernel/fs.c:527)从目录中搜索给定名字的项。如果找到,它更新*poff然后通过调用iget返回一个未加锁的inode。dirlookup是iget返回不加锁inode的原因。调用者已经锁了dp,所以如果找的是.,当前目录的别名,尝试再返回之前锁定inode将尝试第二次对dp加锁,从而造成死锁。(还有很多复杂的死锁场景包括多进程和查找..,父目录的别名;.不是唯一的问题。)调用者可以解锁dp然后锁定ip,保证同时只持有一把锁。

函数dirlink(kernel/fs.c:554)在目录dp中创建了一个给定名字和inode的目录项。如果名字已经存在,dirlink返回错误(kernel/fs.c:560-564)。主循环读取目录项,查找未分配的项。一旦找到就停止循环(kernel/fs.c:538-539),将off设置到可用目录项的偏移中。否则,循环结束时off设置为dp->size。不管怎样,dirlink都通过写入偏移量off在目录中添加了新的一项(kernel/fs.c:574-577)。

8.12 代码:路径名

路径名的查找包含了一连串对dirlookup的调用,每个路径元素调用一次。namei(kernel/fs.c:661)比较路径并返回对应的inode。函数nameiparent是一个变体:它在最后一个元素前停下,返回父目录的inode然后将最后元素拷贝到name中。两个函数都调用了namex来完成真实工作。

namex(kernel/fs.c:626)首先确定了路径计算的跨市为止。如果路径以斜杠开始,计算从根目录开始;否则从当前目录开始(kernel/fs.c:620-633)。然后它使用skipelem来依次考虑路径中的每个元素(kernel/fs.c:635)。没每次循环迭代都在当前inode ip中查找name。迭代以锁定IP开始,先确定它是一个目录。如果不是,则查找失败(kernel/fs.c:636-640)。~~(必须锁定ip,因为ip->type会碍事地改变)~~如果调用是nameiparent而且这是最后的路径元素,循环提前结束;最终的路径元素已经被拷贝到name中,所以namex只需要返回未锁定的ip(kernel/fs.c:641-645)。最后,循环使用dirlooup查找路径元素并通过设置ip = next为下次循环做准备(kernel/fs.c:646-651)。等煦暖查看完所有路径元素后返回ip。

namex可能需要很长时间才能完成:它可能包括几个硬盘操作,为路径中遍历的目录读取inode和目录块(如果它们不在缓冲区缓存中)。xv6精心设计了这个操作,如果一个内核线程调用namex时被阻塞在硬盘I/O,另一个查找不同路径名的内核线程可以同时进行。namex分别锁定路径中的每个目录,这样不同路径的查找可以同时进行。

这种同时查找也引入了一些问题。比方说,当一个内核线程在查找一个路径名时,另一个内核线程可能在执行unlink路径的操作,从而改变了目录树。潜在的风险是查找的目录可能已经被其他内核线程删除,而且它的块又被另一个路径或块重用了。

xv6避免了这些竞争。比方说,当在namex中执行dirlookup时,查找线程持有路径上的锁,而且dirlookup返回了iget获取的inode。iget增加了inode的引用计数。只有从dirlookup中获取到inode后,namex才释放路径的锁。这是另一个线程可以从路径解引用这个inode,但xv6还没有删除这个inode,因为inode的引用计数仍然大于0。

另一个风险是死锁。举例来说,当查找“.”时,next指向与ip相同的inode。在释放ip的锁之前对next上锁会造成死锁。为了避免这种死锁,namex在获取next的锁之前解锁了目录。我们在这里再次看到了iget与ilock分成两个操作的重要性。

succession evaluate underfoot invocation

8.13 文件描述符层

Unix接口的一个很酷的因素,是Unix中的大部分资源都被表示成文件,包括控制台、管道之类的设备以及真实文件。文件描述符层是实现这种统一的层。

xv6给每个进程一张表,包括打开的文件或者文件描述符,正如我们在第1章中看到的。每个打开的文件有一个file结构体表示(kernel/file.h:1),它可能是一个inode或者管道的包装,再加上一个I/O偏移。每个open调用创建了一个新的打开的文件(新file结构体):如果不同的进程独立地打开了同一个文件,不同的实例将拥有不同的I/O偏移量。另一方面,单独打开的文件(相同的file结构体)可以在一个进程的文件表中多次出现,也可以在多个进程的文件表中出现。这可能发生在一个进程使用open打开了文件,然后使用dup创建了一个别名或者使用fork将它共享给子进程。引用计数跟踪了到一个特定的打开文件的引用数。一个文件可以被打开用于读或写或者读写都有。readable与writable域控制这点。

系统中所有的打开文件都被保存在一个全局文件表中,即ftable。这个文件表有很多函数包括分配一个文件(filealloc),创建一个复制的引用(filedup),释放一个引用(fileclose),以及读写数据(fileread和filewrite)。

前三个遵循早已熟悉的形式。filealloc(kernel/file.c:30)在文件表中查找一个未引用的文件(f->ref == 0),然后返回一个新引用;filedup(kernel/file.c:48)增加引用计数;fileclose(kernel/file.c:60)减少引用计数。当文件的引用计数为0时,fclose根据类型释放的所关联的管道或indoe。

函数filestat,fileread和filewrite实现了文件上的stat、read和write操作。filestat(kernel/file.c:88)仅允许在inode使用,它调用了stati。fileread和filewrite检查操作是否被当前打开模式允许,若允许九江调用传递到pipe或者inode的是相爱那种。如果文件代表一个inode,fileread和filewrite使用I/O偏移量作为操作的偏移量,然后由此向前(kernel/file.c:122-123)(kernel/file.c:153-154)。管道没有偏移量的概念。回忆一下,inode函数需要调用者处理锁(kernel/file.c:94-96)(kernel/file.c:121-124)(kernel/file.c:163-166)。inode锁有个便利的副作用就是读写偏移量自动更新,这样对一个文件同时多个写入不会相互覆盖数据,尽管写入的结果可能相互交错。

duplicate interlaced

8.14 代码:系统调用

由于提供了底层的函数,大部分系统调用的实现都很简短(见(kernel/sysfile.c))。有几个调用值得仔细看看。

函数sys_link与sys_unlink编辑目录,创建或者删除到inode的引用。它们是又一个展现事务威力的例子。sys_link(kernel/sysfile.c:120)以获取参数开始,两个字符串,old和new(kernel/sysfile.c:125)。假设old存在而且不是目录(kernel/sysfile.c:129-132),sys_link增加它的ip->nlink计数。然后sys_link调用nameiparent来查找new的父目录和最终路径元素(kernel/sysfile.c:145)然后创建一个指向old的inode的新目录项(kernel/sysfile.c:148)。新的父目录必须存在并与已存在的inode在同一个设备上:inode编号在单硬盘上只有一个单独的意思。如果中间出错,sys_link必须回去将ip->nlink减1。

事务简化了它的实现,因为它需要更新多个磁盘块,但我们不需要在意更新的顺序。要么就全部成功要么就什么都没发生。举例来说,如果没有事务,在创建连接前更新ip->nlink可能需要先暂时让文件系统处于不安全状态,如果中间发生crash将引起灾难。有了事务我们就不需要担心这一点。

sys_link为已存在的inode创建了一个新名字。函数create(kernel/sysfile.c:242)为新inode创建新名字。它是三个文件创建系统调用的泛化:用O_CREATE标志打开,创建一个新的普通文件,mkdir创建了一个新目录,mkdev创建了一个新的设备文件。与sys_link类似,create最开始调用namiparent来获取父目录的inode。然后他调用dirlookup来检查名字是否已经存在(kernel/sysfile.c:252)。如果存在的话,create的行为取决于它用于哪个系统调用:open跟mkdir与mkdev有着不同的语义。如果create用于open(type == T_FILE)而且名字以一个普通文件存在,open就将此视为成功,create也是一样(kernel/sysfile.c:256)。否则是一个失败(kernel/sysfile.c:257-258)。如果名字不存在,create使用ialloc分配一个新的inode(kernel/sysfile.c:261)。如果新inode是一个目录,create用.和..项初始化它。最后,因为数据已经恰当地初始化,create能够将它连接到父目录中(kernel/sysfile.c:274)。create像sys_link一样,同时持有两个inode锁:ip和dp。因为inode ip是刚刚分配的,所以没有死锁的可能:系统中没有任何进程会持有ip锁并尝试用dp上锁。

使用create很容易实现sys_open,sys_mkdir和sys_mknod。sys_open(kernel/sysfile.c:287)最为复杂,因为创建新文件仅仅是它做的事当中的一小部分。如果open收到O_CREATE标志位,它调用create(kernel/sysfile.c:301)。否则,它调用namei(kernel/sysfile.c:307)。create返回一个加锁的inode,但namei不是,所以sys_open必须自己对inode加锁。这正好提供了一个方便的位置来检查目录只能打开用于读而不是写。假设inode用一种方法或另一种方法获得,sys_open分配一个文件和一个文件描述符(kernel/sysfile.c:325)然后填充这个file(kenel/sysfile.c:337-342)。注意没有其他进程能够访问未初始化完的文件因为它只在当前进程的文件表中。

第7章详述了了我们在拥有文件系统之前管道的实现。函数sys_pipe通过提供创建管道对的方式将实现连接到文件系统。参数是一个指向存放两个正数空间的指针,那里记录了两个新的文件描述符。然后它分配管道并安装文件描述符。

havoc generalization semantics

8.15 真实世界

显而易见,真实世界中的缓冲区缓存比xv6的更复杂,但它也服务于同样的两个目标:缓存和同步对硬盘的访问。xv6的缓冲区缓存与v6相似,使用简单的最少近期使用(LRU)驱逐策略;有许多更复杂的策略可以被使用,每一种都在某些工作负载下表现良好,另一些工作相对较差。一种效率更高的LRU患侧会丢弃链表,转而使用哈希表来查找,用堆来进行LRU驱逐。现代缓冲区缓存通常都与虚拟内存系统结合,来支持内存映射的文件。

xv6的日志系统效率不高。提交不能与文件系统系统调用同时进行。即使块上只有一点点字节有改动,系统也要记录完整的块。它执行同步的日志写入,每次一个块,每个都要占用完整的磁盘旋转时间。真实世界的日志系统解决了所有这些问题。

日志并不是解决crash恢复问题的唯一方法。早起文件系统在重启时使用一个清理器(例如UNIX的fsck程序)来检查每个文件、目录、块和inode空闲链表,查找并解决不一致性。大文件系统的清理可以耗费数小时的时间,而且有些情况下无法使用让原始系统调用原子化的方式解决不一致性问题。从log恢复快得多而且在crash面前保持了系统调用的原子性。

xv6使用与早期UNIX相同的基础硬盘布局包括inode和目录;这种架构持续了很多年。BSD的UFS/FFS和Linux的ext2/ext3使用几乎相同的数据结构。这种文件系统布局中最为低效的部分是目录,每次查找都需要线性地扫描所有的硬盘块。当目录仅仅有几个硬盘块时,这样做是合理的,但对包含许多文件的目录来说,代价高昂。微软的Windows的NTFS,Mac OS X的HFS以及Solaris的ZFS,就列举这几个,将目录实现为一棵硬盘块的平衡树。这比较复杂但保证了log(n)级别的目录查找时间。

xv6对硬盘故障的处理十分天真:如果硬盘操作失败,xv6会触发panic。这是否合理取决于硬件:如果操作系统执行在使用冗余掩盖磁盘故障的特殊硬件上,那操作系统可能很少看到故障,panic是合理的。另一方面,使用普通磁盘的操作系统应该对故障有所预料,也应该更宽容地对待它们,这样一个文件中块的丢失就不会影响到其他部分文件系统的使用。

xv6需要文件系统适用于单硬盘设备,而且容量不能改变。因为大型数据库和多媒体文件让存储的需求越来越高,操作系统正在发展新的方式来消除“每个文件系统一个硬盘”的瓶颈。基本方法是将不同的硬盘结合为一整个逻辑硬盘。类似RAID的硬件解决方案仍然是最受欢迎的,但现在的趋势是尽可能多地让软件实现这个逻辑。这些软件实现通常有丰富的功能比方通过运行时增加或删除磁盘,增长或缩减逻辑设备。当然,一个可以动态增长或缩减的存储层需要一个能够做这些事的文件系统:xv6所使用的固定大小的inode块数组在这种环境下是无法做好工作的。文件系统管理分离的硬盘可能是最清晰的设计,但二者之间的复杂接口让一些系统,如Sun的ZFS开始结合它们。

xv6的文件系统缺乏很多现代文件系统的其他特性;比方说,它缺少对快照和增量备份的支持。

现代Unix系统允许很多种类的资源访问同一个系统调用和硬盘存储:有名管道,网络连接,远程访问网络文件系统以及/proc之类的监控和控制接口。它们不使用xv6在fileread与filewrite中使用的if语句,而是给每个打开的文件一张函数指针表,每个函数指针指向一个操作,调用函数指针来调用inode上对应调用的实现。网络文件系统和用户层文件系统提供了将这些调用变为网络RPC的功能,并且在返回之前等待响应。

eviction an entire disk rotation time scavenger essensencially atop plain disk on the fly