1 Memory allocator
Xv6的user/kalloctest 程序关注于内存分配器:三个进程扩大和减小它们的地址空间,导致了很多对于kalloc和kfree的调用,而它们都会获取kmem.lock.kalloctest会打印(像"#fetch-and-add")在acquire中尝试获得一个其他进程已经持有的锁的迭代次数,如kmem、以及一些其他的锁。acquire中的迭代次数是一种粗糙的评估锁竞争次数的方式。在你完成这个lab之前,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
对于每个锁,acquire会维护一个对于这个锁的调用计数,还有acquire中尝试获得锁但是失败的次数。kalloctest调用了系统调用使得内核去打印kmem和bcache锁的相关计数,还有五个竞争次数最多的锁。如果发生锁竞争的话,acquire产生的迭代次数会是非常大的。这个系统调用会返回kmem和bache 锁的总的迭代次数。
对于这个实验,您必须使用专用的多核卸载机器。如果你使用了一个做其他事情的机器,那么计数将会是无意义的。你可以使用雅典娜机,或你自己的笔记本电脑,但不要使用拨号机。
kalloctest中锁争用的根本原因是kalloc()有一个由一个锁保护的空闲列表。为了移除锁竞争,你需要重新设计一个内存分配器来避免单个锁和列表。基本的想法是为每个核都维护一个空闲列表,每个列表都有一个自己的锁。不同CPU上的分配和释放可以并行,因为它们在不同的空闲列表上操作。主要的挑战会是某个CPU的空闲列表为空,而另一个是空闲的;这种情况下,那个CPU必须从其他CPU那边偷一些过来。偷内存可能需要引入锁竞争,但是这是很不平凡的。
您的工作是实现每个CPU的空闲列表,并在CPU的空闲列表为空时进行窃取。您必须给出所有以“kmem”开头的锁名。也就是说,您应该为每个锁调用initlock,并传递一个以“kmem”开头的名称。运行kalloctest,看看您的实现是否减少了锁争用。要检查它是否仍然可以分配所有的内存,请运行usertests sbrkmuch。您的输出将类似于下面所示,在kmem锁上的争用总数大大减少,尽管具体数字会有所不同。确保用户测试中的所有测试都通过。
$ 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的CPU。(这部分没怎么理解,就按照自己的方式做了。)
- cpuid会返回一个当前核号,但是这个调用只在关闭中断时才正确。你应当使用push_off()和push_off()来打开或者关闭中断。
- 看一下snprintf函数,用于字符串格式化。不过,把所有锁都命名为kmem也是可以的。
只要修改kernel/kalloc.c中的内容即可。
为每个CPU都添加一个kmem
struct {
struct spinlock lock;
struct run *freelist;
} kmem[NCPU];
添加一个全局锁,用于窃取内存时的同步
struct spinlock allLock;
修改freerange()
对于freerange的修改与提示不同,本次实现中把freerange函数用于在初始化时给每个CPU的freelist进行分配内存。窃取内存部分实现在steal函数中。
因为整个空间并不能按CPU数进行等分,所以起始和结束都使用了PGROUNDUP宏,否则会出现某页丢失的情况。
void
freerange(int cpuId,void *pa_start, void *pa_end)
{
char *p;
p = (char*)PGROUNDUP((uint64)pa_start);
for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE){
struct run *r = (struct run*)p;
r->next = kmem[cpuId].freelist;
kmem[cpuId].freelist = r;
}
}
修改kinit()
这边主要就是锁的初始化以及空闲列表的分配。
void
kinit()
{
initlock(&allLock,"all");
uint64 partLen = (PHYSTOP - (uint64)end)>>3;
for(uint64 i = 0;i < NCPU;i++){
initlock(&kmem[i].lock, "kmem");
void *st = (void *)PGROUNDUP((uint64) (end) + i*partLen);
void *ed = (void *)PGROUNDUP((uint64) (end) + (i + 1)*partLen);
freerange(i,st, ed);
}
}
添加steal()
该函数用于某CPU自身空闲列表为空,向其他所有CPU窃取内存的情况。
需要在开头释放掉本CPU对应的空闲列表的锁,因为之后就是去获取全局锁,且在循环中会去获取其他CPU的锁,如果不是放自身的锁,而有另一个CPU持有了全局锁,且此时申请了本CPU对应的锁,就会产生死锁。需要在释放全局锁之前,重新获得本CPU对应的锁,来完成之后的分配操作。
循环中就是去窃取所有其他的CPU的空闲列表。
void steal(int cpuId){
release(&kmem[cpuId].lock);
acquire(&allLock);
for(int i = 0;i < NCPU;i++){
if(i == cpuId)
continue;
// steal.
acquire(&kmem[i].lock);
if(kmem[i].freelist){
struct run* s = kmem[i].freelist;
while(s->next)
s = s->next;
s->next = kmem[cpuId].freelist;
kmem[cpuId].freelist = kmem[i].freelist;
kmem[i].freelist = 0;
}
release(&kmem[i].lock);
}
acquire(&kmem[cpuId].lock);
release(&allLock);
}
修改kfree()和kalloc()
kfree()和kalloc()只需要增加一个中断关闭和打开,还有锁就可以。
void
kfree(void *pa)
{
struct run *r;
push_off();
int cpuId = cpuid();
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;
acquire(&kmem[cpuId].lock);
r->next = kmem[cpuId].freelist;
kmem[cpuId].freelist = r;
release(&kmem[cpuId].lock);
pop_off();
}
void *
kalloc(void)
{
push_off();
struct run *r;
int cpuId = cpuid();
acquire(&kmem[cpuId].lock);
r = kmem[cpuId].freelist;
if(!r){
steal(cpuId);
r = kmem[cpuId].freelist;
}
if(r)
kmem[cpuId].freelist = r->next;
release(&kmem[cpuId].lock);
pop_off();
if(r)
memset((char*)r, 5, PGSIZE); // fill with junk
return (void*)r;
}
2 Buffer cache
这一半作业是独立于前一半的;不管你是否完成了前一半,你都可以完成这一半(并通过测试)。
如果多个进程集中使用文件系统,它们可能会争夺bcache.Lock,用于保护kernel/bio.c中的磁盘块缓存。Bcachetest创建了几个进程,这些进程重复读取不同的文件,会cache.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)。
修改块缓存,使bcache中所有锁的获取循环迭代次数在运行bcachetest时接近于零。理想情况下,块缓存中涉及的所有锁的总数应该为零,但如果总数小于500也可以。修改bget和brelse,以便bcache中不同块的并发查找和释放不太可能在锁上发生冲突(例如,不必所有块都要等待bcache.lock)。您必须保持每个块最多只能缓存一个副本。当您完成时,您的输出应该类似于下面所示(尽管不完全相同)。确保用户测试仍然通过。当你完成时,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”开头的名称。
减少块缓存中的争用比kalloc更加棘手,因为bcache缓冲区是在进程(以及cpu)之间真正共享的。对于kalloc,可以通过给每个CPU分配自己的分配器来消除大多数争用;这对块缓存不起作用。我们建议您使用每个哈希桶都有一个锁的哈希表在缓存中查找块数。
以下情况产生的冲突是ok的:
- 两个进程同时使用相同的块号。bcachetest test0不会做这种事情。
- 两个进程并发地丢失缓存,然后需要寻找未使用的块来替代。bcachetest test0不会做这种事情。
- 两个进程同时使用哈希值冲突的块。cachetest
test0可能会遇到这件事情,但是你应当去避免发生这件事(比如改变哈希表的大小)。
bcachetest的test1用了比buffer多很多的不同的块,并且测试了很多的文件系统代码路径。
一些提示:
- 读xv6 book的相关部分( 8.1-8.3)
- 可以不动态改变桶的数目。用一个素数(比如13)来减少冲突的可能性。
- 在哈希表里面查找一个buffer以及当找不到buffer时,为buffer分配一个项应当是原子的。
- 在哈希表上面查找一个buffer以及没有缓存目标buffer时的替换操作必须是源自的。
- 移除所有buffer的链表,使用最后一次使用的时间戳buffer(比如用 kernel/trap.c中的ticks)。这使得brelse不需要申请bcache锁,并且bget可以通过时间戳来挑选LRU block。
- bget中的串行化遗弃策略是可以的(当缓存查找丢失时,bget中选择一个重用buffer的部分)。
- 你的方案可能有时候需要持有两把锁;比如,在替换时可能需要持有bcache lock和一个bucket lock。确保避免死锁。
- 当替换块时,你可能会把一个struct buf从一个桶移到另一个,因为新的块会hash到另一个桶。你可以使用一个策略:新的块hash到相同的桶。确保避免死锁。
- 一些debug建议:实现桶锁,但保留全局bcache。在bget的开始/结束处锁定获取/释放,以序列化代码。一旦您确定它是正确的,没有竞争条件,删除全局锁并处理并发问题。您还可以运行make CPUS=1 qemu来使用一个核进行测试。
修改struct buf
实现没有用到链表,所以把指针都删了。
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 ticks; // for LRU
int hashBucket; // indicate the bucket num
};
修改其余简单部分
首先是添加一个哈希表,还有一个glo_tick(一开始用的trap.c的ticks,后面发现这个有bug,可能是会超限的缘故)。
binit中初始化锁,然后把每个buf都预先固定桶。
然后下面几个函数都比较简单,只要给桶加锁即可。
#define NBUCKET 23
struct {
struct spinlock lock;
struct buf buf[NBUF];
} bcache;
struct spinlock hashLock[NBUCKET];
uint glo_tick = 0;
void
binit(void)
{
struct buf *b;
initlock(&bcache.lock, "bcache");
for(int i = 0;i < NBUCKET;i++)
initlock(hashLock+i,"bcache");
for(int i = 0;i < NBUF;i++){
bcache.buf[i].hashBucket = i%NBUCKET;
bcache.buf[i].ticks = getTicks();
}
for(b = bcache.buf; b < bcache.buf+NBUF; b++){
initsleeplock(&b->lock, "buffer");
}
}
void
brelse(struct buf *b)
{
if(!holdingsleep(&b->lock))
panic("brelse");
releasesleep(&b->lock);
acquire(&hashLock[b->hashBucket]);
b->refcnt--;
b->ticks = glo_tick++;
release(&hashLock[b->hashBucket]);
}
void
bpin(struct buf *b) {
acquire(&hashLock[b->hashBucket]);
b->refcnt++;
release(&hashLock[b->hashBucket]);
}
void
bunpin(struct buf *b) {
acquire(&hashLock[b->hashBucket]);
b->refcnt--;
release(&hashLock[b->hashBucket]);
}
修改bget
第一部分是查找是否有缓存,主要就是里面有两次的确认,因为第一次比对的时候没有加锁,所以在加锁之前可能会被替换。如果在比对前加锁的话,冲突会比较大。
第二部分是执行替换策略,首先还是会重新查找一遍,因为在获得bcache之前,有可能有其他的进程刚刚替换出了同一个要查找的buf,所以为了维持最多只有一份副本的不变性,必须要先检查一遍,而这里比对只需要一次,因为此时只有本进程在bcache的临界区,拥有替换的能力。
最后是真正的替换,这里面会使用一个while循环,因为在寻找LRU buffer的时候我们并不加桶锁,因为加上去的话逻辑会很复杂。所以用一个while去做循环,如果在上面寻找到的evicP的引用数在后面处理的时候不再是0(某个进程刚好用到了准备要替换的cache buffer),就表示失败,重新查找。
static struct buf*
bget(uint dev, uint blockno)
{
struct buf *b;
// Is the block already cached?
for(int i = 0;i < NBUF;i++){
b = bcache.buf + i;
if(b->dev == dev && b->blockno == blockno){
// The buf may be replaced here.
acquire(&hashLock[b->hashBucket]);
// Check it again.
if(b->dev == dev && b->blockno == blockno){
b->refcnt++;
b->ticks = glo_tick++;
release(&hashLock[b->hashBucket]);
// release(&bcache.lock);
acquiresleep(&b->lock);
return b;
}
// If already replaced,go to eviction.
release(&hashLock[b->hashBucket]);
break;
}
}
acquire(&bcache.lock);
// Check again,for some process already put the buf in.
for(int i = 0;i < NBUF;i++){
b = bcache.buf + i;
if(b->dev == dev && b->blockno == blockno){
// The buf may be replaced here.
acquire(&hashLock[b->hashBucket]);
b->refcnt++;
b->ticks = glo_tick++;
release(&hashLock[b->hashBucket]);
release(&bcache.lock);
acquiresleep(&b->lock);
return b;
}
}
// choose the LRU buffer.
while(1){
uint minTime = -1;
struct buf *evitP = 0;
for(int i = 0;i < NBUF;i++){
b = bcache.buf + i;
if(b->refcnt == 0 && minTime > b->ticks){
evitP = b;
minTime = b->ticks;
}
}
if(evitP){
if(evitP->refcnt != 0)
continue;
acquire(&hashLock[evitP->hashBucket]);
evitP->dev = dev;
evitP->blockno = blockno;
evitP->valid = 0;
evitP->refcnt = 1;
evitP->ticks = glo_tick++;
release(&hashLock[evitP->hashBucket]);
release(&bcache.lock);
acquiresleep(&evitP->lock);
return evitP;
}
else
panic("bget: no buffers");
}
}
本方法实现的效率有点偏低,跟网上其他题解相比的话,主要的差别是没有真正利用哈希表的优秀的查找复杂性,而是用了扫描。