CMU15-445 PROJECT1 BUFFER POOL MANAGER 项目记录
任务1:可扩展哈希表
TASK#1:
EXTENDIBLE HASH TABLE
个人认为通过网上的资料比听网课效率更高,所以本人通过参考各种网站以及博客来学习可扩展哈希表算法。 再加之项目注释写得也比较清楚了,按照注释以及项目主页的提示按部就班地进行编码,还是比较容易完成的。
可扩展哈希 是一种动态散列方法,其中目录(directory)和桶(bucket)用于散列数据。
主要特点及相关术语
目录:目录存储指向桶地址的指针。每个目录都有一个唯一的 id,每次扩展时该 id 可能会发生变化。哈希函数返回此目录 id,用于导航到适当的存储桶。
\(目录数 = 2^{Global Depth}\)。
桶:桶用于散列实际数据。如果桶的局部深度小于全局深度,则桶可能包含多个指向它的指针。
全局深度:与目录相关,表示哈希函数用于对密钥进行分类的位数。全局深度 = 目录 id 中的位数。
局部深度:与 全局深度 类似,只是 局部深度 关联的是桶而不是目录。根据全局深度的局部深度用于决定在发生溢出时要执行的操作。局部深度≤全局深度。
桶分裂(split):当桶中的元素数量超过特定大小时,桶被分成两部分。
目录扩展(expand):目录扩展发生在桶溢出时。当 溢出桶的 局部深度 等于 全局深度 时,执行目录扩展。
实现过程
Bucket类的实现
课程主页建议我们先完成 Bucket
类的编写,其中包括
Find(), Remove(), Insert()
三个方法的实现。
Bucket类内置list
结构,存储键值对。三个函数的实现方式都是遍历该list
。
Find()
在找到对应元素时返回 true,否则返回
false;Remove()
在找到对应元素时使用list.erase()
删除该元素并返回 true,若没有找到对应元素返回 false;
Insert()
在遍历过程中如果发现同样的键值则返回
false,否则使用 list.emplace_back()
插入键值对。
Insert()方法实现
首先理解一下项目给出的 IndexOf()
方法,因为
Insert()
方法的实现需要借鉴类似的思想。
1 |
|
对于给定的键值,首先根据全局深度计算mask,比如如果全局深度是5,则(1 << global_depth_)
为100000,mask则为11111,与mask按位与即保留原key哈希值的后5位,即得到对应目录的位置。
理解这个原理后先放一边,先讨论以下最简单的情况,也就是 桶无须分裂 的时候:
此时调用IndexOf()
找到的index对应的就是需要进行插入操作的桶。此时有两种情况:
* 如果键值已经存在,则修改key对应的value为新的值,并直接return即可; *
否则 直接调用 Bucket-> Insert()
在这个桶中插入键值对即可。
接着就是最复杂的部分,就是 需要目录扩展和桶分裂 的情况:
首先要知道,桶在一次插入的过程中可能需要进行多次分裂,所以我们需要进入while (dir_[IndexOf(key)]->IsFull())
循环。
多次分裂是什么情况呢?
一个桶装满的时候,桶中的key对应的hash后若干位(取决于局部深度)一定是一样的,但我们无法保证前面的几位是否就不一样,如果运气不好,在局部深度前的几位都是相同的话,则可能需要分裂出更多的桶来装,否则根本就装不进去。
如果全局深度等于局部深度,则目录需要扩展。
如何进行目录扩展?LRU-K中的k,其实是指最近访问页面的次数,所以我们不难理解,上文所介绍的LRU算法其实就是LRU-1,但是因为仅访问1次就能替代别人,可能会造成“缓存污染”的问题,因此提出了LRU-K的概念。
其核心思想就是将访问一次就能替代的“1”提升为"K"。
将目录的size扩展为原来的两倍,上面的一半最高位是0,下面的一半最高位是1.
下面的一半直接拷贝上面的一半,因为 要指向除最高位以外所有低位相同对应的桶。 杨是要进行分裂的。
如何将桶进行分裂?
首先弄出两个桶,再将原桶中的元素一个个装进去。这里分桶装也是根据key的hash和mask的按位与,只不过这里
1
int mask = 1 << target_bucket->GetDepth();
分完桶中的键值对之后,再将dir中的原先指向target_bucket的指针重新分配一遍。
注意这里我们的bucket都用shared_ptr,所以不用担心原来的bucket会怎么样。
遇到问题及解决方案
在本地测试的时候,进程停滞了,调试提示 1
auto bucket_0 = std::make_shared<Bucket>(bucket_size_, target_bucket->GetDepth() + 1);
原因是我的逻辑本身没有完善就拿去跑测试了。那为什么会停滞呢?那是因为在进行扩展时,需要将原有的桶分裂成两个新的桶,同时将哈希表中的指针进行重新分配。我的代码中实现了这个分裂过程,但是没有将新的桶的指针更新到哈希表中。这会导致新的键值对无法正确插入,最终导致进程停滞。
任务2:LRU-k页面置换算法
TASK#2:
LRU-K Replacement Policy
算法本身比较复杂,本人是先从leetcode的medium题lru入门,lru-k是在此基础上优化的一种算法。
先说什么是LRU,LRU是Least Recently Used的缩写,即最近最少使用算法。leetcode上是通过一个双向队列和一个哈希表实现的。具体来说:
维护一个队列,最近访问的在最底层,最近最少访问的在最上层。
当队列满了,有新的元素进队时,将最上层的元素出队列,将新元素放到队尾。
当访问已经存在于队列的中的老元素,将老元素放到队列的最后一位,其他元素往前补位。
而LRU-K中的k,其实是指最近访问页面的次数,所以LRU算法其实就是LRU-1,但是因为仅访问1次就能替代别人,可能会造成“缓存污染”的问题,因此提出了LRU-K的概念。
算法原理
与LRU算法不同,LRU-K算法需要维护两个队列:历史队列 和 缓存队列。
历史队列保存着每次访问的页面,而缓存队列则是保存已经访问k次的页面。
对于一个访问页面,有以下三种情况:
当页面访问次数达到了k次,就将其从历史队列中移除,并将其加入缓冲队列。
若页面访问次数尚未达到k次,则将其移到历史队列队首。
若页面访问次数大于k_次,将其移到缓冲队列队首。
当我们需要淘汰一个页面时,则先从历史队列淘汰,再从缓冲队列淘汰。这里LRU-k算法可能会有不同的说法,比如理解是 第k次访问距离现在最久 的那个页面。但在本项目中,根据项目注释,
驱逐操作中,找到具有最大后向 k 距离的帧并驱逐该帧。只有标记为“可驱逐”的帧才是驱逐的候选者。 具有少于 k 个历史值的帧的向后 k 距离将被认为是 +inf。 如果多个帧具有 inf 后向 k 距离,则驱逐具有最早时间戳的帧。 框架的成功驱逐应该减少替换器的大小并删除框架的访问历史记录。
这正好与先淘汰历史队列再淘汰缓存队列的操作吻合。
另外,我的实现类中有以下私有成员: 1
2
3
4
5
6
7
8size_t curr_size_{0};
size_t replacer_size_;
size_t k_;
std::mutex latch_;
std::unordered_map<frame_id_t, size_t> use_count_;
std::list<frame_id_t> his_list_, cache_list_;
std::unordered_map<frame_id_t, std::list<frame_id_t>::iterator> his_map_, cache_map_;
std::unordered_map<frame_id_t, bool> is_evictable_;curr_size_
记录当前可驱逐的页面数量;
use_count_
记录页面的使用次数;his_list_, cache_list_
分别是 历史队列 和
缓冲队列;his_map, cache_map_
分别记录页面在历史队列或者缓冲队列中的位置;is_evictable_
用于记录页面是否可以被驱逐。
遇到问题及解决方案
其实在做project1的时候,遇到的问题有很多,但是都没有及时记录,现在通过在gradescope的提交记录回忆一下。
最严重的问题其实是忘记加锁了,在万分仔细地检查逻辑之后发现没有加锁,导致了本地测试能过,而线上测试不能过。
另外,由于本地测试对于Remove()
方法的测试太弱了,导致线上Remove()
引发的问题很多,于是在修改这个方法上花了比较多的时间。对于这个方法,在按要求判断是否需要抛出异常时,需要判断!is_evictable_[frame_id]
而frame_id不一定存在在表中,所以要修改异常部分代码:
1
2
3if (is_evictable_.find(frame_id) != is_evictable_.end() && !is_evictable_[frame_id]) {
throw std::exception();
}
在Evict()
以及Remove()
方法中,需要驱逐或者删除页面的时候,要注意各个成员的erase()
顺序,否则会出现heap-use-aftrer-free
的错误。
任务3:缓冲池管理器实例
Task
#3 - Buffer Pool Manager Instance
这部分没有太难的数据结构与算法上的实现。让人眼花缭乱的是根据注释编写代码。
部分功能实现
比较复杂的是NewPgImp()
:
首先检查缓冲池中是否有可用的空闲页面。如果有,从空闲页面中选择一个页面。否则,从替换器中选择一个可替换的页面。
分配新的页面 ID。如果分配失败,则返回 nullptr。
更新元数据并pin页面。这里更新两个地方的元数据:
缓冲池中的 page_table_:从原有页面 ID 的映射中删除该页面,插入新的页面 ID 和缓冲池中选择的帧 ID 的映射。
帧表 frame_table_:重置帧的元数据和数据。更新页面 ID 和 pin 计数,并将 is_dirty_ 设置为 false。 将新页面插入缓冲池,并且返回该页面的指针。返回前要确保该帧被固定(Pinned),并且更新 replacer 访问历史。
以及 FetchPgImp()
:
在缓冲池中查找目标页面,如果找到则直接返回对应的 Page 对象,并将该帧设置为不可淘汰(即被固定)。
如果目标页面不在缓冲池中,则需要从磁盘中读取该页面并将其载入到缓冲池中。首先需要选择一个可用的帧(令其id为
frame_id
)来存放该页面,此过程与NewPgImp()
中的过程类似。如果找到了可用的帧,则需要将旧页面从该帧中移除,然后读取目标页面的数据并将其存入该帧中。如果该帧原来的页面是脏页面,则需要将其写回磁盘。
接着调用
disk_manager_->ReadPage()
从磁盘读取数据存到frame_id
处。最后需要将该帧设置为不可淘汰(即被固定),并将其访问历史记录到replacer中,以便在进行淘汰时能够选择最近最少访问的帧。然后返回
pages_[frame_id]
。需要注意的是,如果在创建新页面时发现内存不足,则会返回 nullptr。
过多的此处就不赘述了,task3就是按规矩办事,别多办别漏办别办错就行。
遇到问题及解决方案
出现LeakSanitizer: detected memory
leaks问题,并且在gradescope测试中的HardTest总是过不了,最后发现是在一个free_list_
的取帧操作中忘记pop了。
内存泄漏可能是程序的一个严重问题,可以导致程序崩溃、性能下降等问题,因此在开发过程中应该注意内存管理,避免出现内存泄漏问题。
最后附上通关图!!!