在这个实验室中,你将获得重新设计代码以提高并行性的经验。在多核机器上并行性差的一个常见症状是高锁争用。提高并行性通常需要改变数据结构和锁策略,以减少争用。你将为xv6内存分配器和块缓存做这件事。
在编写代码之前,请确保阅读xv6书中的以下部分。
- 第6章:"锁定 "和相应的代码。
- 第3.5节:“代码。物理内存分配器”
- 第8.1节到8.3节:“概述”、"缓冲区缓存层 "和 “代码。缓冲区高速缓存”
user/kalloctest程序强调xv6的内存分配器:三个进程增加和减少他们的地址空间,导致对kalloc和kfree的多次调用。 kalloctest打印(作为 “#fetch-and-add”)由于试图获取另一个核已经持有的锁而在aquire中循环迭代的数量,对于kmem锁和其他一些锁。获取中的循环迭代次数是对锁竞争的一个粗略衡量。在你完成实验之前,kalloctest的输出看起来与此相似。
$ kalloctest
start test1
test1 results:
--- lock kmem/bcache stats
lock: kmem: #fetch-and-add 83375 #acquire() 433015
lock: bcache: #fetch-and-add 0 #acquire() 1260
--- top 5 contended locks:
lock: kmem: #fetch-and-add 83375 #acquire() 433015
lock: proc: #fetch-and-add 23737 #acquire() 130718
lock: virtio_disk: #fetch-and-add 11159 #acquire() 114
lock: proc: #fetch-and-add 5937 #acquire() 130786
lock: proc: #fetch-and-add 4080 #acquire() 130786
tot= 83375
test1 FAIL
kalloctest调用一个系统调用,使内核打印出kmem和bcache锁(这是本实验的重点)以及5个争夺最激烈的锁的计数。如果存在锁的争夺,那么获取循环的迭代次数将会很大。系统调用返回kmem锁和bcache锁的循环迭代次数之和。
在这个实验中,你必须使用一台有多个内核的专门的无负载机器。如果你使用一台正在做其他事情的机器,那么kalloctest所打印的计数将是无稽之谈。你可以使用专用的Athena工作站,或者你自己的笔记本电脑,但不要使用拨号机。
kalloctest中锁争用的根本原因是kalloc()有一个单一的自由列表,由一个锁保护。为了消除锁的争夺,你必须重新设计内存分配器,以避免单一的锁和列表。基本的想法是为每个CPU维护一个空闲列表,每个列表有自己的锁。不同CPU上的分配和释放可以并行运行,因为每个CPU将对不同的列表进行操作。主要的挑战是如何处理这样的情况:一个CPU的空闲列表是空的,但另一个CPU的列表有空闲内存;在这种情况下,一个CPU必须 "偷 "走另一个CPU的空闲列表的一部分。偷窃可能会带来锁的争夺,但希望这种情况不常发生。
你的工作是实现每个CPU的自由列表,并在一个CPU的自由列表为空时进行窃取。你必须给你所有的锁起一个以 "kmem "开头的名字。也就是说,你应该为你的每个锁调用initlock,并传递一个以 "kmem "开头的名字。运行kalloctest,看看你的实现是否减少了锁的争夺。为了检查它是否仍然可以分配所有的内存,运行usertests sbrkmuch。你的输出将类似于下图所示,在kmem锁上的争夺大大减少,尽管具体数字会有所不同。确保usertests中的所有测试都通过了。 make grade应该说kalloctests通过了。
:
$ kalloctest
start test1
test1 results:
--- lock kmem/bcache stats
lock: kmem: #fetch-and-add 0 #acquire() 42843
lock: kmem: #fetch-and-add 0 #acquire() 198674
lock: kmem: #fetch-and-add 0 #acquire() 191534
lock: bcache: #fetch-and-add 0 #acquire() 1242
--- top 5 contended locks:
lock: proc: #fetch-and-add 43861 #acquire() 117281
lock: virtio_disk: #fetch-and-add 5347 #acquire() 114
lock: proc: #fetch-and-add 4856 #acquire() 117312
lock: proc: #fetch-and-add 4168 #acquire() 117316
lock: proc: #fetch-and-add 2797 #acquire() 117266
tot= 0
test1 OK
start test2
total free number of pages: 32499 (out of 32768)
.....
test2 OK
$ usertests sbrkmuch
usertests starting
test sbrkmuch: OK
ALL TESTS PASSED
$ usertests
...
ALL TESTS PASSED
$
一些提示:
- 你可以使用kernel/param.h中的常数NCPU
- 让freerange把所有的空闲内存给运行freerange的CPU。
- 函数cpuid返回当前的核心号,但只有在关闭中断时调用它并使用其结果才是安全的。你应该使用 push_off() 和 pop_off() 来关闭和开启中断。
- 看一下kernel/sprintf.c中的snprintf函数,了解一下字符串格式化的想法。不过,把所有的锁命名为 "kmem "也是可以的。
该题的主要目的是想让我们将减少kmem锁的竞争,但由于在进行分配内存时,需要对freelist进行修改,为此需要保护该全局变量,但在原来的设计中,只有一个锁对此进行保护,这就造成许多进程对该锁的竞争。为此需要为每一个CPU分配一个自己的freelist,这样就不会出现竞争,这里的难点是理解题意,第一个是让freerange把所有的空闲内存给运行freerange的CPU,我们知道只有kinit函数才会调用该函数,所以只有第一个CPU会调用这个函数,这使得第一个CPU会获得所有的空闲链表,第二个一个CPU必须 "偷 "走另一个CPU的空闲列表的一部分,这就涉及到第二个CPU必须从第一个CPU中取走一部分freelist,这里为了方便只取一半,而当CPU发现自己没有内存就向其他CPU取走,这里需要跳过自己,然后返回获得的free list指针。代码如下:
首先是针对每一个CPU生成一个freelist,对应一个自旋锁:
struct {
struct spinlock lock;
struct run *freelist;
} kmem[NCPU];
然后初始化所有锁:
void
kinit()
{
for(int i=0;i<NCPU;i++){
char str[10];
snprintf(str,9,"kmem %d",i);
initlock(&kmem[i].lock, str);
}
freerange(end, (void*)PHYSTOP);
}
接着是对kfree中每个CPU的链表进行free:
acquire(&kmem[id].lock);
r->next = kmem[id].freelist;
kmem[id].freelist = r;
release(&kmem[id].lock);
难点是在kalloc函数里面,如何从别的CPU中取得空闲链表指针:
void *
kalloc(void)
{
struct run *r;
push_off();
int id = cpuid();
pop_off();
acquire(&kmem[id].lock);
r = kmem[id].freelist;
if(!r){
r = steal(id);
}
if(r)
kmem[id].freelist = r->next;
release(&kmem[id].lock);
if(r)
memset((char*)r, 5, PGSIZE); // fill with junk
return (void*)r;
}
这里的steal函数采用双指针的方法快速得到其余CPU上freelist的中间指针,返回即可,这里需要注意偷取的时候需要对被偷对象加锁,否则会出现bug,最后要注意解锁,放在死锁。
struct run *steal(int id){
struct run *r,*slow, *fast;
for(int i=0;i<NCPU;i++){
if(i == id)
continue;
acquire(&kmem[i].lock);
if(kmem[i].freelist){
slow = kmem[i].freelist;fast = kmem[i].freelist->next;
r = slow;
if(fast == 0||fast->next == 0){
release(&kmem[i].lock);
continue;
}
while(fast != slow){
if(fast == 0||fast->next == 0)
break;
slow = slow->next;
fast = fast->next->next;
}
r = slow->next;
slow->next = 0;
release(&kmem[i].lock);
return r;
}
release(&kmem[i].lock);
}
return 0;
}
这一半的作业与前一半的作业是独立的;无论你是否完成了前一半的作业,你都可以进行这一半的工作(并通过测试)。
如果多个进程密集地使用文件系统,它们很可能会争夺bcache.lock,它保护kernel/bio.c中的磁盘块缓存。bcachetest创建了几个重复读取不同文件的进程,以产生对bcache.lock的争夺;其输出看起来像这样(在你完成本实验之前)。
$ bcachetest
start test0
test0 results:
--- lock kmem/bcache stats
lock: kmem: #fetch-and-add 0 #acquire() 33035
lock: bcache: #fetch-and-add 16142 #acquire() 65978
--- top 5 contended locks:
lock: virtio_disk: #fetch-and-add 162870 #acquire() 1188
lock: proc: #fetch-and-add 51936 #acquire() 73732
lock: bcache: #fetch-and-add 16142 #acquire() 65978
lock: uart: #fetch-and-add 7505 #acquire() 117
lock: proc: #fetch-and-add 6937 #acquire() 73420
tot= 16142
test0: FAIL
start test1
test1 OK
你可能会看到不同的输出,但bcache锁的获取循环迭代次数会很高。如果你看一下kernel/bio.c中的代码,你会发现bcache.lock保护了缓存块缓冲区的列表,每个块缓冲区的引用计数(b->refcnt),以及缓存块的身份(b->dev和b->blockno)。
修改块缓存,使运行bcachetest时,bcache中所有锁的获取循环迭代次数接近于零。理想情况下,区块缓存中涉及的所有锁的计数之和应该为零,但如果总和小于500也没关系。修改bget和brelse,使bcache中不同区块的并发查找和释放不太可能在锁上发生冲突(例如,不必都等待bcache.lock)。你必须保持一个不变的原则,即每个区块最多只有一个副本被缓存。当你完成后,你的输出应该类似于下图所示(虽然不完全相同)。确保usertests仍然通过。当你完成后,make grade应该通过所有的测试。
$ bcachetest
start test0
test0 results:
--- lock kmem/bcache stats
lock: kmem: #fetch-and-add 0 #acquire() 32954
lock: kmem: #fetch-and-add 0 #acquire() 75
lock: kmem: #fetch-and-add 0 #acquire() 73
lock: bcache: #fetch-and-add 0 #acquire() 85
lock: bcache.bucket: #fetch-and-add 0 #acquire() 4159
lock: bcache.bucket: #fetch-and-add 0 #acquire() 2118
lock: bcache.bucket: #fetch-and-add 0 #acquire() 4274
lock: bcache.bucket: #fetch-and-add 0 #acquire() 4326
lock: bcache.bucket: #fetch-and-add 0 #acquire() 6334
lock: bcache.bucket: #fetch-and-add 0 #acquire() 6321
lock: bcache.bucket: #fetch-and-add 0 #acquire() 6704
lock: bcache.bucket: #fetch-and-add 0 #acquire() 6696
lock: bcache.bucket: #fetch-and-add 0 #acquire() 7757
lock: bcache.bucket: #fetch-and-add 0 #acquire() 6199
lock: bcache.bucket: #fetch-and-add 0 #acquire() 4136
lock: bcache.bucket: #fetch-and-add 0 #acquire() 4136
lock: bcache.bucket: #fetch-and-add 0 #acquire() 2123
--- top 5 contended locks:
lock: virtio_disk: #fetch-and-add 158235 #acquire() 1193
lock: proc: #fetch-and-add 117563 #acquire() 3708493
lock: proc: #fetch-and-add 65921 #acquire() 3710254
lock: proc: #fetch-and-add 44090 #acquire() 3708607
lock: proc: #fetch-and-add 43252 #acquire() 3708521
tot= 128
test0: OK
start test1
test1 OK
$ usertests
...
ALL TESTS PASSED
$
请给你所有的锁取一个以 "bcache "开头的名字。也就是说,你应该为你的每一个锁调用initlock,并传递一个以 "bcache "开头的名字。
减少bcache中的争用比kalloc更棘手,因为bcache缓冲区确实是由进程(也就是CPU)共享的。对于kalloc来说,我们可以通过给每个CPU提供自己的分配器来消除大部分的争用,但这对bcache来说是行不通的。我们建议你用一个带锁的哈希表来查询缓存中的区块号码,每个哈希桶有一个锁。
在某些情况下,如果你的解决方案有锁冲突,那是可以的。
bcachetest的test1使用了比缓冲区更多的独立块,并使用了大量的文件系统代码路径。
这里有一些提示:
这道题的难度远大于第一题,当然自己也真正学会了锁的使用方法,以及锁的重要性。首先是要理解题目含义,他要求我们要减少对bcache锁的竞争,该锁是负责保护缓存块的,当多个进程进行读取磁盘中的文件时,对该缓存块进行保护,当然这也造成了很严重的竞争,不像第一个题目那样,可以单独为每个CPU分配一个空闲链表,这里每个缓存块都是代表一个文件的缓存内容,因此是共享的,不存在说是一个CPU独享的。为此难点在于如何处理多个CPU对不同文件的缓存,这里根据题目的提示,和原来的设计不同,采用哈希算法对缓存块进行索引,当然这里为了保证每个哈希桶的不变性,对每个桶进行了加锁,也就是说有多少个桶就有多少个对于的“桶锁”,这样还不够,由于还需要知道缓存块的剩余数量这里还需要保留之前的bcache锁,用于维护整个哈希表的缓存块的个数,当我做到这里的时候,本以为已经大功告成了,结果还是出现了死锁(它的具体表征就是测试过程极其漫长,卡住了),于是对整个bget进行了仔细查看,发现当多个CPU进行文件的读取时,就会卡住,再次审题发现了这样一句话在哈希表中搜索一个缓冲区,并在没有找到缓冲区时为其分配一个条目,必须是原子性的。所以,问题的根本出现在驱逐一个条目这块中,发现对于整个哈希表来说,仍然需要一个锁用于保证哈希表的不变性,这里要和哈希桶的不变性区分开,于是就需要这一块锁,其余部分比较简单,这里就不再赘述了。
struct {
struct spinlock lock;
struct spinlock locks[BUCKETNUM];
struct spinlock hashlock;
struct buf buf[NBUF];
struct buf buckets[BUCKETNUM];
uint size;
// Linked list of all buffers, through prev/next.
// Sorted by how recently the buffer was used.
// head.next is most recent, head.prev is least.
// struct buf head;
} bcache;
void
binit(void)
{
struct buf *b;
initlock(&bcache.lock, "bcache");
initlock(&bcache.hashlock, "hashlock");
for(int i=0;i<BUCKETNUM;i++){
initlock(&bcache.locks[i], "bcache.bucket");
}
// Create linked list of buffers
for(b = bcache.buf; b < bcache.buf+NBUF; b++){
initsleeplock(&b->lock, "buffer");
}
}
static struct buf*
bget(uint dev, uint blockno)
{
struct buf *b,*p;
p=0;
uint time = -1;
int index = HASH(blockno);
acquire(&bcache.locks[index]);
//仿照原来第一种情况进行编写即可
for(b = bcache.buckets[index].next;b != 0;b = b->next){
if((b->blockno == blockno)&&(b->dev == dev)){
b->refcnt++;
release(&bcache.locks[index]);
acquiresleep(&b->lock);
return b;
}
}
//not cached ,but still have free blocks
acquire(&bcache.lock);
if(bcache.size<NBUF){
b = &bcache.buf[bcache.size++];
b->blockno = blockno;
b->dev = dev;
b->valid = 0;
b->refcnt = 1;
b->next = bcache.buckets[index].next;
bcache.buckets[index].next = b;
release(&bcache.lock);
release(&bcache.locks[index]);
acquiresleep(&b->lock);
return b;
}
release(&bcache.lock);
release(&bcache.locks[index]);
//not cached but don't have free blocks then must evict
acquire(&bcache.hashlock);
int j=0;
for(int i=0;i<BUCKETNUM;i++){
acquire(&bcache.locks[i]);
for(b=bcache.buckets[i].next;b!=0;b = b->next){
if(b->refcnt==0 && b->time_stamp<time){
//这里是用于记录最小时间戳的缓存块
p = b;
j = i;
time = b->time_stamp;
}
}
release(&bcache.locks[i]);
}
acquire(&bcache.locks[j]);
for(b=&bcache.buckets[j];b->next!=0;b = b->next){
if(b->next == p){
//这里是寻找最小时间戳前面一个节点,用于拆节点
break;
}
}
if(j != index){
acquire(&bcache.locks[index]);
}
if(p){
p->blockno = blockno;
p->dev = dev;
p->refcnt = 1;
p->valid = 0;
b->next = p->next;
p->next = bcache.buckets[index].next;
bcache.buckets[index].next = p;
if(j!=index)
release(&bcache.locks[index]);
release(&bcache.locks[j]);
release(&bcache.hashlock);
acquiresleep(&p->lock);
return p;
}
panic("no free blocks");
}
extern uint ticks;
void
brelse(struct buf *b)
{
int index;
if(!holdingsleep(&b->lock))
panic("brelse");
releasesleep(&b->lock);
index = HASH(b->blockno);
acquire(&bcache.locks[index]);
b->refcnt--;
if (b->refcnt == 0) {
// no one is waiting for it.
b->time_stamp = ticks;
}
release(&bcache.locks[index]);
}
void
bpin(struct buf *b) {
int index = HASH(b->blockno);
acquire(&bcache.locks[index]);
b->refcnt++;
release(&bcache.locks[index]);
}
void
bunpin(struct buf *b) {
int index = HASH(b->blockno);
acquire(&bcache.locks[index]);
b->refcnt--;
release(&bcache.locks[index]);
}
别忘了在主目录编写time.txt文件哈。
这里可能比较漫长,耐心等待即可!当然出现timeout的情况的话,可修改gradelib里面的timeout。
== Test running kalloctest ==
$ make qemu-gdb
(68.8s)
== Test kalloctest: test1 ==
kalloctest: test1: OK
== Test kalloctest: test2 ==
kalloctest: test2: OK
== Test kalloctest: sbrkmuch ==
$ make qemu-gdb
kalloctest: sbrkmuch: OK (9.1s)
== Test running bcachetest ==
$ make qemu-gdb
(6.4s)
== Test bcachetest: test0 ==
bcachetest: test0: OK
== Test bcachetest: test1 ==
bcachetest: test1: OK
== Test usertests ==
$ make qemu-gdb
usertests: OK (97.9s)
== Test time ==
time: OK
Score: 70/70