MIT 6.S081 文件系统的崩溃恢复
目录
崩溃恢复机制简述
文件系统的崩溃
logging机制
在XV6操作系统中的实践
挑战
崩溃恢复机制简述
文件系统的崩溃
由于操作系统在执行任务时不可避免的遇到突然断电、磁盘受损等突发情况导致操作系统的崩溃。但文件系统中的数据是永久存在的,如果不做任何的措施会导致文件系统中的数据存在不一致、不正确的状态,这种状态是危险的。
logging机制
对于文件系统的突然崩溃,我们在重启操作系统时需要对文件系统进行恢复,使其能够满足数据的一致性、正确性。最笨的方法就是遍历一遍文件系统所有的文件,包括但不限于inode节点的情况,bitmap块的情况,各个数据块的情况,查看目前信息是否相互匹配。但显而易见的是这种方法时间复杂度太高难以接受。
此时,数据库技术中借鉴的日志恢复技术可以解决这个问题(都是对于数据的一致性保证,这点在数据库和操作系统的目的是一致的)。总的可以分为以下四步:
首先你对文件系统做的任何操作都不直接操控文件系统进行修改,而是先写在日志中,当一些列操作(需要原子性质的操作,即这一系列操作要么同时发生要么都不发生)全部完成时,就将这些操作进行提交。提交之后,操作系统才真正的按照日志中的信息对文件系统进行修改,这一步叫做安装。最后当日志中的所有内容安装完毕时,清空日志内容。
这是正常运行时的操作,当操作系统重启时,对于文件系统而言,首先检查日志中有无提交的标识,若有则重新执行日志中的所有操作,也就是安装。若无提交标识则清空日志。
我们依次考虑崩溃发生的每个时期,看有无影响。首先若发生在写日志阶段或者写完日志未提交,这时重启以后由于没有提交标识,会忽略日志中的内容,对文件系统无影响(因为原本也没有对文件系统进行修改);当发生在提交之后(提交可以理解未一个原子操作,因为他就是一个写下一个标识,在操作系统层面,向某个磁盘某个扇区写,可以看作原子操作,所以崩溃不会说发生在提交过程中,只会发生在提交前或者提交后)或正在安装,这时重启之后由于已经有提交标识,所以会将日志中的所有操作重新安装,由于这里只有写操作(这里只考虑写操作,其他的文件操作可以通过这些简单的基本操作组合而成或者需要更复杂的机制来保障),所以他是幂等的,也就是无论执行多少次结果相同,所以这里对所有日志操作重新安装是没问题的;最后发生在安装后,清理日志前,这个和上面同理。
可以发现,通过日志的操作可以使一系列文件操作具备原子性,要么全部发生要么全部不发生,这有效的保证了文件系统中数据在崩溃前后的一致性。实际我们把这种一系列操作一起的机制叫做事务。一个事务是具备原子性的,当然这种结构使很简单的,当然存在更为复杂和高效的崩溃恢复机制去保障数据的一致性。
在XV6操作系统中的实践
在XV6的实践中,事务的开始用begin_op(),结束采用end_op(),也就是说在end_op()前所作的所有文件操作都不会真正的实施到文件系统中。首先看一下begin_op的代码,如下:
// called at the start of each FS system call. //在关于文件的系统调用前都需要调用
void
begin_op(void) //事务开始的函数,确保有足够的资源才可以开始
{acquire(&log.lock); //先获取锁while(1){if(log.committing){ //有事务正在提交sleep(&log, &log.lock); //睡眠等待} else if(log.lh.n + (log.outstanding+1)*MAXOPBLOCKS > LOGSIZE){ //日志空间容量不够// this op might exhaust log space; wait for commit.sleep(&log, &log.lock); //睡眠等待} else {log.outstanding += 1; //正在进行的事务个数++ release(&log.lock); //释放锁break; //循环等待}}
}
可以发现仅当没有事务正在提交且当前日志空间足够时才可以开始一个事务,原则上仅允许一个事务同时提交(如果同时有多个事务提交,突然的崩溃会让恢复变得很复杂)。
接下来以sys_open()为例,继续追踪事务的过程,从以下代码可以看到任何关于文件系统的修改都需要包装在事务中。
begin_op();if(omode & O_CREATE){ip = create(path, T_FILE, 0, 0);if(ip == 0){end_op();return -1;}} else {if((ip = namei(path)) == 0){end_op();return -1;}ilock(ip);if(ip->type == T_DIR && omode != O_RDONLY){iunlockput(ip);end_op();return -1;}}
之后我们跟随其中的create函数,继续追踪create函数中的ialloc函数,如下:
// Allocate an inode on device dev.
// Mark it as allocated by giving it type type.
// Returns an unlocked but allocated and referenced inode.
struct inode*
ialloc(uint dev, short type)
{int inum;struct buf *bp;struct dinode *dip;for(inum = 1; inum < sb.ninodes; inum++){bp = bread(dev, IBLOCK(inum, sb));dip = (struct dinode*)bp->data + inum%IPB;if(dip->type == 0){ // a free inodememset(dip, 0, sizeof(*dip));dip->type = type;log_write(bp); // mark it allocated on the diskbrelse(bp);return iget(dev, inum);}brelse(bp);}panic("ialloc: no inodes");
}
可以发现使用了bread获取了一个块缓存,其中有log_write(bp),我们是先修改了块缓存并且将bp这个快缓存标记到日志中,标识这个块修改了,接下来具体看log_write中的代码,如下:
void
log_write(struct buf *b)
{int i;acquire(&log.lock);if (log.lh.n >= LOGSIZE || log.lh.n >= log.size - 1)panic("too big a transaction");if (log.outstanding < 1)panic("log_write outside of trans");for (i = 0; i < log.lh.n; i++) {if (log.lh.block[i] == b->blockno) // log absorptionbreak;}log.lh.block[i] = b->blockno;if (i == log.lh.n) { // Add new block to log?bpin(b);log.lh.n++;}release(&log.lock);
}
发现,以上代码仅仅是将修改了的块缓存的块号放入了日志中,表明在该事务中,当前块发生了修改。注意,这里最后调用了bpin(b),这个函数的功能是个快缓存b增加一个引用次数,这是为什么呢?原因是当快缓存引用次数为0且空闲缓存不够时,他就有可能被退回到磁盘中去,这是顺便将快缓存中的修改写回到磁盘中,但这样对磁盘写的操作是我们严格控制的,我们不期望在事务结束前发生这样的事情,因此,这里增加了快缓存的引用次数,防止快缓存出现这种写回的情况。
随后是事务结束函数,end_op(),如下:
// called at the end of each FS system call.
// commits if this was the last outstanding operation.
void
end_op(void)
{int do_commit = 0;acquire(&log.lock); //获取日志锁log.outstanding -= 1; //正在进行事务-=1if(log.committing) //不应该事务正在提交panic("log.committing"); if(log.outstanding == 0){ //这是最后一个事务,可以提交do_commit = 1;log.committing = 1;} else {// begin_op() may be waiting for log space,// and decrementing log.outstanding has decreased// the amount of reserved space.wakeup(&log);}release(&log.lock);if(do_commit){ //提交// call commit w/o holding locks, since not allowed// to sleep with locks.commit();acquire(&log.lock);log.committing = 0;wakeup(&log);release(&log.lock);}
}
最终的处理过程是在commit中,代码如下:
static void
commit()
{if (log.lh.n > 0) {write_log(); // Write modified blocks from cache to logwrite_head(); // Write header to disk -- the real commitinstall_trans(0); // Now install writes to home locationslog.lh.n = 0;write_head(); // Erase the transaction from the log}
}
我们依次分析这些过程,首先是write_log,将有修改的缓存块信息写入到磁盘的日志区域,随后write_head将事务的元信息-日志头写入磁盘中,之后根据磁盘中的日志信息修改磁盘中的文件信息,最后清除磁盘头。注意这里和磁盘的交互都是块缓存充当中间层进行操作的。
挑战
在XV6操作系统中存在3个挑战。
1.关于块缓存的退回,如果是脏的需要写回磁盘,在事务中如果发生这种情况会不会导致错误?
这点在log_write中有,事务中会对所有有修改的快缓存进行固定(块缓存引用次数加一),使得在事务结束前不会被退回。
2.由于日志块存在最大数量限制,所以文件操作依次最多操控的块数是否存在限制?
是的,大部分文件操作都会小于磁盘中的日志区域的物理块数,但仍存在一些巨大的文件操作,对于这些操作,需要将很大的文件操作分解成很多事务去进行,只要保证不破坏数据的一致性即可。
3.对于文件操作的并发?
XV6操作系统中通过限制在事务中的数量限制并发数量,并将这些事务打包成组,只有当最后一个事务提交时才统一提交。