15.8 File system challenges

前面说到XV6的文件系统有一定的复杂性,接下来我将介绍一下三个复杂的地方或者也可以认为是三个挑战。

第一个是cache eviction。假设transaction还在进行中,我们刚刚更新了block 45,正要更新下一个block,而整个buffer cache都满了并且决定撤回block 45。在buffer cache中撤回block 45意味着我们需要将其写入到磁盘的block 45位置,这里会不会有问题?如果我们这么做了的话,会破坏什么规则吗?是的,如果将block 45写入到磁盘之后发生了crash,就会破坏transaction的原子性。这里也破坏了前面说过的write ahead rule,write ahead rule的含义是,你需要先将所有的block写入到log中,之后才能实际的更新文件系统block。所以buffer cache不能撤回任何还位于log的block。

前面在介绍log_write函数时,其中调用了一个叫做bpin的函数,这个函数的作用就如它的名字一样,将block固定在buffer cache中。它是通过给block cache增加引用计数来避免cache撤回对应的block。在之前(注,详见14.6)我们看过,如果引用计数不为0,那么buffer cache是不会撤回block cache的。相应的在将来的某个时间,所有的数据都写入到了log中,我们可以在cache中unpin block(注,在15.5中的install_trans函数中会有unpin,因为这时block已经写入到了log中)。所以这是第一个复杂的地方,我们需要pin/unpin buffer cache中的block。

第二个挑战是,文件系统操作必须适配log的大小。在XV6中,总共有30个log block(注,详见14.3)。当然我们可以提升log的尺寸,在真实的文件系统中会有大得多的log空间。但是无所谓啦,不管log多大,文件系统操作必须能放在log空间中。如果一个文件系统操作尝试写入超过30个block,那么意味着部分内容需要直接写到文件系统区域,而这是不被允许的,因为这违背了write ahead rule。所以所有的文件系统操作都必须适配log的大小。

为什么XV6的log大小是30?因为30比任何一个文件系统操作涉及的写操作数都大,Robert和我看了一下所有的文件系统操作,发现都远小于30,所以就将XV6的log大小设为30。我们目前看过的一些文件系统操作,例如创建一个文件只包含了写5个block。实际上大部分文件系统操作只会写几个block。你们可以想到什么样的文件系统操作会写很多很多个block吗?是的,写一个大文件。如果我们调用write系统调用并传入1M字节的数据,这对应了写1000个block,这看起来会有很严重的问题,因为这破坏了我们刚刚说的“文件系统操作必须适配log的大小”这条规则。

让我们看一下file.c文件中的file_write函数。

从这段代码可以看出,如果写入的block数超过了30,那么一个写操作会被分割成多个小一些的写操作。这里整个写操作不是原子的,但是这还好啦,因为write系统调用的语义并不要求所有1000个block都是原子的写入,它只要求我们不要损坏文件系统。所以XV6会将一个大的写操作分割成多个小的写操作,每一个小的写操作通过独立的transaction写入。这样文件系统本身不会陷入不正确的状态中。

这里还需要注意,因为block在落盘之前需要在cache中pin住,所以buffer cache的尺寸也要大于log的尺寸。

最后一个要讨论的挑战是并发文件系统调用。让我先来解释一下这里会有什么问题,再看对应的解决方案。假设我们有一段log,和两个并发的执行的transaction,其中transaction t0在log的前半段记录,transaction t1在log的后半段记录。可能我们用完了log空间,但是任何一个transaction都还没完成。

现在我们能提交任何一个transaction吗?我们不能,因为这样的话我们就提交了一个部分完成的transaction,这违背了write ahead rule,log本身也没有起到应该的作用。所以必须要保证多个并发transaction加在一起也适配log的大小。所以当我们还没有完成一个文件系统操作时,我们必须在确保可能写入的总的log数小于log区域的大小的前提下,才允许另一个文件系统操作开始。

XV6通过限制并发文件系统操作的个数来实现这一点。在begin_op中,我们会检查当前有多少个文件系统操作正在进行。如果有太多正在进行的文件系统操作,我们会通过sleep停止当前文件系统操作的运行,并等待所有其他所有的文件系统操作都执行完并commit之后再唤醒。这里的其他所有文件系统操作都会一起commit。有的时候这被称为group commit,因为这里将多个操作像一个大的transaction一样提交了,这里的多个操作要么全部发生了,要么全部没有发生。

学生提问:group commit有必要吗?不能当一个文件系统操作结束的时候就commit掉,然后再commit其他的操作吗?

Frans教授:如果这样的话你需要非常非常小心。因为有一点我没有说得很清楚,我们需要保证write系统调用的顺序。如果一个read看到了一个write,再执行了一次write,那么第二个write必须要发生在第一个write之后。在log中的顺序,本身就反应了write系统调用的顺序,你不能改变log中write系统调用的执行顺序,因为这可能会导致对用户程序可见的奇怪的行为。所以必须以transaction发生的顺序commit它们,而一次性提交所有的操作总是比较安全的,这可以保证文件系统处于一个好的状态。

最后我们再回到最开始,看一下begin_op,

首先,如果log正在commit过程中,那么就等到log提交完成,因为我们不能在install log的过程中写log;其次,如果当前操作是允许并发的操作个数的后一个,那么当前操作可能会超过log区域的大小,我们也需要sleep并等待所有之前的操作结束;最后,如果当前操作可以继续执行,需要将log的outstanding字段加1,最后再退出函数并执行文件系统操作。

再次看一下end_op函数,

在最开始首先会对log的outstanding字段减1,因为一个transaction正在结束;其次检查committing状态,当前不可能在committing状态,所以如果是的话会触发panic;如果当前操作是整个并发操作的最后一个的话(log.outstanding == 0),接下来立刻就会执行commit;如果当前操作不是整个并发操作的最后一个的话,我们需要唤醒在begin_op中sleep的操作,让它们检查是不是能运行。

(注,这里的outstanding有点迷,它表示的是当前正在并发执行的文件系统操作的个数,MAXOPBLOCKS定义了一个操作最大可能涉及的block数量。在begin_op中,只要log空间还足够,就可以一直增加并发执行的文件系统操作。所以XV6是通过设定了MAXOPBLOCKS,再间接的限定支持的并发文件系统操作的个数)

所以,即使是XV6中这样一个简单的文件系统,也有一些复杂性和挑战。

最后让我总结一下:

这节课讨论的是使用logging来解决crash safety或者说多个步骤的文件系统操作的安全性。这种方式对于安全性来说没有问题,但是性能不咋地。

学生提问:前面说到cache size至少要跟log size一样大,如果它们一样大的话,并且log pin了30个block,其他操作就不能再进行了,因为buffer中没有额外的空间了。

Frans教授:如果buffer cache中没有空间了,XV6会直接panic。这并不理想,实际上有点恐怖。所以我们在挑选buffer cache size的时候希望用一个不太可能导致这里问题的数字。这里为什么不能直接返回错误,而是要panic?因为很多文件系统操作都是多个步骤的操作,假设我们执行了两个write操作,但是第三个write操作找不到可用的cache空间,那么第三个操作无法完成,我们不能就直接返回错误,因为我们可能已经更新了一个目录的某个部分,为了保证文件系统的正确性,我们需要撤回之前的更新。所以如果log pin了30个block,并且buffer cache没有额外的空间了,会直接panic。当然这种情况不太会发生,只有一些极端情况才会发生。

Last updated