【翻译】Xv6 book Chapter 8:File system

Chapter 8 File system

文件系统的目的是阻止和存储数据。文件系统持久地支持在用户和应用之间分享数据,所以这些数据在重启之后仍然是可以得到的。

xv6的文件系统提供了类Unix的文件、文件目录和路径名称,并且把这些数据持久地存在virtio磁盘上。这个文件系统会遇到以下几个挑战:

  • 文件系统需要一个在磁盘上的数据结构来代表文件树,来分辨那些各自文件内容的块,来记录空闲的磁盘区。
  • 文件系统必须是支持崩溃恢复的。这意味着,如果机器奔溃了,文件系统必须在重启的时候是正确的。这个风险是来自于,一个崩溃可能会打断一系列的更新并且使得磁盘数据结构不连续(比如一个块既被用在文件中又被标记为空闲。
  • 不同的进程可能会同时操作在一个文件系统上,所以文件系统的代码必须维持不变性。
  • 获取磁盘的数据要比获取内存的数据慢上几个数量级,所以文件系统必须为经常用到的块维护一个缓存。

本章剩下部分会解释xv6如何处理这些挑战。

8.1 Overview

xv6文件系统被组织成七层,如图8.1所示。disk层从virtio硬件处读写。buffer cache层缓存disk层中的块,并且同步对其的访问,确保同一时刻只能有一个内核进程对一个特别的块进行修改。logging层允许高层把几个对块的操作打包成一个事务,并确保这些块在遇到崩溃的时候也是原子化更新的(全部处理或全部都不)。inode层提供了单独的文件,每个文件用一个独一无二的数inode和一些持有文件数据的块组成。directory层把每个目录视为一个特别的inode,它的内容是一系列文件条目,每个包含文件名和i-number。pathname层提供了层级目录,类似于 * /usr/rtm/xv6/fs.c *,并递归地解析它。file descriptor层用文件系统接口把很多Unix资源抽象,简化了应用级程序员。

文件系统必须决定在哪里存储inode和内容块。为了实现这个目标,xv6把disk拆分成了几个部分,如图8.2所示。文件系统不会使用块0(该块包含了启动部分)。块1被成为超级块;它包含了文件系统的元数据(文件系统地大小,数据块地数量,inode地数量,和在日志里的块数)。从第二块开始存日志。在日志之后是inode,每个块里面会有很多个。之后是位图块,其上记录了正在使用的数据块。剩下的都是数据块;每个都在位图中被标记为free或者为某个文件或者文件夹持有数据。超级块被一个单独的程序所填充,叫做mkfs,它初始化了文件系统。

本章剩下的部分会谈论各个层,并从buffercache开始。关注于那些低层的关键抽象,这些抽象减轻了高层的设计压力。

8.2 Buffer cache layer

buffer cache有两个任务:(1)同步对于disk块的获取,来确保在内存中只有一份副本,并且同一时间只有一个线程可以使用它(表述是否不准确?读应该不用锁吧);(2)缓存经常被用到的块,来避免从缓慢的disk中重新读取。这部分代码在bio.c。

buffer cache主要的接口有两个:breadbwrite;前者会得到一个带有block副本的buf,这个buf可以在内存中修改和读取,后者是修改一个buffer到disk中的相应block处。一个内核线程必须通过调用brelse来释放buf。buffer cache使用每个buf独有的sleep-lock来确保同一时间只有一个线程在使用这个buffer;bread返回一个加锁的buffer,然后brelse会释放这个锁。

让我们回到buffer cache。buffer cache有固定数量的buffer来持有disk块,这意味着如果文件系统请求一个不再缓存中的块,buffer cache必须循环利用一个buffer来持有一些另外的块。buffer cache重复利用最旧未被使用的buffer来持有新的块。这个假设是醉酒未被使用的buffer是最不可能马上再次被使用到。

8.3 Code: Buffer cache

buffer cache 是一个buffer的双向链表。binitmain调用,初始化了NBUF个buffer,这些buffer来自一个静态数组。所有对于buffer cache的获取都要通过bcache.head,而不是buf数组。

一个buffer有两个状态域。valid表示这个buffer包含了一份块的副本(一个buffer只能包含一个?)。disk表示这个buffer的内容以及被传到了disk,这可能会改变这个buffer(比如从disk写入数据到data域)没懂

Bread调用bget来获取对应的一个buffer。如果这个buffer需要从disk读出,在返回之前bread调用virtio_disk_rw来实现。

Bget扫描buffer 链表来寻找一个buffer,这个buffer上面带有需要查找的device和sector号。如果有这样的一个buffer,bget会申请一个该buffer对应的锁。然后返回这个加锁的buffer。

如果没有承载了目标扇区的缓存buffer,那么bget必须要实现这个目标,很可能会重新利用一个缓存了其他扇区的buffer。它会扫描buffer李彪,来查看是否有一个buffer没有在被使用( b->refcnt = 0 );任何这样的buffer都可以被使用。Bget修改buffer的元数据来记录新的device和扇区号,并且申请一个sleep-lock。注意步骤 b->valid = 0 确保了bread将会从disk读取块数据,而不是不正确的使用buffer之前的数据。

一个disk扇区最多cached buffer中缓存一份是非常重要的,这确保了读者可以看见写入的内容,因为文件系统用锁来同步了buffer。Bget确保了这个不变性,通过从第一个循环检查是否被缓存到第二个循环声明这个块已被缓存间连续地持有bache.lock(通过设置dev blockno和refcnt)。这使得对于块存在的检查和(如果不存在)指定一个buffer持有这个块的行为是原子化的。

begt再bcache.lock的临界区外申请buffer的sleep-lock是安全的,因为b->refcnt非零可以保护buffer被不同的disk block重用。sleep-lock保护了对于块缓存内容的读写,bcache.lock保护了关于那些块被缓存的信息。

如果所有的buffer都在被使用,,然后太多的进程同时在执行文件系统调用;bget会panic。一个更优雅的恢复是陷入沉睡直到某个buffer空间,虽然有可能会导致死锁。

一旦bread读取了disk(如果有必要)并且返回buffer给调用者,这个调用者会独占地使用这个buffer,并且在其上读写信息。如果调用者修改了buffer,那么它必须在释放buffer之前调用bwrite吧改变的信息写入disk。Bwrite调用virtio_disk_rw与disk硬件交互。

当调用者使用完了这个buffer,它必须调用brelse来释放它。(brelse是b-release的简写,有点神秘,但是值得学习:它来自于Unix,并且在BSD\Linux还有Solaris中使用)Brelse释放sleep-lock,并且把这个buffer移到链表的最前面。移动buffer会使得列表按照最近使用顺序排列:第一个buffer是最近被使用过的,最后的buffer是最久未被使用的。bget中的两个循环利用了这个特质:直接扫描所有存在的buffer会是一种最糟糕的情况,但是先从最近使用的buffer开始查找(从bcache.head然后是next指针指向的)将会减低扫描时间,因为引用的局部性。挑选一个最久未被使用的buffer来重新使用是通过方向扫描完成的(利用prev指针)

8.4 Logging layer

文件系统设计中一个最有趣的问题是崩溃恢复。这个问题是由于很多文件系统操作关于很多对disk的写操作,并且在一系列的写操作之后可能会使得disk上面的文件系统进入一个不一致的状态。比如,嘉禾一个崩溃发生在文件阶段中(设置一个文件的长度未0,并且释放它的内存块)。根据disk写的顺序,这可能会使得一个inode引用了一个被标记未free的块,或者这会产生一个被分配了但是没有被引用的内容块。

后者是相对好处理的,但是如果一个inode引用了一个空间块的话可能会在重启之后导致严重的问题。重启之后内核可能会把这个块分配给另外的文件,现在我们有两个文件非故意的指向了同一个块。如果xv6支持多用户的话,这个情况会变成一个严重的问题,因为旧文件的拥有者会读写新文件的块,而这部分也属于一个不同的用户。

Xv6通过在文件系统操作时加入一种简单形式的logging来解决这个问题。一个xv6的系统调用不会直接写入disk上的文件系统数据结构。而且,它会在disk上面写入一个日志,这个日志记录了所有disk写操作的描述。一旦系统调用记录了所有的写入操作,他会写入一个特别的commit记录到disk来说明这个日志包含了完整的操作。此时系统调用拷贝了这些写入到disk的文件系统数据结构中。在这些写操作完成之后,系统调用会擦去disk上面的日志。

如果系统崩溃了,然后重启,这个文件系统代码在允许任何程序之前按以下方式来恢复。如果被标记为包含了一组完整的操作,那么恢复代码会拷贝所有的写入到相应的disk文件系统中去。如果日志并不是完整的,那么恢复代码就直接无视这个日志。回复代码直接删除日志。

为什么xv6的日志可以解决文件系统操作中的崩溃问题?如果崩溃发生在操作提交前,那么disk上面的日志将不会被标记成完整的,恢复代码将会午是这部分,disk的状态会如同这个操作从未发生。如果crash发生在一个操作提交之后,那么恢复代码将会重新执行这些操作,如果操作已经开始写入了disk的数据结构,那可能会重新执行这一部分。换句话说,日志及使得崩溃相关的操作原子化:在恢复之后,所有的操作都被执行了,或者都没有执行。

8.5 Log design

日志放在一个已知的固定的位置,superblock记录了这个位置。它由一个header块,以及之后一系列的被更新的块的副本(“被记录的块”)。header block 包含了一个记录了每个logged blocks的扇区号的数组,以及日志块的数量。他如果这个计数是0,那么表示日志中没有事务,如果是非零,那么表示这个日志包含了一个完整已提交的事务,并带有logged blocks的数量。Xv6会在事务提交的时候写入header block,在复制logged blocks到文件系统之后把计数设置为0 。因此一个事务中间的崩溃将会导致日志的header block的计数为0;一个提交之后的崩溃会导致一个非零的计数。

每个系统调用的代码需要明确一系列写入操作的起始和结束,因为崩溃,这个必须是原子化的。为了使得不同进程并发执行文件系统操作,日志系统可以累积多个多个系统调用的写入操作到一个事务中。因此单个提交可能会包含多个完整的系统调用写入操作。为了避免把一个系统调用拆分到不同的事物,日志系统只能在没有系统调用执行的时候才能提交。

一种一次提交多个事务的方式叫做group commit。Group commit降低了磁盘操作的数量,因为它在多次操作中平摊了固定的提交消耗。Group commit也使得磁盘系统写的并发度更高,可能会使得可以在一次磁盘旋转中写入它们。Xv6的virtio driver不支持这种批处理,但是xv6的文件系统设计是支持的。

Xv6给日志在disk上划分了固定的空间。一次事务的系统调用所写入的block的数量必须服从这个空间大小。这回又两个后国。没有单个系统调用会被允许写入大小超过日志空间的不同块。这对于很多的系统调用来说并不是问题,但是又两个系统调用是由可能写入很多的块的:writeunlink。大文件的写入可能会写入很多的数据块和位图块,inode块也一样;unlinking一个大的文件也可能会写入很多的位图块,以及一个inode块。Xv6的write系统调用会把大的写入操作分拆成多个小的写入,来适应日志系统,unlink不会导致这种问题,因为xv6在实际中只有一个位图块。另外一个由有限的日志空间引发的问题是日志系统不会允许一个系统调用发生,除非它很确定这个系统调用的写入是符合日志空间的剩余量的。

8.6 Code: logging

在系统调用中,一个日志的典型用法是如以下:

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

begin_op会等待到日志系统目前没有提交,并且由足够的空间来存放本次调用的写入内容。log.outstading记录了已经保存到日志空间的系统调用次数;总的已保存空间大小是 log.outstanding * MAXOPBLOCKS。增加log.outstading既记录空间,又避免在本次系统调用中发生提交。这部分代码保守地假设每个系统调用可能会写入MAXOPBLOCKS不同的块。

log_write类似于bwrite的代理。它在记录了该块在内存中的扇区号,并保存在了日志的一个槽上,并且通过增加这个buffer的引用来避免这个缓存块被丢弃。这个块必须在缓存中待到提交:只有这样,缓存的副本是唯一的修改;着不能被写入到其对应的磁盘位置,直到提交;在同一个事务中,其他的读取必须能看见这个修改。log_writee会发现在一次事务中当一个块被多次写入,并且把该块分配到同一个槽中。这个优化经常被称作“吸收”。比如,经常会发生在一次事务中,某个包含多个文件的inode的磁盘块会多次被写入。通过把几次磁盘写入到一个块上面,文件系统可以节省很多的日志空间,并且又更好的性能,因为只有一个一个磁盘副本需要被写入。

end_op会先减少outstanding系统调用计数。如果计数为0,那么通过调用commit() 提交本次的事务在这个过程中又四个阶段。*write_log()*会把所有存在buffer中的被修改过的块拷贝到日志空间的相应位置。write_head()修改header block:着是一个提交的节点,如果在这次写入的时候崩溃了,会导致恢复的时候重新执行写入的内容。install_trans从日志空间中读取所有的块,并写入文件系统相应位置。最后end_op把header block的计数改为0;着必须发生在下一次事务开始之前,因此崩溃并不会导致恢复中用到了之后事务的logged blocks。

recover_from_loginitlog中被调用,而这又在启动阶段在第一个程序运行前的fsinit中被调用。这会读取日志的header block,并且在模拟end_op中相应的行为,如果header表明log包含了一个完全提交的事务。

一个日志使用的例子在filewrite中。这个事务类似于以下:

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

这部分代码被包装在一个循环里,这会把一个大量数据的写入操作拆分成一些扇区的单独的事务,来避免日志空间的过载。writei的调用会写入很多的块,作为事务的一部分:文件的inode,一个或多个位图块,以及一些数据块。

8.7 Code: Block allocator

文件和目录内容被存在磁盘块上面,而这必须从空闲的内存池中被分配。xv6的块分配器在磁盘上用一个磁盘块维护了一个空闲的位图。位为0表示这个相关的块是空闲的;位为1表示该块正在被使用。程序mkfs设置了包括启动部分,超级块,日志块、inode块以及位图的相关位。

block allocator提供了两种函数:balloc会分配一个新的空闲磁盘块,而bfree会释放一个块。Balloc中的循环会考虑从0开始到sb.size所代表的所有块。它会寻找一个对应位图位为0的块,这表明这个块是空闲的。如果balloc发现了这样的一个块,他会更新位图,并返回这个块。为了效率,循环会分成两个部分。外部的循环读取位图的每个块,内部循环在一个位图块中检查所有BPB位。如果两个进程同时尝试分配块,那么竞争就会发生,但buffer cache每次只能由一个进程使用,这可以使得每个位图块同时只能被一个进程所使用。

Bfree找到对应的位图块,并清除对应的位。breadbrelse的独占使用可以避免显式地加锁。

就如本章剩下部分中会讲解到的很多代码中,ballocbfree必须在一次事务中被调用。

8.8 Inode layer

属于inode由两种相关的含义。它可能指向一个磁盘上面的数据结构,该数据结构包含了文件的大小以及一系列数据块号。或者“inode”可能会指向一个内存中的inode,它包含了一个磁盘上的inode的副本,以及一些kernel需要的其他信息。

磁盘上的inode被包装到一个连续地磁盘空间,叫做inode blocks。每个inode都同样大小,所以给定一个数n,在磁盘上找到相应的inode非常简单。事实上,这个数n被叫做i-number,这正是确定一个inode的原因。

磁盘上的inode被定义为一个struct dinode。type域是用来区分文件,目录以及特殊的文件(devices)。如果type值为0,则表示这个磁盘上的inode是空闲的。为了直到什么时候需要释放磁盘上的inode和它的数据块,nlink域记录了有多少个目录项引用到了这个inode。size域表明了这个文件内容所占用的空间大小。addrs数组记录了这个文件内容所存放的磁盘块号。

内核会在内存中保存一系列活跃的inode;struct inode 是磁盘上的数据结构 struct dinode的一个内存中的副本。只有当一个C指针指向一个inode,内核才会把它存在内存中。ref域记录了指向这个内存中的inode的C指针数,如果该引用数为0,内核就会丢弃它。igetipot函数会申请和释放指向inode的指针,修改其引用计数。指向inode的指针可以来自于文件描述符,目前的工作目录,或者暂时的内核代码,比如说exec

xv6的inode代码中,有4中锁或者类似锁的机制。icache.lock用来保护不变性1,一个inode最多在缓存中存放一个,不变性2,一个被缓存的inode的ref域记录了内存中的指向该inode的指针数。每个内存中的inode有一个lock域,包含了一个sleep-lock,这用来确保了对于inode域(比如文件长度),以及inode对应的文件以及目录所对应的内容块的独占使用。如果一个inode的ref大于0,那么系统就会把这个inode存放在cache中,并且不会把隔着cache块用于其他的inode。最后,每个inode都包含一个nlink域(如果它被缓存了,从磁盘上拷贝到了缓存中),这记录了指向这个inode的目录项数量;如果一个inode的连接数大于0,那么xv6不会释放它。

iget()返回的struct inode的指针在iput调用之前都被保证是合法的;这个inode不会被删除,并且被该指针指向的空间也不会被其他的inode使用。iget()提供了一个对于inode的非互斥访问,所以可以有很多的指针指向相同的inode。很多文件系统的代码都依赖于iget(),为了长期持有对于inodes的引用(如打开文件或者当前的工作目录),和为了在当避免死锁的代码中使用了很多的inode时(比如路径查出)避免竞争。

iget()返回的struct inode可能没有含有任何有用的信息。为了确保它含有磁盘inode的副本,必须调用ilock。这会给inode加锁(所以没有其他的进程可以再ilock)然后从磁盘上面读取inode,如果之前没有读取过的话。iunlock会释放在inode上面的锁。用锁来拆分对于inode指针的获取可以避免某些情况的死锁,比如再目录查找中。多个程序能够通过*iget()*来获得返回的C指针,但在同一时间,只有一个进程可以给这个inode上锁。

inode cache只会缓存那些会内核代码或者数据结构用C指针引用的inode。主要的工作是同步多进程的访问;缓存是第二部分。如果一个inode被频繁使用,如果这个inode没有在cache中,那么buffer cache 将会把它放入内存中。inode cache是write-through的,这意味着修改一个缓存的inode必须立即用iupdate写入磁盘。

8.9 Code: Inodes

为了分配一个新的inode(比如新建一个文件),xv6调用iallocIalloc类似于balloc:它在磁盘上inode数据结构中循环,一次一个块,查找一个被标记为空闲的块。找到之后,它会通过修改type域,来声明,然后从inode cache中通过*iget()*返回一个条目。ialloc的正确性源于这样的一个事实,在一个时间点只能由一个进程可以持有bp的引用:ialloc可以确保其他的进程不会同时发现这个inode是可以获得的并尝试去声明。

Iget从inode cache中寻找一个带有所需的device和inode号的活跃的条目(ip->ref > 0)。如果找到了就会返回一个对这个inode的引用。随着iget扫描,它会记录第一个为空的槽,如果需要分配新的缓存条目的话就会用到它。

在读写元数据或者内容之前,必须通过ilock把这个 inode 加锁。Ilock为此使用了sleep-lock。一旦ilock独占了这个inode,那么如果有需要,它就可以从磁盘(更有可能是buffer cache)中读取这个inode。iunlock会释放这个sleep-lock,这可能会导致其他进程从沉睡中苏醒。

Iput会释放指向inode的C指针,通过减少引用计数。如果这是最后的引用,那么inode cache中的这个槽会被释放,并且可以被其他的inode使用。

iput释放inode的锁协议值得仔细研究。其中一个危险是并发线程可能会等待在ilock(比如读一个文件内容或者列出目录项),并且不会发现这个inode不再被分配。这不会发生,因为如果一个系统调用没有连接到它,并且ip->ref是1,它是不会得到指向这个被缓存的inode的指针的。iput确实会在icache.lock的临界区外面检查引用计数,但是此时的连接计数是0,所以没有其他的线程会尝试获取一个新的引用。另外一个主要的危险是并发调用ialloc可能会选择到iput释放的相同的inode。这只能发生在iupdate写入到disk之后,所以inode的type = 0.这个竞争是良性的;在读写这个inode之前(此时以及完成iput),分配线程将会等待获取目标inode的sleep-lock。

*iput()会写入到磁盘上。这意味着任何文件系统调用都会写入到磁盘,因为系统调用可能是对这个文件的最后一个引用。即使想read()这样的只读的系统调用也可能是会最后调用iput()*的。这意味着只读的系统调用也可能会被包装到事务中。

*iput()*和崩溃之间有一个挑战。当一个文件的连接数变为0时,*iput()*不会立即截断文件,因为某些进程可能仍然在内存中持有一个对该inode的引用:一个进程可能仍然在读写这个文件,因为成功打开了它。但是,如果崩溃发生在最后一个进程关闭其文件描述符时,这个文件将被标记为分配在了磁盘上,但是没有目录项指向它。

文件系统有两种方式处理这种情况。简单的解决办法是在重启恢复中,文件系统扫描整个文件系统,找到那些被分配了,但是目录项连接数为0的文件,并释放它们。

第二种方式不需要扫描整个文件系统。在这个方案中,文件系统把那些连接数为0,引用数不为0的inode号记录在磁盘上(比如说在超级块上)。如果文件系统把一个引用计数为0的文件删除了,那么他会在磁盘列表上更新这个inode。在恢复的时候,文件系统会把所有在这个列表上的文件都释放。

Xv6一个方法都没有实现,这意味着那些可能不会再被使用的,也会被标记为已分配的inode。这意味着xv6可能会有超出磁盘空间的风险。

8.10 Code: Inode content

磁盘上的inode数据结构,struct dinode,包含了大小和一个关于块号的数据(见图8.3)。inode的数据存放在 dinode的addrs数组所标记的块中。前NDIRECT块数据是被列在数组前NDIRECT个表项中;这部分块被称作直接块。下面NINDIRECT块数据不被列在inode里,而是在一个数据块中,这被称作间接块。addrs数组中的最后一项给出了间接块的地址。因此前12KB(NDIRECT * BSIZE)字节的数据可以被放在inode所列出部分,余下的256KB(NINDIRECT x BSIZE)字节数据只能被加载到简介块上面。这对于磁盘表示来说是简单的,但是对于用户来说却是复杂的。函数bmap会处理这种表示,所以高层的程序比如说readiwritei是简短的。Bmap会返回这个inode ip的第bn个块的磁盘块号。如果ip没有这样一个block,那么bmap会分配一个。

函数bmap从处理简单情况开始:前NDIRECT块会被列在inode中。剩下的NINDIRECT块会被列在位于ip->addrs[NDIRECT]的间接块中。Bmap会读取非简介快,然后再读取一个正确位置的数据块。如果这个块号大于NDIRECT +NINDIRECT,bmap会panic;writei包含了这个检查来避免panic的发生。

bmap在有需要的时候会分配块。ip->addrs[]或者间接块的条目为0表示没有块被分配。如果bmap遇到了0,那么它会给这部分分配空闲块。

itrunc释放文件的块,重新设置inode的大熊奥为0.Itrunc先释放直接块,然后是被列入间接块中的数据,最后是间接块本身。Bmap使得readiwritei获取一个inode的数据很简单。Readi先确保偏移和数量不会超出文件的范围。读的起点超过了范围会返回一个error,如果起始点在范围内,结尾超出了,那么会返回部分数据。主循环处理每个文件的块,从buffer中复制数据到dst中。writeireadi一样,会有三个部分:超出界限;循环拷贝;如果写的超出了文件范围,那么writei必须更新文件的大小。

readiwritei都从检查ip->type == T_DEV开始。这个情况会处理特殊的设备,它的数据不在文件系统中;我们将在文件描述符层来介绍这部分。

函数stati复制inode的元数据到 struct stat中,这通过系统调用stat暴露给用户使用。

8.11 Code: directory layer

目录的内部实现很像文件。它的inode的type是T_DIR,它的数据是一系列目录项。每个项都是一个struct dirent,包含了文件名和一个inode号。名字最长为DIRSIZ(14)个字符,如果比这要短的话,就用NUL(0)截断。目录项的 inode如果是0,就表示这个项为空。

函数dirlookup在一个目录中查找一个给定名字的目录项。如果找到了,就会返回一个相关的不上锁的inode的指针,并且把poff设置为这个目录项在目录中的偏移量,如果调用者想要修改它的话。如果dirlookup找到了对应名称的目录项,他会更新poff并且通过inode返回一个不上锁的inode。dirloopupiget返回不上锁的inode的理由。调用者已经给dp上了锁,所以如果这次查找是针对于目前目录的别名,在返回前尝试去给inode上锁会尝试去重新给dp上锁,然后死锁。(会有很多复杂的死锁情景)调用者能够解锁dp然后给ip上锁,来确保一次只持有一个锁。

函数dirlink会在目录dp里写入一个给定名称和inode号的新的目录项。如果这个名字已经存在,那么dirlink会返回一个error。主循环会从目录项中找到一个未被分配的目录项。如果找到了,就会停止循环,带着被置为可得到的目录项的偏移量off。否则,循环结束会把off设为dp->size。无论哪种方式,dirlink会通过偏移量off,给目录添加一个新的目录项。

8.12 Code: Path names

路径名查找和一系列的dirlookup相关,每个路径组成都会调用一次。Namei分析了path并且返回相应的inode。函数nameiparent是变化的:它会在最后一个元素前停止,返回父目录的inode,并且拷贝最后一个元素到name里面。两个函数调用的主要工作都是函数namex完成的。

Namex开头先决定从哪里开始路径分析。如果路从从一个斜杠开始,分析就从根目录开始;否则,就从当前目录开始。然后它会使用skipelem按顺序考虑每一个路径元素。每次循环的迭代都必须寻找name在当前的inode ip中。这个迭代从给ip上锁开始,然后检查它是否是一个目录。如果不是,那么查找就失败了。(给ip上锁的必要性不是来自于ip->type会被改变,事实上并不会,而是因为ip->type不能被保证已加载进了磁盘,直到ilock运行。如果这个调用是nameiparent为真,并且这是最后的路径元素,,那么这个循环会提早停止,因为nameiparent的定义;最后的路径元素已经被复制进了name,所以namex只需要返回这个没上锁的ip就行了。最后,这个循环通过dirlookup来查找路径元素,并且通过设置ip = next 为下次迭代做准备。当循环运行出了路径元素,这会返回ip。

namex程序可能会花费很长时间去完成:这回牵涉到几个磁盘操作以读取路径名中所遍历的目录的索引节点和目录块(如果它们没有被进入缓存)。xv6被精心设计,所以如果一个内核线程被阻塞在了磁盘IO,另外的线程还是可以并发的查找一个不同的路径名。Namex会给不同的目录分别上锁,所以查询是可以并行的。

这种并发性会引入一些挑战。比如,当一个内核线程正在查询一个路径名,而另一个内核线程可能会通过取消一个目录的连接来改变目录树。一个潜在的危险是一个查找可能会一个已经被其他线程删除的目录,并且它的磁盘块已经被其他的目录或者文件重新使用了。xv6避免了这样的竞争。比如,在namex中执行dirlookup时,查找线程会持有目录的锁,并且dirlookupiget返回的inode。Iget会增加这个inode 的引用。只有从dirlookup中接收到了这个inode,namex才会释放这个目录的锁。现在另外的线程可能会从目录中unlink这个inode,但是xv6不会删除这个inode,因为它的引用计数仍然是非0的。

另外一个风险是死锁。比如,当查找“.”的时候,next指针和ip指向相同的inode。在释放ip的锁之前,给next加锁会导致死锁。为了避免这个死锁,namex会在得到next的锁之前,释放掉目录的锁。现在我们知道了为什么igetilock的拆分如此重要了。

8.13 File descriptor layer

一个Unix接口中很酷的方面是大多数Unix中的资源都被表示成了文件,包括设备,比如说像控制台,管道,当然还有真实的文件。文件描述符层实现了这种统一性。

Xv6给了每个进程自己的一个打开文件表,或者文件描述符,就像我们在第一章中看到的那样。每个打开的文件都被表示成了struct file,这把一个inode或者pipe加上一个IO偏移量包装了。每次调用open会创建一个新的打开文件(一个新的struct file):如果多个进程独立地打开了相同地文件,那么它们会有不同的偏移量。另一方面,一个单独的打开文件可以在一个进程的文件表中多次出现,也可以在多进程的文件表中多次出现。如果一个进程使用open打开文件,并且使用dup创建了一个别名,或者通过fork和子进程分享同一个打开文件的时候会出现。一个引用计数就记录对于特定打开文件的引用数。一个文件可以被打开读写。readable和writeable域决定了相关性质。

在系统中的所有打开文件会被记录在一个全局的文件表中,ftable。这个文件表拥有可以分配文件的函数(filealloc),创建重复引用的函数(filedup),释放引用的函数(fileclose),以及读写数据的函数(fileread和filewrite)。

filealloc会扫描整个文件表来找到一个未被引用的文件(f->ref == 0)并且返回一个新的引用;filedup会增加以哦你用计数;fileclose会降低引用计数。当一个文件的引用计数为0的时候,fileclose会释放掉它。

函数filestat、fileread、filewrite是实现了stat、read、write的文件操作。filestat只允许使用在inode上,并且调用stati.Fileread.filewrite根据打开模式检查是否被允许,然后传递调用给管道或者inode实现。如果文件代表一个inode,filereadfilewrite会用IO偏移作为操作的偏移量,并且更新它。管道没有偏移的概念。重新调用这些函数需要调用者处理锁的问题。inode的锁机制使得读写的偏移更新原子化,所以多个进程并发地写入同一个文件不会覆盖掉其他用户地数据,虽然它们的数据可能会交织在一起。

8.14 Code: System calls

大多数使用了低层提供的函数实现的系统调用是微不足道的。但是这里有几个调用值得我们仔细去推敲。

sys_linksys_unlink会修改目录,并创建或者移除对于inode的引用。它们是事务作用的另一个好的例子。sys_link先获取它的参数,old和new两个字符串。假设old存在且不是文件夹,sys_link会增加ip->nlink计数。然后sys_link调用nameiparent找到父目录以及new的最后的路径元素,并i企鹅船舰一个新的文件想指向整个old的inode。新的父目录必须存在且需要和old在同一个设备上:只有在同一个设备上inode号才是独一无二的。如果有错误发生,那么sys_link必须返回,并且小勺ip->nlink。

事务简化了这个实现,因为它要求更新多个块,但是我们不需要担心这样做的顺序。它们会全部成功或者都失败。比如,没有事务,在创建link之前更新ip->nlink会使得文件系统暂时进入不安全的状态。有了事务我们就不需要担心这个问题了。

sys_link为一个已存在的inode创建了一个新的名字。函数create回味一个新的inode创建一个新的名字。它是三种文件创建系统调用的一般化:带有O_CREATE标记的open会创建一个新的原始的文件,mkdir会创建一个新的目录,mkdev会创建一个新的设备文件。像sys_linkcreate先调用nameiparent来获取对应inode的父目录。然后调用dirlookup来检查是否名字已经存在。如果该名字没有存在,呢么create的行为将会取决于是哪个系统调用:openmkdir以及mkdev有着不同的语义。如果create是代表open(type == T_FILE),并且这个名字已经作为一个正常的文件存在,那么open会把这个视为成功,所以create也同样会这样。否则,这就是个错误。如果这个名字没有存在,create现在会用ialloc分配一个新的inode。如果新的inode是一个目录,create将会为它处理话 ”."和“…"两个目录项。最后数据已经初始化完毕,create会把它与父目录link。create,像sys_link,同时持有两个inode的锁:ip和dp。这不会产生死锁,因为inode ip是新分配的:没有其他的进程会持有ip的锁,并且申请dp的锁。

利用create,实现sys_open、sys_mkdir、sys_mknod就会非常简单。sys_open是最复杂的,因为创建一个新的文件是它做的很小的一部分。如果open被传入了O_CREATE标志,那么他会调用create。否则他会调用nameicreate返回一个上锁的inode,但是namei不会,所以sys_open必须自己给这个inode上锁。这使得检查打开的目录是只被用于读取而不是写入的很方便。假设这个inode被某种方式得到,sys_open会分配一个文件,以及一个文件描述符,然后填充这个文件。注意没有其他任何进程能够获取这个部分被初始化地文件,因为它只存在于当前的进程的文件表中。

第七章在文件系统之前说明了管道的实现。函数sys_pipe通过创建一个管道对连接了文件系统。它的参数是一个指向两个integers的指针,这记录了两个新的文件描述符。然后他会分配管道,并且设置文件描述符。

8.15 Real world

在真实的OS中,buffer cache的复杂性是远大于xv6的,但是他又两个相同的目标,缓存和同步对于磁盘的访问。xv6的buffer cache,像V6一样,使用了一个简单的最久未使用的遗弃策略。;有很多更加复杂的策略可以实现,每个策略对于部分指标很好,但是对于其他的就不行了。一个更加有效的LRU缓存会删除链表,而用一个哈希表来查找,一个堆来实现LRU的一起。现代buffer cache通常继承了虚拟内存系统来支持文件的内存映射。

Xv6的日志系统是效率低下的。一个提交不能和文件系统调用并发执行。这个系统会记录整个块,即使只改变了一个块中的很小的字节。它会有长时间的日志写入,一次写入一块,每个都可能需要磁盘转一圈的时间。真实的日志系统解决了这些问题。

日志不是唯一的方式来提供崩溃恢复。早先的文件系统在重启中使用 scavenger(比如UNIX的fsck程序)来检查所有的文件和目录和块和inoe的空闲列表,寻找并处理不一致性。Scavenging可能会为大的文件系统花费数个小时,也有可能遇到这样的情况,在原始系统调用原子化的条件下解决不一致性。日志恢复比它快得多,并且使得面对崩溃的时候系统调用是原子化的。

Xv6和早先的UNIX一样使用相同的基本inode和目录的磁盘布局;多年来,这种形式非常持久。BSD的UFS/FFS和Linux的ext2/ext3使用了相同的数据结构。文件系统布局中最低效的部分是目录,每次查找中都要求堆磁盘块有一个线性的扫描。这对于仅仅含有几个磁盘块的目录是非常合理的,但是对于拥有很多文件的目录来说开销就太大了。微软的WIindows的NTFS和Mac OS的HFS还有Solaris的ZFS都是西安了一个磁盘上的块的平衡树。这是复杂的但是保证了目录查找只需要对数时间即可。

Xv6对于磁盘操作的失败处理是很朴素的:如果一个磁盘操作失败,那么xv6panic。这是否合理依赖于硬件:如果一个操作系统在特殊的硬件之上,硬件使用额外的部分来标记磁盘错误,可能操作系统发现错误不太发生,那么panic是ok的。另一方面,使用简单的磁盘的操作系统会遇见这些失败,并且更加优雅地处理它们,所以一个文件中的一个块的丢失不会影响其他文件系统的使用。

Xv6要求文件系统符合一个磁盘设备,并且不能改变它的大小。随着数据库和多媒体文件驱动着存储空间更大,操作系统发展出了一些方式来小取一个磁盘一个文件系统的瓶颈。最基本的方法是把很多磁盘集合成一个逻辑磁盘。硬件处理方式,比如RAID,仍然是最流行的,但是目前的趋势是在软件中实现更多的逻辑磁盘。这些软件的实现通常允许更多的应用,比如说增长或者邪恶见逻辑设备,通过增加或者移除磁盘。当然,可以增长或者缩减的存储层需要一个可以完成同样任务的文件系统:xv6中固定大小的inode数组块在这种环境中是不能很好工作的。从文件系统拆分磁盘管理可能回收最干净的设计,但是复杂的接口会使得某些系统去结合它们,比如说Sun的ZFS。

Xv6的文件系统缺少很多其他现带文件系统的特征;比如,缺少对快照还有增量备份的支持。

现代Unix系统允许很多种类的资源被相同的系统调用获得。有名管道,网络连接,远程访问网络文件系统,还有监控以及像/proc的控制接口。不同于xv6中的filereadfilewrite中的if声明,这些系统通常会给每个打开文件一个函数指针表,每个操作对应一个指针,通过调用函数指针来激发对应的操作。网路文件系统和用户级文件系统提供了通过RPC传输的调用,并且在返回前等待回应。

你可能感兴趣的:(翻译)