MIT6.S081实验八学习记录
Lab: locks
这个实验主要任务是将锁进行细粒化,提高并行程度。比如说,如果整个系统只有一把锁,那么只要你占有,其他所有的进程都得停滞,对于多核的系统来说更是灾难,完全无法发挥出应该有的作用。再者来说,多块内存如果只由一个锁来管理,那么即使不同线程要访问不同的内存,但是由于锁被占用,本来没有被访问到的内存也没法被使用。 上锁保证了隔离性,但是如果对并行程度影响过大就得不偿失了。
首先,切换到实验八的分支。
$ git fetch
$ git checkout lock
$ make clean
1、Memory allocator (moderate)
第一个实验就是要将kalloc 中kmem 的锁细粒化,要保证给每个CPU都能分配到锁。当当前CPU分配内存时,如果发现自己的freelist都被占用了,就要去“偷”其他CPU的可用freelist为己所用。
首先,给每个CPU都细化一个kmem类。
struct {
struct spinlock lock;
struct run *freelist;
} kmem[NCPU];
接下来,初始化阶段将所有锁初始化。
void
kinit()
{
for(int i = 0; i < NCPU; i++){
char name[9] = {0};
snprintf(name, 8, "kmem_%d", i); //每个锁的名字不一样
initlock(&kmem[i].lock,name);
}
//initlock(&kmem.lock, "kmem");
freerange(end, (void*)PHYSTOP);
}
接下来,修改分配内存的代码,如果当前freelist可用,就直接分配,如果不可用,那就从其他CPU的freelist 找一个可用的。
void *
kalloc(void)
{
struct run *r;
//关闭所有中断,找到当前的cpuid
push_off();
int i = cpuid();
pop_off();
//获取当前cpu的freelist
acquire(&kmem[i].lock);
r = kmem[i].freelist;
if(r){
kmem[i].freelist = r->next; //如果可用,就直接用
}else{ //不可用,找其他的CPU是否有可用的freelist
for(int j = 0; j < NCPU; j++){
if(j == i) continue;
acquire(&kmem[j].lock); //上锁
if(!kmem[j].freelist){ //没有可用的,解锁,继续找
release(&kmem[j].lock);
continue;
}
r = kmem[j].freelist; //找到了可用的freelist
kmem[j].freelist = r->next; //将可用的freelist赋给之前需要的CPU
release(&kmem[j].lock); //解锁
break; //直接退出,无需再找
}
}
release(&kmem[i].lock);
if(r)
memset((char*)r, 5, PGSIZE); // fill with junk
return (void*)r;
}
然后需要修改一下释放内存的函数 kfree ,主要就是把上锁方式修改一下,因为上面已经将锁细粒化了,代码如下:
void
kfree(void *pa)
{
struct run *r;
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
// Fill with junk to catch dangling refs.
memset(pa, 1, PGSIZE);
r = (struct run*)pa;
//关中断,获取当前CPU的id
push_off();
int i = cpuid();
pop_off();
acquire(&kmem[i].lock);
r->next = kmem[i].freelist;
kmem[i].freelist = r;
release(&kmem[i].lock);
}
然后这部分就修改完了,可以在xv6里测试一下,这里usertests太多了,我就测试一下kalloctest和sbrkmuch。
xv6 kernel is booting
hart 2 starting
hart 1 starting
init: starting sh
$ kalloctest
start test1
test1 results:
--- lock kmem/bcache stats
lock: bcache: #fetch-and-add 0 #acquire() 8
lock: bcache: #fetch-and-add 0 #acquire() 2
lock: bcache: #fetch-and-add 0 #acquire() 12
lock: bcache: #fetch-and-add 0 #acquire() 18
lock: bcache: #fetch-and-add 0 #acquire() 6
lock: bcache: #fetch-and-add 0 #acquire() 6
lock: bcache: #fetch-and-add 0 #acquire() 12
lock: bcache: #fetch-and-add 0 #acquire() 10
lock: bcache: #fetch-and-add 0 #acquire() 274
--- top 5 contended locks:
lock: proc: #fetch-and-add 390592 #acquire() 124893
lock: proc: #fetch-and-add 366233 #acquire() 124892
lock: proc: #fetch-and-add 327611 #acquire() 124892
lock: proc: #fetch-and-add 263392 #acquire() 124891
lock: proc: #fetch-and-add 260295 #acquire() 124892
tot= 0
test1 OK
start test2
total free number of pages: 32495 (out of 32768)
.....
test2 OK
$ usertests sbrkmuch
usertests starting
test sbrkmuch: OK
ALL TESTS PASSED
发现都通过了。
2、Buffer cache (hard)
这个实验相较于上一个实验要复杂许多,首先需要理解这个实验的目的。
这个实验是要给文件系统的缓存区上锁,当多个进程频繁适用文件系统时,会高频次访问这个缓存区,因此这里的锁被 accquire的次数会非常多。原始版本的代码只有一个锁来管理,而题目也给出了一些提示,也就是用哈希桶,将整个缓存区分块,对每一块都上锁,这样访问其中一个缓存块就不会把整个缓存区都锁上了,因此可以提高并行度。
这里原本缓存区使用一个双向链表来管理的,其实也就是为了方便使用LRU算法,如果在leetcode写过LRU的话,就应该知道双向链表的作用。
因此,首先需要定义一个哈希桶,将整个缓存区分块,这里也提示,建议哈希桶数量设置为质数来避免哈希冲突,因此就把哈希桶的大小定义为13.
extern uint ticks;
#define NBUCKET 13
struct {
struct spinlock lock[NBUCKET]; //每个桶都有一个锁
struct buf buf[NBUF];
struct buf buckets[NBUCKET]; //每个桶都有一个索引开始的地方,可以看作是原来的head
} bcache;
//定义哈希函数
int hash(uint blockno){
return blockno % NBUCKET;
}
接下来就是初始化整个bcache
void
binit(void)
{
struct buf *b;
//初始化每个桶的锁
for(int i = 0; i < NBUCKET; i++){
initlock(&bcache.lock[i], "bcache");
}
// 将每个桶内的双向链表初始化
for(int i = 0; i < NBUCKET;i++){
bcache.buckets[i].prev = &bcache.buckets[i];
bcache.buckets[i].next = &bcache.buckets[i];
}
//将每个双向链表分配好NUBF的内存
for(b = bcache.buf; b < bcache.buf+NBUF; b++){
b->next = bcache.buckets[0].next;
b->prev = &bcache.buckets[0];
initsleeplock(&b->lock, "buffer");
bcache.buckets[0].next->prev = b;
bcache.buckets[0].next = b;
}
}
接下来就是最重要的bget函数,这个函数本来的作用就是遍历整个buffer cache找对应块的缓存,如果找到,直接返回这个缓存块,把引用计数增加就行。没找到的话,就需要先把当前使用最早的缓存块淘汰掉。原版应该是把引用计数为0的块换掉,这里实验多加了一条要求,就是要按照时间来判断,把最早的替换掉,所以也算是实现一个LRU。
如果当前没找打满足要求的缓存块,就需要从其他桶中找一个空闲的块分配给当前块使用,因为这里是链表形式的,所以空间上并不连续,这种实现是完全可行的。
static struct buf*
bget(uint dev, uint blockno)
{
struct buf *b;
int id = hash(blockno);
acquire(&bcache.lock[id]);
// 寻找对应设备号和块号的缓存块
for(b = bcache.buckets[id].next; b != &bcache.buckets[id]; b = b->next){
if(b->dev == dev && b->blockno == blockno){ //命中
b->refcnt++; //增加引用数
release(&bcache.lock[id]);
acquiresleep(&b->lock);
return b;
}
}
uint least = ticks; //获取当前时间戳
struct buf *res = 0; //定义一个临时缓存块
//找到最小的时间戳,也就是最早使用的缓存块,赋值给res
for(b = bcache.buckets[id].next; b != &bcache.buckets[id]; b = b->next){
if(b->refcnt == 0 && b->lastuse <= least){ //
least = b->lastuse;
res = b;
}
}
if(res){ //如果这个块可用,那就初始化,将所有信息重置
res->dev = dev;
res->blockno = blockno;
res->valid = 0;
res->refcnt = 1;
release(&bcache.lock[id]);
acquiresleep(&res->lock);
return res;
} else { /如果不可用,需要从其他的桶中来“偷”
for(int i = 0; i < NBUCKET;i++){
if(i == id) continue; //跳过当前id的桶
acquire(&bcache.lock[i]); //给当前桶上锁
least = ticks; //获取当前时间戳
//找到这个桶里最早使用的块
for(b = bcache.buckets[i].next; b != &bcache.buckets[i]; b = b->next){
if(b->refcnt==0 && b->lastuse<=least) {
least = b->lastuse;
res = b;
}
}
//如果这个块不可用,那就找下一个桶
if(!res){
release(&bcache.lock[i]);
continue;
}
//可用就初始化这个快的信息
res->dev = dev;
res->blockno = blockno;
res->valid = 0;
res->refcnt = 1;
//把这个可用块从当前桶(非id桶)中拿出来
res->next->prev = res->prev;
res->prev->next = res->next;
release(&bcache.lock[i]);
//把这个可用块放到需要的桶中id位置
res->next = bcache.buckets[id].next;
bcache.buckets[id].next->prev = res;
bcache.buckets[id].next = res;
res->prev = &bcache.buckets[id];
release(&bcache.lock[id]);
acquiresleep(&res->lock);
return res;
}
}
//上述过程也没找到,说明当前调度算法下,没有可用块
release(&bcache.lock[id]);
panic("bget: no buffers");
}
写到这里,基本上就完成了,但是还有一个重要问题,如何获取到当前块的最早使用时间呢?也就是上面的b->lastuse,这里首先在 buf.h中定义这个变量。
struct buf {
int valid; // has data been read from disk?
int disk; // does disk "own" buf?
uint dev;
uint blockno;
struct sleeplock lock;
uint refcnt;
struct buf *prev; // LRU cache list
struct buf *next;
uchar data[BSIZE];
uint lastuse; //上一次使用的时间戳
};
接下来需要在其被LRU调度后,也就是把这个缓存解锁后,放到整个使用链表头部的时候,说明是被刚刚访问的,此时加上时间戳。因此放到 brelse 函数中
// Release a locked buffer.
// Move to the head of the most-recently-used list.
void
brelse(struct buf *b)
{
if(!holdingsleep(&b->lock))
panic("brelse");
releasesleep(&b->lock);
int id = hash(b->blockno);
acquire(&bcache.lock[id]);
b->refcnt--;
if (b->refcnt == 0)
b->lastuse = ticks; //引用计数为0,说明刚初始化,加上时间戳
release(&bcache.lock[id]);
}
最后还需要修改上锁的地方,因为前面已经把锁细粒化了。
void
bpin(struct buf *b) {
int id = hash(b->blockno);
acquire(&bcache.lock[id]);
b->refcnt++;
release(&bcache.lock[id]);
}
void
bunpin(struct buf *b) {
int id = hash(b->blockno);
acquire(&bcache.lock[id]);
b->refcnt--;
release(&bcache.lock[id]);
}
到这里整个实验就做完了,然后用脚本测试一下。
jimmy@ubuntu:~/xv6-test/xv6-labs-2020$ ./grade-lab-lock
make: 'kernel/kernel' is up to date.
== Test running kalloctest == (104.3s)
== Test kalloctest: test1 ==
kalloctest: test1: OK
== Test kalloctest: test2 ==
kalloctest: test2: OK
== Test kalloctest: sbrkmuch == kalloctest: sbrkmuch: OK (14.5s)
== Test running bcachetest == (41.1s)
== Test bcachetest: test0 ==
bcachetest: test0: OK
== Test bcachetest: test1 ==
bcachetest: test1: OK
== Test usertests == usertests: OK (245.1s)
(Old xv6.out.usertests failure log removed)
== Test time ==
time: OK
Score: 70/70
全部通过。
总结
这个实验和上个实验都有用到锁这一关键的计数,只是侧重点不同。上个实验重在体会锁带给整个系统,尤其是线程间的隔离性,而这个实验主要是要能够平衡隔离性,提升并行化程度。
第一个实验就是简单地将kmem按照CPU数目来上锁,并且还要做一个不同CPU间“借”内存的操作。
第二个实验是对文件系统缓存区进行优化,将原来的一个大锁用哈希桶分块,然后对每个块进行上锁解锁,进而可以较好地保证当一个线程访问当前缓存时,不会将整个缓存区上锁,而是对每个缓存块上锁,如果当前线程不需要这个缓存块,那么也不会一直占着不给别的线程用,因此进一步提升运行效率。
[1]. https://pdos.csail.mit.edu/6.S081/2020/labs/lock.html
[2]. https://zhuanlan.zhihu.com/p/609771386
[3]. https://zhuanlan.zhihu.com/p/629372818