[15-445 fall 2023] P0 + P1解析

使用GradeScope的建议

建议:使用GradeScope进行打分测试的时候,我们可以通过使用std::cout来输出查看测试代码的函数调用顺序和参数是什么,从而明白到底是什么样的case没有过。

PROJECT #0 - C++ PRIMER

第一个项目主要用来熟悉高版本C++的新特性。

需要仔细查看trie.h中的每个类的属性。

工程简介

我们需要实现一个支持copy-on-write的key-value形式存储的前缀树。

这个cow trie对于每次修改操作都会生成一个全新的trie。而修改前的trie还是依旧存在的。这就确保了,trie的历史状态全都可以保留下来,如果我们想要回到任意个操作前的trie的状态都可以轻松实现,十分便于回滚操作。

此外,cow trie还能够让读写操作并行执行,由于写操作会生成一个新的trie,因此写操作期间不会对旧的trie进行任何修改操作,读操作可以正常进行读取操作。这大大提高了性能。

其余部分非常简单,分别是实现k-v store、debug和SQL相关的功能。

举例说明cow trie的特性:

原来的tire中仅有(ab,1)和(ac,val)这两个k-v值。

对这个trie进行修改操作,插入一个新的值(ad,2),这个新的插入操作会生成一颗新的trie,我们可以看到key = ad,因此从根节点开始,需要两个节点来表示a和d,这两个节点都都和插入的key-val值有关,因此这些节点都需要新建,而那些完全无关的节点则可以直接进行复用。

我们可以看到从根节点开始a、d路径的两个节点都为新节点,其余无关节点都可以直接复用。

具体一些细节将在下面的代码环节进行展开讲述。

代码实现

Trie::Get方法

这个方法非常简单,我们考虑的一个特殊情况就是,root_为nullptr的时候,那么我们就直接返回nullptr即可。

我们依次遍历key的值,每次取一个char,从节点的children_中进行查找。

如果遍历中途找不到节点了,那就返回nullptr;如果能找到最后的节点,那就尝试将节点类型从TrieNode通过dynamic_cast转换为TrieNodeWithValue类型。

如果节点本身不携带值,即最后找到的节点为TrieNode而非TrieNodeWithValue类型,那么dynamic_cast转换将返回nullptr值,反之就是有值,返回值的指针即可。

Trie::Put方法

方法获得两个值分别为key和value。首先Put方法会修改Tire树的结构,因此我们需要新建一颗Trie树。

然后key指定的路径上的节点都会发生更新,因此那些节点全都需要新建一个。

特殊情况:我们需要考虑的就是key有可能为空字符串。

此外,我们要进行复用原节点的children_的时候,直接通过Clone()方法来获取一个浅拷贝获取的新TrieNode,然后再这个新的TrieNode上进行子节点的调整即可。

同时我们在遍历key的值的时候,可能有两种可能性,如果发现原路径上没有节点,那么就新建一个节点,反之就是获取该老节点的一个克隆,并基于这个克隆节点进行调整其父节点的children_的值。

当我们遍历完key值,如果节点不存在那么就新建一个TrieNodeWithValue节点即可,反之就是复制原节点的children_值,然后配合上value来构造一个即可。

这里需要注意的一点就是我们利用value来构建一个TrieNodeWithValue的时候,千万要使用std::move(value)来传递value值到构造函数中。如下所示,不然编译会出错,看报错结果可能是因为这个一个模板方法,value的值有可能是unique_ptr,如果不通过std::move来进行传递,就会发生方法中的局部变量value在退出这个方法后进行析构,从而释放了这个unique_ptr中指定的内存。

node = std::make_shared<TrieNodeWithValue<T>>(std::make_shared<T>(std::move(value)));
node = std::make_shared<TrieNodeWithValue<T>>(node->children_[item]->children_,
                                                      std::make_shared<T>(std::move(value)));

Trie::Remove方法

方法获得一个key值,删除指定key值的TrieNodeWithValue节点。由于这个操作也会改变原Trie树的结构,因此我们也需要新建一颗Trie树来表示删除后的新的Trie树的结果。同理,key指定路径上的节点都会发生更新,那些节点全都需要新建一个。

特殊情况:key值可能是空字符串;以及指定的TrieNodeWithValue节点被删除后,如果这个节点也没有任何儿子节点,那么就要将该节点删除,同理,随着该节点的删除,可能其父节点也会丢失唯一的儿子节点,那么父节点也要删除,依次回退到根节点进行考虑。

针对特殊情况中,节点没有儿子节点后需要删除,我使用两个链表来记录被删除的链路上每个节点,依次来遍历检查处理。

std::list<std::shared_ptr<TrieNode>> node_store;
std::list<char> key_store;

Remove方法中,除此之前需要考虑的东西就是非常简单了,在遍历搜寻指定的TrieNodeWithValue节点的过程中,发现找不到节点了,那么表明该节点不存在,直接返回nullptr即可,或者找到的节点为TrieNode并不携带值,那么也返回nullptr即可。

TrieStore::Get、Put、Remove方法

这里的代码实现非常简单,我们仅需知道一个原则:每次操作针对Trie的克隆对象即可,因此我们每次先申请一个临时对象

root_lock_.lock();
// 我们每次操作前都先申请一个临时对象,后续操作都是基于这个克隆对象来进行,从而避免了多线程对同个Trie操作造成的并发问题
auto root = root_;
root_lock_.unlock();

后续在考虑Put和Remove这两个会进行修改的操作,修改操作都是基于当前的值进行修改,因此修改操作获取当前值也需要按序进行,因此,可以通过write_lock_来实现互斥操作。

但是这里要求读写操作可以同时进行,因此写操作需要确保在写执行过程中不占有root_locl_。

Debug

最简单的环节,可以通过cout的方式来输出,或者就直接通过gdb来进行调试获取即可。

const uint32_t CASE_1_YOUR_ANSWER = 10;
const uint32_t CASE_2_YOUR_ANSWER = 1;
const uint32_t CASE_3_YOUR_ANSWER = 25;

SQL String Functions

这里关键在于类的作用和关系梳理清楚,需要些的代码非常简单。

通过std::transfrom来进行大小写转换即可

打分图

PROJECT #1 - BUFFER POOL

前两个项目都比较简单直接放一块写了,第二个项目就是主要是实现LRU-K算法和Buffer pool,还有一点磁盘调度的简单实现。

工程简介

在项目中,很多类的成员变量都是提前帮你定义好了,你要用就启用就好。就是跟BufferPoolManager、DiskSchedular、LRUKReplacer打交道。基本也没啥难度,但是有一些默认的特性需要知道,不然拿不满分。

写的时候,大家可能还需要注意的就是一些函数提前return了,那么都需要及时将锁给释放掉,C++中没有defer可以用,因此在很多地方,我们会忘记释放锁导致死锁。还有就是读写锁的使用,能够确保实现的性能相比简单粗暴的互斥锁好不少。

注意:!!!这里关于锁还有一个问题需要注意,在Page里面会有一个读写锁,buffer pool manager中的方法对Page进行操作的过程中,针对Page的元数据像pin_count等数据的修改无需对这个读写锁进行操作,因为元数据只有bpm可以控制修改,而bpm已经有latch_来进行保证有序读写,Page中的读写锁主要保证的是Page的data是被有序访问读写的。但是其中的Flush函数的调用过程中,涉及到读取pin_count非0的Page的data读取,因此这个方法还是需要获取读锁。不然在P2中实现就会出现很多问题。

代码部分

lru_k_replacer.h

首先LRU算法在Leetcode也是一道非常非常经典的题目了,因此直接就使用类似哈希链表的原理来进行实现了。

同时,LRU-K算法要求,唯有一个物理内存页被访问的次数超过K次,该页面才会加入到LRU链表中,因此访问次数未到K次的内存页都会按照FIFO的方式来进行淘汰。

可以看到下面,我的修改,新增了两条链表,其中一条链表作为FIFO策略来作用到那些访问次数未到K次的内存页,另一条链表用作为LRU-K策略来作用到那些访问次数超过K次的内存页。新增的节点也要记得在析构函数中delete掉。

由于我们每个内存页帧frame对应一个LRUKNode,因此我直接创建了一个内存池大小相同的LRUKNode的list,后续想要使用LRUKNode直接从node_list_中取即可,根据frame_id_。其实node_list_可以理解为哈希表,由于我们使用的内存页帧始终都是内存池中的那些内存页,因此无需使用哈希表,改用数组即可。

// add a pool size LRUKNode list
// avoid repeatedly new LRUKNode
LRUKNode *node_list_;
LRUKNode *head_node_;// LRU-K链表的头节点
LRUKNode *tail_node_;// LRU-K链表的尾节点
LRUKNode *head_inf_node_;// FIFO链表的头节点
LRUKNode *tail_inf_node_;// FIFO链表的尾节点

由于链表必须是一个双向链表,因此LRUKNode也需要进行调整,在类中新增了两个成员变量pre和next,分别用来记录该节点的前后节点的地址。

LRUKReplacer::Evit()

该函数的作用为挑选一个物理页交换出去。

策略:

1、首先淘汰FIFO链表中那些访问次数不足的内存页,按照FIFO策略进行淘汰,同时我们还需要确保指定的内存页是可被淘汰的,也就是is_evictable的值为true,同时更新curr_size的值,同时还需要将对应的LRUKNode进行Reset操作,也就是对fid_ , pre_ , next_ , history_ , is_evictable_等属性进行初始化为未使用的状态。

2、如果FIFO链表中没有节点,那么就从LRUK链表中找,操作同上;

3、如果两条链表都没有找到可以淘汰的内存页,那就返回false.

LRUKReplacer::RecordAccess()

该函数用于记录访问了指定frame_id的内存页,要求每个LRUKNode记录访问的时间戳,其实这个时间戳并不需要严格记录下具体时间,只要时间戳能够体现出先后顺序即可,因此我们使用current_timestamp_即可,每次记录后,将该变量+1即可。

策略:

1、如果这个内存页之前没有被用,那么就将对应的LRUKNode进行更新,将fid_值由-1更新为frame_id_,同时更新history_。当然还需要将这个LRUKNode插入到FIFO链表中(假设LRU-K的K值大于1),由于该内存页的访问次数没有达到K次;

2、如果这个内存页之前被用过了,那么就往该内存页对应的LRUKNode的history_中插入最新的时间戳,如果history_的长度大于等于K了,那么就将该节点放到LRUK链表的表头,反之就不做处理。

LRUKReplacer::SetEvictable()

该函数用于将指定的内存页设置成可交换或不可交换。

策略:

1、frame_id指定的内存页如果没有在用,那就无需设置的;

2、查看内存页对应的LRUKNode的is_evictable_和设定值是否一致,如果是一致那就无需调整,如果不一致,那就更新该设定值,如果该Node被调整为可交换的,那么curr_size+1,反之-1.

LRUKReplacer::Remove()

该函数用于移除释放指定内存页,这里默认frame_id一定是合法的,因此需要加上assert,如果frame_id不合法直接程序崩溃。

策略:

移除指定的内存页,并更新节点所处的链表状态,以及Reset LRUKNode的状态

DiskScheduler::Schedule()

将收到的IO请求放到管道里即可

DiskScheduler::StartWorkerThread()

工作线程来执行这个函数,作用就是不断执行管道中的IO请求,直接用一个while(1)循环即可,同时我们还需要注意结束的条件,如果参数request是std::nullopt时,那就要break这个循环,结束线程。

BufferPoolManager::NewPage()

注意该Page不是内存页的意思,数据库中数据是存储在页中的,可以理解为新建一个数据页用于存储数据,这些数据页类比为存储文件。我们需要一个内存页来存放这个新建的数据页的内容。

策略:

查看free_list_中是否有空余的内存页,如果有那就拿一个出来;如果没有就通过Evict来交换出一个空闲的内存页。如果这两个方式都无法获得一个内存页帧,那么就返回nullptr;

取出了一个空闲内存页帧:调用AllocatePage()获取新页的页号,并在page_table_中完成内存页和数据页的映射,此外还需要对这个内存页完成初始化,page_id_设置好,将pin_count置为1,is_dirty置为false。同时还需要调用LRUK算法中的RecordAccess完成内存页的访问记录,并将对应的LRUKNode标记为不可交换出去的。

交换出了一个内存页帧:由于这个内存页中的数据将要被清除,因此,如果该内存页的数据为脏的,那么还需要将这些数据落盘,后续还在page_table_中清除该内存页和原数据页的映射关系,后续就是完成page_table_的映射操作,并初始化page,以及更新LRUKNode。

BufferPoolManager::FetchPage()

获取指定数据页的数据,有两种情况,一种情况为数据页的数据就在内存中;另一种情况是数据页目前保存在硬盘上,需要读取到内存中。

数据页的数据在内存中:在page_table_我们可以通过page_id找到对应的内存页,那么就相当于对该内存页进行了一个访问,我们需要对该内存页LRUK算法的RecordAccess完成访问记录,并对该内存页的pin_count_++

数据页不在内存中:在page_table_中找到指定的数据页的映射,那么需要一个内存页来存放读取的数据页的数据,这个思路等同于NewPage中的实现方法,此处不赘述,获取到一个空白的可用的内存页后,调用DiskScheduler从硬盘上读取数据页到指定的内存页,其余步骤相同。

BufferPoolManager::UnpinPage()

当一个内存页被一个线程使用结束后,那么就可以调用该方法来Unpin,当一个内存页的pin_count_变为0 的时候,表明没有线程使用该内存页了,那么该内存页就可以被交换出去了。

注意:当该内存页为脏的时候,UnpinPage方法无法将其设置为非脏。

通过page_table_找到指定的内存页,然后查看pin_count_是否为0,若大于0则-1,然后函数的参数将内存页的脏位进行标记,但是内存页是脏的不能将其标记为非脏的。

如果pin_count_-1后变为了0,那么表明该内存页已经没有线程在用了,那么这个内存页就可以被交换出去了,因此就需要通过LRUK算法将该内存页标记为可交换的。

BufferPoolManager::FlushPage()/FlushAllPages()

FlushAllPages直接通过For循环内存页来依次调用FlushPage即可

FlushPage方法,如果能找到指定的内存页,那就通过Disk Scheduler来将内存页中的数据落盘,同时将内存页的脏位置0,这个过程记得要上写锁,因此可能这个Page的引用次数非0,这个Page的data还有其他线程正在使用修改。

BufferPoolManager::DeletePage()

该方法是将指定的数据页不再占用内存池,并非是删除数据页。

如果指定的内存页的pin_count_并非为0,那就删除失败。

如果指定的内存页是脏的,那就先将这个内存页落盘,然后再将这个内存页空出来。

打分图

  • 16
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值