Lab: locks
Memory allocator (moderate)
Your job is to implement per-CPU freelists, and stealing when a CPU’s free list is empty. You must give all of your locks names that start with “kmem”. That is, you should call initlock for each of your locks, and pass a name that starts with “kmem”. Run kalloctest to see if your implementation has reduced lock contention. To check that it can still allocate all of memory, run usertests sbrkmuch. Your output will look similar to that shown below, with much-reduced contention in total on kmem locks, although the specific numbers will differ. Make sure all tests in usertests pass. make grade should say that the kalloctests pass.
kalloc()有一个空闲列表,由单个锁保护,这个在并发量较高时会产生争用的情况。
kalloc 原本的实现中,使用 freelist 链表,将空闲物理页本身直接用作链表项(这样可以不使用额外空间)连接成一个链表,在分配的时候,将物理页从链表中移除,回收时将物理页放回链表中。
这里解决性能热点的思路是「将共享资源变为不共享资源」。
锁竞争优化一般有几个思路:
- 只在必须共享的时候共享(对应为将资源从 CPU 共享拆分为每个 CPU 独立)
- 必须共享时,尽量减少在关键区中停留的时间(对应“大锁化小锁”,降低锁的粒度)
该实验实现方法主要如下:
- 为每个 CPU 分配独立的 freelist
- 在一个 CPU freelist 中空闲页不足的情况下,仍需要从其他 CPU 的 freelist 中“偷”内存页,所以一个 CPU 的freelist 并不是只会被其对应 CPU 访问,还可能在“偷”内存页的时候被其他 CPU 访问,故仍然需要使用单独的锁来保护每个CPU 的 freelist
// kernel/kalloc.c
struct {
struct spinlock lock;
struct run *freelist;
} kmem[NCPU]; // 为每个 CPU 分配独立的 freelist,并用独立的锁保护它。
char *kmem_lock_names[] = {
"kmem_cpu_0",
"kmem_cpu_1",
"kmem_cpu_2",
"kmem_cpu_3",
"kmem_cpu_4",
"kmem_cpu_5",
"kmem_cpu_6",
"kmem_cpu_7",
};
void
kinit()
{
for(int i=0;i<NCPU;i++) { // 初始化所有锁
initlock(&kmem[i].lock, kmem_lock_names[i]);
}
freerange(end, (void*)PHYSTOP);
}
// kernel/kalloc.c
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;
push_off();
int cpu = cpuid();
acquire(&kmem[cpu].lock);
r->next = kmem[cpu].freelist;
kmem[cpu].freelist = r;
release(&kmem[cpu].lock);
pop_off();
}
void *
kalloc(void)
{
struct run *r;
push_off();
int cpu = cpuid();
acquire(&kmem[cpu].lock);
if(!kmem[cpu].freelist) { // no page left for this cpu
int steal_left = 64; // steal 64 pages from other cpu(s)
for(int i=0;i<NCPU;i++) {
if(i == cpu) continue; // no self-robbery
acquire(&kmem[i].lock);
struct run *rr = kmem[i].freelist;
while(rr && steal_left) {
kmem[i].freelist = rr->next;
rr->next = kmem[cpu].freelist;
kmem[cpu].freelist = rr;
rr = kmem[i].freelist;
steal_left--;
}
release(&kmem[i].lock);
if(steal_left == 0) break; // done stealing
}
}
r = kmem[cpu].freelist;
if(r)
kmem[cpu].freelist = r->next;
release(&kmem[cpu].lock);
pop_off();
if(r)
memset((char*)r, 5, PGSIZE); // fill with junk
return (void*)r;
}
但是上述代码有可能会产生死锁的情况,cpu_a 持有自己的锁尝试偷 cpu_b,cpu_b持有自己的锁尝试偷 cpu_a,解决方案 https://github.com/Miigon/blog/issues/8
运行 kalloctest测试
$ kalloctest
start test1
test1 results:
--- lock kmem/bcache stats
lock: kmem_cpu_0: #fetch-and-add 0 #acquire() 35979
lock: kmem_cpu_1: #fetch-and-add 0 #acquire() 195945
lock: kmem_cpu_2: #fetch-and-add 0 #acquire() 201094
lock: bcache: #fetch-and-add 0 #acquire() 1248
--- top 5 contended locks:
lock: proc: #fetch-and-add 22486 #acquire() 132299
lock: virtio_disk: #fetch-and-add 16002 #acquire() 114
lock: proc: #fetch-and-add 11199 #acquire() 132301
lock: proc: #fetch-and-add 5330 #acquire() 132322
lock: proc: #fetch-and-add 4874 #acquire() 132345
tot= 0
test1 OK
start test2
total free number of pages: 32499 (out of 32768)
.....
test2 OK
Buffer cache (hard)
Modify the block cache so that the number of acquire loop iterations for all locks in the bcache is close to zero when running bcachetest. Ideally the sum of the counts for all locks involved in the block cache should be zero, but it’s OK if the sum is less than 500. Modify bget and brelse so that concurrent lookups and releases for different blocks that are in the bcache are unlikely to conflict on locks (e.g., don’t all have to wait for bcache.lock). You must maintain the invariant that at most one copy of each block is cached. When you are done, your output should be similar to that shown below (though not identical). Make sure usertests still passes. make grade should pass all tests when you are done.
bcache 中的区块缓存是会被多个进程(进一步地,被多个 CPU)共享的(由于多个进程可以同时访问同一个区块)。
原版 xv6 的设计中,使用双向链表存储所有的区块缓存,每次尝试获取一个区块 blockno 的时候,会遍历链表,如果目标区块已经存在缓存中则直接返回,如果不存在则选取一个最近最久未使用的,且引用计数为 0 的 buf 块作为其区块缓存,并返回。
新的改进方案,可以建立一个从 blockno 到 buf 的哈希表,并为每个桶单独加锁。这样,仅有在两个进程同时访问的区块同时哈希到同一个桶的时候,才会发生锁竞争。当桶中的空闲 buf 不足的时候,从其他的桶中获取 buf。
这一块我自己的思考的是直接采用每个桶单独加锁,这样就能很好避免多个锁的竞争。
但是网上参考很多的大佬的代码后,发现这一块没我想象的那么简单,很容易引发死锁问题,虽然我的代码能够通过测试机。
附上大佬的博客,这篇算是写的很详细了。
https://blog.miigon.net/posts/s081-lab8-locks/#%E6%AD%BB%E9%94%81%E9%97%AE%E9%A2%98
完整代码:
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;
uint lastuse; // *newly added, used to keep track of the least-recently-used buf
struct buf *next;
uchar data[BSIZE];
};
// kernel/bio.c
// bucket number for bufmap
#define NBUFMAP_BUCKET 13
// hash function for bufmap
#define BUFMAP_HASH(dev, blockno) ((((dev)<<27)|(blockno))%NBUFMAP_BUCKET)
struct {
struct buf buf[NBUF];
struct spinlock eviction_lock;
// Hash map: dev and blockno to buf
struct buf bufmap[NBUFMAP_BUCKET];
struct spinlock bufmap_locks[NBUFMAP_BUCKET];
} bcache;
void
binit(void)
{
// Initialize bufmap
for(int i=0;i<NBUFMAP_BUCKET;i++) {
initlock(&bcache.bufmap_locks[i], "bcache_bufmap");
bcache.bufmap[i].next = 0;
}
// Initialize buffers
for(int i=0;i<NBUF;i++){
struct buf *b = &bcache.buf[i];
initsleeplock(&b->lock, "buffer");
b->lastuse = 0;
b->refcnt = 0;
// put all the buffers into bufmap[0]
b->next = bcache.bufmap[0].next;
bcache.bufmap[0].next = b;
}
initlock(&bcache.eviction_lock, "bcache_eviction");
}
// Look through buffer cache for block on device dev.
// If not found, allocate a buffer.
// In either case, return locked buffer.
static struct buf*
bget(uint dev, uint blockno)
{
struct buf *b;
uint key = BUFMAP_HASH(dev, blockno);
acquire(&bcache.bufmap_locks[key]);
// Is the block already cached?
for(b = bcache.bufmap[key].next; b; b = b->next){
if(b->dev == dev && b->blockno == blockno){
b->refcnt++;
release(&bcache.bufmap_locks[key]);
acquiresleep(&b->lock);
return b;
}
}
// Not cached.
// to get a suitable block to reuse, we need to search for one in all the buckets,
// which means acquiring their bucket locks.
// but it's not safe to try to acquire every single bucket lock while holding one.
// it can easily lead to circular wait, which produces deadlock.
release(&bcache.bufmap_locks[key]);
// we need to release our bucket lock so that iterating through all the buckets won't
// lead to circular wait and deadlock. however, as a side effect of releasing our bucket
// lock, other cpus might request the same blockno at the same time and the cache buf for
// blockno might be created multiple times in the worst case. since multiple concurrent
// bget requests might pass the "Is the block already cached?" test and start the
// eviction & reuse process multiple times for the same blockno.
//
// so, after acquiring eviction_lock, we check "whether cache for blockno is present"
// once more, to be sure that we don't create duplicate cache bufs.
acquire(&bcache.eviction_lock);
// Check again, is the block already cached?
// no other eviction & reuse will happen while we are holding eviction_lock,
// which means no link list structure of any bucket can change.
// so it's ok here to iterate through `bcache.bufmap[key]` without holding
// it's cooresponding bucket lock, since we are holding a much stronger eviction_lock.
for(b = bcache.bufmap[key].next; b; b = b->next){
if(b->dev == dev && b->blockno == blockno){
acquire(&bcache.bufmap_locks[key]); // must do, for `refcnt++`
b->refcnt++;
release(&bcache.bufmap_locks[key]);
release(&bcache.eviction_lock);
acquiresleep(&b->lock);
return b;
}
}
// Still not cached.
// we are now only holding eviction lock, none of the bucket locks are held by us.
// so it's now safe to acquire any bucket's lock without risking circular wait and deadlock.
// find the one least-recently-used buf among all buckets.
// finish with it's corresponding bucket's lock held.
struct buf *before_least = 0;
uint holding_bucket = -1;
for(int i = 0; i < NBUFMAP_BUCKET; i++){
// before acquiring, we are either holding nothing, or only holding locks of
// buckets that are *on the left side* of the current bucket
// so no circular wait can ever happen here. (safe from deadlock)
acquire(&bcache.bufmap_locks[i]);
int newfound = 0; // new least-recently-used buf found in this bucket
for(b = &bcache.bufmap[i]; b->next; b = b->next) {
if(b->next->refcnt == 0 && (!before_least || b->next->lastuse < before_least->next->lastuse)) {
before_least = b;
newfound = 1;
}
}
if(!newfound) {
release(&bcache.bufmap_locks[i]);
} else {
if(holding_bucket != -1) release(&bcache.bufmap_locks[holding_bucket]);
holding_bucket = i;
// keep holding this bucket's lock....
}
}
if(!before_least) {
panic("bget: no buffers");
}
b = before_least->next;
if(holding_bucket != key) {
// remove the buf from it's original bucket
before_least->next = b->next;
release(&bcache.bufmap_locks[holding_bucket]);
// rehash and add it to the target bucket
acquire(&bcache.bufmap_locks[key]);
b->next = bcache.bufmap[key].next;
bcache.bufmap[key].next = b;
}
b->dev = dev;
b->blockno = blockno;
b->refcnt = 1;
b->valid = 0;
release(&bcache.bufmap_locks[key]);
release(&bcache.eviction_lock);
acquiresleep(&b->lock);
return b;
}
// ......
// Release a locked buffer.
void
brelse(struct buf *b)
{
if(!holdingsleep(&b->lock))
panic("brelse");
releasesleep(&b->lock);
uint key = BUFMAP_HASH(b->dev, b->blockno);
acquire(&bcache.bufmap_locks[key]);
b->refcnt--;
if (b->refcnt == 0) {
b->lastuse = ticks;
}
release(&bcache.bufmap_locks[key]);
}
void
bpin(struct buf *b) {
uint key = BUFMAP_HASH(b->dev, b->blockno);
acquire(&bcache.bufmap_locks[key]);
b->refcnt++;
release(&bcache.bufmap_locks[key]);
}
void
bunpin(struct buf *b) {
uint key = BUFMAP_HASH(b->dev, b->blockno);
acquire(&bcache.bufmap_locks[key]);
b->refcnt--;
release(&bcache.bufmap_locks[key]);
}
运行结果
$ bcachetest
start test0
test0 results:
--- lock kmem/bcache stats
lock: kmem_cpu_0: #fetch-and-add 0 #acquire() 32897
lock: kmem_cpu_1: #fetch-and-add 0 #acquire() 77
lock: kmem_cpu_2: #fetch-and-add 0 #acquire() 61
lock: bcache_bufmap: #fetch-and-add 0 #acquire() 6400
lock: bcache_bufmap: #fetch-and-add 0 #acquire() 6685
lock: bcache_bufmap: #fetch-and-add 0 #acquire() 6696
lock: bcache_bufmap: #fetch-and-add 0 #acquire() 7018
lock: bcache_bufmap: #fetch-and-add 0 #acquire() 6266
lock: bcache_bufmap: #fetch-and-add 0 #acquire() 4206
lock: bcache_bufmap: #fetch-and-add 0 #acquire() 4206
lock: bcache_bufmap: #fetch-and-add 0 #acquire() 2193
lock: bcache_bufmap: #fetch-and-add 0 #acquire() 4202
lock: bcache_bufmap: #fetch-and-add 0 #acquire() 2196
lock: bcache_bufmap: #fetch-and-add 0 #acquire() 4359
lock: bcache_bufmap: #fetch-and-add 0 #acquire() 4409
lock: bcache_bufmap: #fetch-and-add 0 #acquire() 6411
lock: bcache_eviction: #fetch-and-add 0 #acquire() 83
--- top 5 contended locks:
lock: proc: #fetch-and-add 397110 #acquire() 70988
lock: proc: #fetch-and-add 262715 #acquire() 70988
lock: proc: #fetch-and-add 222165 #acquire() 70987
lock: virtio_disk: #fetch-and-add 161088 #acquire() 1098
lock: proc: #fetch-and-add 45459 #acquire() 71331
tot= 0
test0: OK
start test1
test1 OK
$
多线程这块,锁的持有很容易造成死锁问题的产生,而想要有效解决,需要对底层指令和cpu运行原理有着很好的了解,我还有很长的一段路需要学习。