本次 lab 是要重新设计 xv6 的内存分配器和块缓存来获取更高的并行性。要增加并行性通常要设计到更改数据结构和上锁策略来减少竞态。
Memory allocator (moderate)
原本的 allocator 中只有一个单独的 freelist,也就是说 page 的分配和释放都会频繁的产生竞争。所以给每个 CPU 设置一个单独的 freelist 和一个单独的锁。所以进程在不同的 CPU 上申请和分配 page 就可以并行运行了。如果当前 CPU 上 freelist 的为空的时候,就要去别的 CPU 那里偷取 page,这个时候依旧会产生竞争。
代码实现
首先修改 CPU 结构体,在每个 CPU 中加入一个之前的 kmem,让每个 CPU 可以单独的分配内存。
1 | struct run { |
通过 NCPU 参数可以知道,有 8 个 CPU,所以在初始化 alloctor 的时候将 freepage 分成 8 份,分别放入 8 个 CPU 的 kmem 中。其实也可以初始化的时候都放到一个 CPU 里,然后其它 CPU 去偷。但是我感觉这样会有一个冷启动的过程,不如在初始化的时候直接先分配了。
1 | void |
然后修改 kfree,在释放的时候申请当前 CPU 的 kmem 锁,将 page 释放到当前 CPU 的 freelist 中即可,注意开关中断。
1 | void |
最后是 kalloc,在分配的时候由于当前 CPU 的 freelist 可能已经为空了,那么就需要去其它 CPU 那里偷。
1 | void * |
去其它 CPU 那里偷的时候,由于要获取其它 CPU 的锁,所以会产生竞争。还有一种情况就是当前进程持有当前 CPU 的锁去偷其它 CPU 的时候,那个被偷的 CPU 可能也正在尝试偷当前 CPU,这样就会产生死锁。
可以选择在去别的 CPU 那里偷之前把当前 CPU 的锁释放掉,这样就不会产生死锁,破坏了死锁的必要条件。我在大佬的博客上看到说如果这个时候释放了锁,就会导致重复偷取 page。我想了一下,如果说在释放当前 CPU 的 kmem 锁之后,该进程被调度走了,另外一个进程又过来执行 kalloc,也发现当前 CPU 的 freelist 是空的,也会进行偷取。但是我感觉当前已经是关中断了,那么时钟中断也会被屏蔽,当前进程就不会被调度了,所以应该不会产生这种情况吧。这一块没有太懂,不过能通过测试,暂时先这样。
Buffer cache (hard)
这部分要做的事情跟第一部分的目的是一样的,修改 bcache 结构体,并降低锁的粒度。将原本的用一个大锁锁住整个双向链表的设计,拆分成一个 Hash Table,每个 bucket 一个锁,那么就可以使得访问不同 bucket 的进程并行访问 buffer cache。
代码实现
根据 hints,最基本的思路是很容易想到的。将 buffer 以哈希表的形式组织,bucket 的数量为 13 个,每个 bucket 需要一把锁。并且把原本的双向链表设计去掉,原本的双向链表是为了实现 LRU,但现在我们通过给每个 buffer 记录时间戳来实现 LRU,时间戳就为 kernel/trap.c 中的 ticks。
所以 bcache 结构改为以下形式:
1 | struct { |
在 buf 结构体中去掉 prev 指针,并加入 timestamp 字段,用于实现 LRU:
1 | struct buf { |
这样一来就不需要维护双向链表了,binit 的实现也很简单,只需要初始化锁并且将 bcache.buf 中的所有 buffer 都放进 bcache.buckets[0],并初始化每个 buf 的 sleeplock 即可。
1 | void |
在我最开始的实现中,bcache.lock 是用不上的(所以也没跑出正确答案)。但是根据 Hints:
Your solution might need to hold two locks in some cases; for example, during eviction you may need to hold the bcache lock and a lock per bucket. Make sure you avoid deadlock.
是需要上两把锁的,具体使用场景到后面再看。
由于我们现在不需要维护双向链表,在 brelse 的时候直接释放 buf 的 sleep 的 lock,然后再获取对应的 bucket 的 lock,将 refcnt– 即可,如果 refcnt == 0,则将当前的 ticks 设置为 buf 的 timestamp。
1 | void |
同样的修改 bpin 和 bunpin,这里就不贴出来了。
bget
整个 bget 是最折磨的地方,多线程场景下的问题还是太难发现了。
最开始我没有意识到问题的严重性,按照最初的思路,写了第一版代码,逻辑如下:
- 获取 dev 和 blockno 对应的 bucket 的锁;
- 如果已经有缓存了就直接返回 buf,否则执行 3;
- 在所有的 bucket 中找出一个 refcnt 为 0,并且最近最久未使用的 buf。在这个过程中需要对 bucket 上锁,遍历完一个 bucket 之后,释放它的锁;
- 如果找到了这样的 buf,就申请它所在的 bucket 锁并进行 eviction,将它从原本的 bucket 中移除,否则直接 panic;
- 将找到的 buf 插入 dev 和 blockno 对应的 bucket 中,并设置 buf 的值;
- 释放 dev 和 blockno 对应的 bucket 的锁;
- 获取找到的 buf 的 sleeplock;
- 返回 buf;
这样乍一看是没什么问题,但是事实并非如此。
首先一个容易注意到的点就是,会产生死锁。由于我们在发现没有缓存的时候,并没有释放刚刚获取的 bucket 的锁,然后就开始在所有 bucket 中找一个可以被 evict 的 buf,这时候要获取其它 bucket 的锁。那么如果一个进程 A 持有 bucket1 的锁,又去获取 bucket 2 的锁,但是进程 B 又持有 bucket2 的锁去获取 bucket1 的锁时,就产生了死锁。
所以在发现我们要找的 buf 并没有在缓存中时,要先释放当前持有的 bucket 的锁,再去进行 eviction。
第二个问题就是,当我们找到了那个可以被 evict 的 buf 时,将锁释放掉了,正式进行 evict 时,又去申请锁,但是在释放锁到重新申请锁的这个间隙,可能有其它进程又引用了刚刚找出来的那个 buf,使得它的 refcnt 不为 1,这个时候将其 evict 掉就会发生错误。
解决方案就是,在找出可以 evict 的 buf 后,不释放对应 bucket 的锁,而是直到 evict 之后再释放。那么其它进程在刚开始获取缓存的时候就会阻塞,因为它获取不到这个 bucket 的锁,待到 ecivt 结束后,它才能去查看 bucket,这时候它就看不到那个被 evict 的 buf 了。
完成以上两个修改之后,bcachetest 已经能够通过了。但是如果执行 usertests,第一个 manywrites 就无法通过,报的错误是 panic: freeing free block,即释放了一个原本已经释放的 block cache。而这种重复释放的原因,肯定就是同一个 block 被缓存了多次。
下面的思路来自于Miigon。
假设当前有两个进程同时访问同样缓存块,在第一个进程获取到对应的 bucket 锁后,发现不存在缓存,就释放了锁,进入寻找可 evict 的 buf 阶段。这时,第二个进程同样能够获取对应 bucket 的锁,并同样发现缓存不存在,也进入寻找可 evict 的 buf 阶段。
这个时候完全有可能发生的是,它们找到了两个不同的 buf,并且将它们 evict 掉之后都插入了它们要找的 block 对应的 bucket 中。这时就出现了同一块 block 有多个 cache 的情况。最后也就会触发 freeing free block。
这时候前文提到的那个 hint 就要回收了,再看一遍:
Your solution might need to hold two locks in some cases; for example, during eviction you may need to hold the bcache lock and a lock per bucket. Make sure you avoid deadlock.
再结合前一条:
It is OK to serialize eviction in bget (i.e., the part of bget that selects a buffer to re-use when a lookup misses in the cache).
现在想想其实就是暗示我们在寻找并 evict 可用 buf 的时候将整个流程串行化,保留最开始查看是否有缓存的并行。并且缓存丢失的概率一般来说都是非常低的,所以后面的串行化造成的性能损失其实是可以接受的。
所以现在利用上 bcache.lock,在发现缓存不存在释放掉对应 bucket 的锁之后,立刻获取 bcache.lock 这把大锁。由于在释放对应 bucket 锁到获取 bcache.lock 期间,可能有别的进程已经完成了对我们要找的 block 设置 cache,所以在获取完 bcache.lock 后,再进行一次查找缓存,如果发现已经存在了,就直接返回对应的 buf。
这样一来,就算有多个进程同时进入 bget,也只有第一个进程可以获取到 bcache.lock,并完成缓存的设置,后面的进程都会被获取完锁后的第二轮查找缓存给拦住。这样就避免了一个 block 被缓存多次。
下面是完整的代码:
1 | static struct buf* |
测试结果
总结
多线程场景下的问题真的很难搞,感觉在业务场景下,用一些常见的模式还没有那么难。到系统编程的场景下,这些问题都要自己考虑,解决一个问题之后可能又产生另一个问题,我自己对于这方面的能力还是太弱了。
引用一下Miigon大佬的总结:
锁竞争优化一般有几个思路:
- 只在必须共享的时候共享(对应为将资源从 CPU 共享拆分为每个 CPU 独立)
- 必须共享时,尽量减少在关键区中停留的时间(对应“大锁化小锁”,降低锁的粒度)
第一种思路就是本次 lab 的第一部分,只有当前 CPU 的 freelist 已经为空时,才去和其它的 CPU 共享 freelist,其它情况下都是并行执行的。
第二种思路就是本次 lab 的第二部分,bcache 是没有办法单独划分给每个 CPU 的,属于必须共享,所以只能通过缩小临界区,缩小锁的粒度来实现。
后续这方面还是得多多加强。