xv6源码分析 017
在buffer cache上面的就是logging层了,这一层主要的工作是维持每一个文件系统写入的操作的原子性。什么是原子性?通俗地来讲,原子性可以这样理解,如果一组操作(或者一个操作)在执行的时候不会被其他的操作打断并且,这一组操作只有全部操作完成之后才算作真正的完成;如果其中的一个操作执行出现错误或者异常了,那么前面执行的操作就会回滚(roll back),整一组操作就好像没有执行一样。在文件系统这个场景来看就是,对文件系统不产生任何的影响。
复用一下前面的图,
ok现在我们来看看代码。
在这里先简单说明一下,xv6中的logging并不支持并发,一次只能够提交一个事务(transaction)
logging中的结构体
首先是logheader
struct logheader {
int n;
int block[LOGSIZE];
};
LOGSIZE
:表示一个文件所能够持有的最大的块数block
:用来跟踪文件在内存的日志块
然后是核心数据结构log
struct log {
struct spinlock lock;
int start;
int size;
int outstanding; // how many FS sys calls are executing.
int committing; // in commit(), please wait.
int dev;
struct logheader lh;
};
struct log log;
struct spinlock lock
:锁,不说了int start
:文件的第一个日志块int size
:这个文件对应的日志块的总数int outstanding
:记录在本次事务中正在进行的了文件系统调用的数量int commiting
:表示当前的日志是否正在提交,如果是,那么其他要提交的日志需要等待int dev
:设备的id(外部存储设备)struct logheader lh
:跟踪一个文件所有日志块的引导块(就是上面介绍的)
然后是对应的操作接口
先介绍两个局部函数static void recover_from_log(void)
和static void commit()
static void recover_from_log(void)
这个函数是在提交事务的时候将事务从磁盘的日志存储区写到对应的磁盘块中(从磁盘到磁盘),这样的操作看似有些多余,但是却能够保证文件系统的完整性。
现在假设没有logging,我们的文件系统调用是直接写到磁盘上对应的块上的,这样的io我们称之为随机io,不仅效率低下,而且如果在操作执行到一半的时候突然发生意外,系统崩溃了或者断电了,现在文件系统是不完整,因为操作系统并不知道故障发生之前的操作执行到哪里,也不知道是否全部的操作都已经完成了,更不知道那些操作需要被撤销。
但是有了logging之后,系统在重启之后就能够进行recover,也就是下面这个函数,基于上面的情况,不同的是我们现在有了logging,所以文件系统会先检查日志储存区,将上面的日志重做(redo)一遍,如果发现有的日志没有被提交,文件系统就知道这个操作并没有完成应该撤销(即使操作完成了,但是没有提交一律视作未完成)。这样就能够保证文件系统的完整性了。
而且写日志是顺序io,能够大幅度地减少磁盘写操作的开销。
static void
recover_from_log(void)
{
read_head();
install_trans(); // if committed, copy from log to disk
log.lh.n = 0;
write_head(); // clear the log
}
static void commit()
看名字就知道,这个函数是用来提交事务的。注释很详细。
static void
commit()
{
if (log.lh.n > 0) {
write_log(); // Write modified blocks from cache to log
write_head(); // Write header to disk -- the real commit
install_trans(); // Now install writes to home locations
log.lh.n = 0;
write_head(); // Erase the transaction from the log
}
}
从代码中不难看出,在提交的时候是直接将系统对buffer cache的修改一股脑地写到磁盘(日志存储区)上,然后调用write_head()
真正地提交,写一个尾部标志到日志存储区的最后,这样一段事务的范围就能被确认,如果没有这个尾部,那么在崩溃恢复的时候,文件系统就把它当作未提交。
然后就是安装(install)了,最后将对应的日志清除,,因为已经持久化到磁盘上对应的扇区中了,所以也就没什么用了。
void initlog(int dev, struct superblock *sb)
int dev
:设备idstruct superblock *sb
:超级块,现在可以理解为文件系统层面的引导块,记录了所有文件的i节点的信息。
void
initlog(int dev, struct superblock *sb)
{
if (sizeof(struct logheader) >= BSIZE)
panic("initlog: too big logheader");
initlock(&log.lock, "log");
log.start = sb->logstart;
log.size = sb->nlog;
log.dev = dev;
recover_from_log();
}
static void read_head(void)
从磁盘中读取头部日志块到内存中
log.dev
和log.start
表明了读取一个文件的头部日志块
下面的(struct logheader *)(buf->data)
表示读取了头部日志块中的数据并将其格式化成对应的struct logheader
。
最后是将logheader中的数据加载到log中,
static void
read_head(void)
{
struct buf *buf = bread(log.dev, log.start);
struct logheader *lh = (struct logheader *) (buf->data);
int i;
log.lh.n = lh->n;
for (i = 0; i < log.lh.n; i++) {
log.lh.block[i] = lh->block[i];
}
brelse(buf);
}
static void wrtie_head(void)
过程跟上面的基本相同,只是这个函数是将头部日志块中的数据写到磁盘中。
static void
write_head(void)
{
struct buf *buf = bread(log.dev, log.start);
struct logheader *hb = (struct logheader *) (buf->data);
int i;
hb->n = log.lh.n;
for (i = 0; i < log.lh.n; i++) {
hb->block[i] = log.lh.block[i];
}
bwrite(buf);
brelse(buf);
}
begin_op(void)
这个函数的作用是将我们每一次文件系统调用的操作都进行日志记录。
函数的主体是一个大循环,里面是一个分支判断,我们分别来看看这些分支的作用:
第一个分支log.commit
:如果当前的文件的日志正在提交(文件的事务正在提交),那么这个操作将进行睡眠,直到事务提交成功,因为xv6中没有数据库系统那样复杂的并发控制机制,所以只支持一个文件一次事务提交。
第二个分支log.lh.n + (log.outstanding+1)*MAXOPBLOCKS > LOGSIZE
:如果当前文件的日志的大小已经超过了限制,也需要等待,直到事务提交刷新日志存储区
第三个分支:顺利执行outstanding
递增。
void
begin_op(void)
{
acquire(&log.lock);
while(1){
if(log.committing){
// 1.
sleep(&log, &log.lock);
} else if(log.lh.n + (log.outstanding+1)*MAXOPBLOCKS > LOGSIZE){
// 2.
// this op might exhaust log space; wait for commit.
sleep(&log, &log.lock);
} else {
// 3.
log.outstanding += 1;
release(&log.lock);
break;
}
}
}
void end_op(void)
当一个文件系统调用结束时调用这个函数,(但是并不代表提交)。
这个函数其实也很简单,可能大家在下面两个断点处可能会有疑惑。我们考虑这样一个情景:假设一个文件系统有大量的写入操作,这时候,情况一:日志正在提交;情况二:日志已经写满了。由于文件系统有大量的写操作所以在这两个情况之下,可能会有大量的线程在log.lock(begin_op())
上等(当然这在平时我们用的系统中是不可能出现这么抽象的情况的),
所以,每一个写操作在执行完成之后就会有两个选择:1.提交事务;2.唤醒一个线程继续执行写操作。由于在begin_op
中,我们只有出了大循环之后才会使outstanding++
,因此那些正在等待的操作并不会阻碍事务的提交,当outstanding
执行完之后,我们也再唤醒一次。
注意,由于begin_op()
的实现,这种唤醒并不会带来副作用(虚假唤醒),因为有一个大循环。
void
end_op(void)
{
int do_commit = 0;
acquire(&log.lock);
log.outstanding -= 1;
if(log.committing)
panic("log.committing");
if(log.outstanding == 0){
do_commit = 1;
log.committing = 1;
} else {
// 1.
// 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;
// 2.
wakeup(&log);
release(&log.lock);
}
}
static void write_log(void)
这个函数的作用很简单:将buffer cache中对文件block所作的修改写到日志中(在commit中调用)
static void
write_log(void)
{
int tail;
for (tail = 0; tail < log.lh.n; tail++) {
struct buf *to = bread(log.dev, log.start+tail+1); // log block
struct buf *from = bread(log.dev, log.lh.block[tail]); // cache block
memmove(to->data, from->data, BSIZE);
bwrite(to); // write the log
brelse(from);
brelse(to);
}
}
void log_write(struct buf *b)
这里是将对日志的修改转移到了buffer cache中,因为在事务提交的时候,会将所用的buffer cache的内容都写回磁盘,所以我们只需要修改buffer cache上的内容。
如果文件的空间不足或者空间缩小了,函数就会动态地增或减少相应的块。
void
log_write(struct buf *b)
{
int i;
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");
acquire(&log.lock);
for (i = 0; i < log.lh.n; i++) {
if (log.lh.block[i] == b->blockno) // log absorbtion
break;
}
log.lh.block[i] = b->blockno;
if (i == log.lh.n) { // Add new block to log?
bpin(b);
log.lh.n++;
}
release(&log.lock);
}
OK,logging层就讲解完了。