系列第一篇文章:
注释:从底向上理解FS的原因在于磁盘层的操作不依赖于任何其余层,并向上层提供接口,因此先理解磁盘层不会像从上到下解析一样,在遇到某个不属于本层次的接口时暂时跳过。自底向上会在每层的解析中逐渐揭开文件系统的全貌。
上篇文章我们解析了磁盘层的操作。本篇文章中我们会直接使用其提供的接口,不再做具体阐述。
xv6文件系统层次结构图:
struct buf* bread(uint dev, uint blockno)
—>为某个特定磁盘块获取一块缓冲void bwrite(struct buf *b)
—>对缓冲中数据修改完之后,将它写入硬盘void brelse(struct buf *b)
—>对缓冲操作结束,释放缓冲buffer cache
的原则是硬盘中每一个块在buffer cache中只有唯一的缓存。同一块的多个副本会造成文件内容错乱等未知问题。
buffer cache
层中的缓冲满时,需要使用LRU算法在所有未被使用的块中选出一块置换。
文件系统中关于bcache的定义如下:
struct buf {
int valid; // 记录是否数据从磁盘读取到内存,首次记录目标块时会标记为0
int disk; //用于磁盘层驱动和中断之间作为消息
uint dev; // 设备号
uint blockno; //缓冲对应的硬盘块号
struct sleeplock lock;//睡眠锁
uint refcnt; //记录有多少进程在使用该缓冲块
struct buf *prev; // 双向循环链表,方便LRU算法处理
struct buf *next;
uchar data[BSIZE]; //硬盘数据存储位置
};
struct {
//自旋锁
struct spinlock lock;
struct buf buf[NBUF];//共30个缓冲块供上层使用
// 用于LRU算法
struct buf head;
} bcache;
bcache结构的缺点:bcache自旋锁使得同一时间只有一个进程可以修改bcache的内容。造成了效率不高的情况。
void binit(void)
{
struct buf *b;
//初始化锁
initlock(&bcache.lock, "bcache");
//初始化双向循环链表头尾指向自身
bcache.head.prev = &bcache.head;
bcache.head.next = &bcache.head;
// 头插法初始化好双向循环链表
for(b = bcache.buf; b < bcache.buf+NBUF; b++){
b->next = bcache.head.next;
b->prev = &bcache.head;
initsleeplock(&b->lock, "buffer");
bcache.head.next->prev = b;
bcache.head.next = b;
}
}
由于初次访问硬盘块时必然内存中无缓存,初始化会设置好供LRU算法使用的双链表,同时初始化所有的锁。
static struct buf* bget(uint dev, uint blockno)
{
struct buf *b;
// 申请bcache全局锁
acquire(&bcache.lock);
// 搜索双向循环链表,按最近最多顺序依次访问
for(b = bcache.head.next; b != &bcache.head; b = b->next){
if(b->dev == dev && b->blockno == blockno){
b->refcnt++;
release(&bcache.lock);
acquiresleep(&b->lock);
return b;
}
}
// 缓冲未命中
// 按LRU算法置换出最近最少使用的闲置块,无闲置块则直接panic
for(b = bcache.head.prev; b != &bcache.head; b = b->prev){
if(b->refcnt == 0) {
//对目的空闲块记录详细信息,设置尚未从硬盘中读取内容valid标志位等标志位
//申请该块的操作锁,准备对该缓冲块使用接口进行操作
b->dev = dev;
b->blockno = blockno;
b->valid = 0;
b->refcnt = 1;
release(&bcache.lock);
acquiresleep(&b->lock);
return b;
}
}
panic("bget: no buffers");
}
代码如下:
struct buf* bread(uint dev, uint blockno)
{
struct buf *b;
//申请空闲块,未缓冲则记录信息
b = bget(dev, blockno);
//未命中该块则先从硬盘中读进内存
if(!b->valid) {
virtio_disk_rw(b, 0);
b->valid = 1;
}
return b;
}
// 将缓冲内容写入硬盘,必须持有该块的睡眠锁
void
bwrite(struct buf *b)
{
if(!holdingsleep(&b->lock))
panic("bwrite");
//调用硬盘层提供的接口,写入硬盘
virtio_disk_rw(b, 1);
}
// 释放一个缓冲,必须持有该缓冲的睡眠锁
// 若无进程等待使用该块,将它移动至最近最多使用的头部
void
brelse(struct buf *b)
{
if(!holdingsleep(&b->lock))
panic("brelse");
releasesleep(&b->lock);
acquire(&bcache.lock);
b->refcnt--;
if (b->refcnt == 0) {
// 先把b从链表中取出来
b->next->prev = b->prev;
b->prev->next = b->next;
//把b链到bcache.head的next位置
//next指针按顺序往后为最近最多使用链表
//prev指针按顺序往前为最近最少使用链表
b->next = bcache.head.next;
b->prev = &bcache.head;
bcache.head.next->prev = b;
bcache.head.next = b;
}
release(&bcache.lock);
}
完结撒花。至此源码解析完毕。
注释:文件中剩余的bpin
和bunpin
函数为Logging(日志)
层的内容,留到下篇做解析。
Buffer Cache的缺点在于使用bcache.lock
作为本层的全局锁,同一时间只有一个进程可以访问bcache。降低了效率。
一个可行的优化方法:使用hash表,按hash表的桶为基本访问单位
1:哈希表的hash方法为块号对哈希表桶的数量取余,即同一余数所有块号在一个桶中,hash表桶容量尽量为素数,如13,实验中我取用29。
2:哈希表中每一个桶有自己的自旋锁,初始化时把所有的缓冲都挂在哈希表的桶下。同一组对哈希表桶数量取余之后相同的所有块号竞争一个锁。也就是:桶数量越多,性能越好。
3:LRU算法基于桶进行,每次寻找本桶中的最近最少使用的可用块。当没有时,则跨桶寻找,按照桶在hash表中的下标从大到小顺序申请锁防止死锁。
Buffer Cache层本身不困难,理解其功能后理解源码会更加简单。
关于源码详解中注释部分可能观感上不太清楚,如有问题,欢迎私信,必虚心受教。