项目中涉及到的C++语法
C++11中尾返回类型
auto Find(const K &key, V &value) -> bool; 是 C++11 中的函数声明方式,采用了 尾返回类型(trailing return type) 的语法。
在函数声明中,auto 表示函数的返回类型由尾部的 -> bool 指定。
这样写的好处是,当返回类型较复杂时,代码会更加清晰。
函数返回类型是布尔值(bool)。
explicit 的作用
当一个构造函数标记为 explicit 时,编译器不允许通过单参数的构造函数进行隐式类型转换。只有显式调用构造函数时,才能创建对象。
MyClass obj = 42; 会隐式调用构造函数 MyClass(int value)。
将构造函数声明为 explicit 后,编译器禁止 MyClass obj = 42; 这种隐式转换。
必须显式调用构造函数 MyClass obj(42);。
STL中list的使用
list不支持随机访问[],但可以通过迭代器遍历。
在C++标准模板库(STL)中,std::list 是一个双向链表容器,适用于频繁的插入和删除操作。
std::list<int> myList;
std::list<int> myList = {1, 2, 3, 4, 5};
std::list<int> myList(5, 10); // 5个元素,每个值为10
myList.push_back(6);
myList.push_front(0);
//插入到指定位置
auto it = myList.begin(); // 指向第一个元素
std::advance(it, 2); // 移动到第3个位置
myList.insert(it, 99); // 在第3个位置插入99
删除所有匹配的元素:
myList.remove(99); // 删除值为99的所有元素
//排序
myList.sort();
myList.sort([](int a, int b) { return a > b; }); // 降序排序
myList.reverse();
myList.clear();
//合并两个链表
std::list<int> otherList = {7, 8, 9};
myList.merge(otherList); // 合并后otherList变为空
分配内存
Bucket first_bucket = new Bucket; 不正确,因为 new Bucket 返回的是指针。
根据需求选择合适的方法:
栈上分配:Bucket first_bucket;。
堆上分配:Bucket* first_bucket = new Bucket();,记得 delete。
使用智能指针:std::shared_ptr 或 std::unique_ptr。
如果要在堆上分配内存,但不想手动管理其生命周期,使用智能指针
std::shared_ptr<Bucket> first_bucket = std::make_shared<Bucket>();
// 或
std::unique_ptr<Bucket> first_bucket = std::make_unique<Bucket>();
定义和声明
在 C++ 中,定义和声明是两个重要的概念,它们在语法和功能上有所不同。理解它们的区别对于编写模块化代码和处理编译器的错误至关重要。
- 声明(Declaration)
声明告诉编译器某个变量、函数或类的存在及其基本信息(如类型、参数等),但不会为它分配内存或提供实现。使编译器了解某些符号(变量、函数、类)的存在,以便在使用它们时不会报错。
特点
- 仅提供“接口”信息。
- 不生成代码,只是让编译器知道某个实体的类型或存在。
- 通常出现在头文件或 .h 文件中。
- 定义(Definition)
定义为变量、函数或类提供具体的实现。这不仅让编译器知道它的存在,还会为变量分配内存,或为函数提供具体实现。
特点
- 生成代码或分配内存。
- 变量定义会分配存储空间。
- 函数定义提供函数体。
- 类定义提供成员变量和方法的实现。
override关键字
auto Remove(const K &key) -> bool override;
override 是 C++11 引入的关键字,标记该函数是重写基类的虚函数。
提高代码的可读性,让其他开发者一眼就能看出这是一个重写函数。
C++ 风格的 Cast
static_cast<size_t>强制类型转换
int x = 5;
double y = static_cast<double>(x); // C++ style cast
const int x = 5;
int* y = const_cast<int*>(&x); // 去掉 const
位运算
// 创建一个掩码,最低的n位是1,其他位是0
size_t mask = (1 << n) - 1;
// 使用按位与运算获取最低n位
size_t lowest_n_bits = key_index & mask;
1 << n 将 1 左移 n 位,得到一个值,其最低的 n 位是 1,其他位是 0。例如,当 n = 5 时,1 << 5 的结果是 32,即二进制为 100000。
(1 << n) - 1 会将掩码的最低 n 位设为 1,其他位设为 0。例如,当 n = 5 时,掩码是 11111,即二进制为 31。
key_index & mask 会将 key_index 的最低 n 位与掩码进行按位与运算,保留最低的 n 位,其他位设置为 0。
取数据类型最大值
size_t victim_timestamp=std::numeric_limits<size_t>::max();
多线程
多线程安全的背景
在哈希表这种数据结构中,多个线程可能同时进行插入、删除、查找等操作。如果没有适当的同步机制,可能会导致以下问题:
数据竞争(Data Race):多个线程同时修改同一个数据结构,导致数据不一致。
不一致的视图:一个线程在读取数据时,另一个线程修改了数据,导致读取到的值不可靠。
为了避免这些问题,通常使用锁(如 std::mutex)来同步线程之间的操作。例如,在 GetNumBuckets() 中加锁,确保在获取当前桶的数量时,其他线程无法修改哈希表的状态,从而保证返回值的一致性。
latch_ 这个 std::mutex 是 ExtendibleHashTable 的成员,它保护整个哈希表的数据结构,因此,当前代码锁的是整个哈希表实例。
//latch_ 是一个 std::mutex 对象,用来保护对哈希表内部状态(如 num_buckets_)的访问。
//std::scoped_lock 是一种 RAII 风格的锁,它会在作用域结束时自动解锁
std::scoped_lock<std::mutex> lock(latch_);
return GetNumBucketsInternal();
锁的策略
如果多个线程可能并发修改哈希表的数据(如插入、删除),这些操作就需要加锁。
读取操作如果会读取到共享数据(如桶的数量、某个桶的内容等),也需要加锁。
不同的加锁策略可以优化性能,比如细粒度锁或读写锁,视具体的应用场景和并发需求而定。
哈希策略
mask 的作用是将哈希值限制在某一范围内。在你的代码中,mask 是通过 1 << global_depth_ 计算的,并减去 1,这样得到的 mask 具有 global_depth_ 个最低位是 1,其他位是 0。这意味着:
std::hash()(key) & mask 只保留哈希值的最低 global_depth_ 位,抛弃其他高位。这是为了确保哈希表的索引是根据 global_depth_ 动态调整的,使得哈希表能够正确地管理桶的划分
可扩展哈希
主要类与成员
ExtendibleHashTable 类:
模板参数 K 和 V 分别代表键和值的类型。
继承自 HashTable<K, V>,是具体的哈希表实现。
包括管理全局深度(global_depth_)、桶大小(bucket_size_)以及桶数量(num_buckets_)的私有成员。
int global_depth_; // The global depth of the directory
size_t bucket_size_; // The size of a bucket
int num_buckets_; // The number of buckets in the hash table
mutable std::mutex latch_;
std::vector<std::shared_ptr<Bucket>> dir_; // The directory of the hash table
- Find:
- 寻找指定键对应的值。
- 如果找到,返回 true,并通过参数返回值。
- 使用 IndexOf(key) 来定位键对应的桶索引。
- Insert:
插入键值对,如果键已存在,更新值。
当桶满时,可能需要:
- 扩展目录大小并增加全局深度。
- 增加桶的局部深度。
- 拆分桶并重新分配键值对。
- Remove:
- 删除指定键对应的键值对。
- 仅移除,不需要合并或缩小桶。
Bucket 类:
表示一个哈希表中的桶。
包含了与桶的深度(depth_)、存储容量(size_)以及存储数据的列表(list_)相关的逻辑。
TODO:
find: 在桶中查找键。
insert: 插入键值对(若桶满则返回 false)。
Remove: 删除键值对。
编译Debug
1.
template <typename K, typename V>
auto ExtendibleHashTable<K, V>::Bucket::Remove(const K &key) -> bool {
// UNREACHABLE("not implemented");
for(const auto& pair:list_){
if(pair.first==key){
list_.remove(pair);
return true;
}
}
return false;
}
代码乍一看没有问题,但是clang+clangd给出了很奇怪的报错
Replace loop by 'std::any_of()'clang-tidy(readability-use-anyofallof)
问题分析:
std::list 是双向链表:当你在 Remove 方法中使用 for (const auto& pair : list_) 时,它实际上是一个基于范围的循环,底层实现会不断解引用迭代器,这对于链表来说是可以的。但问题出现在你试图删除正在遍历的元素。
list_.remove(pair) 的问题:在 std::list 中,remove 会遍历整个列表并删除与 pair 匹配的所有元素。你在 for 循环中试图使用 remove 来删除元素,但 remove 本质上需要一个元素来比较,并且一旦在遍历过程中调用删除操作,list_ 内部结构可能会被改变,导致遍历发生错误。
解决方案:
使用 std::list::erase 来删除元素。erase 可以安全地删除遍历过程中找到的元素。
在删除元素时,我们不能直接用 for 循环,因为如果在循环中删除了元素,后续的元素会移动到之前的位置,从而改变迭代器的状态。正确的方法是用 erase 删除元素,并确保我们处理好迭代器。
2.
你遇到的错误提示 Non-const lvalue reference to type 'List_iterator<…>’ cannot bind to a temporary of type 'List_iterator<…>’ 是因为 std::list::erase 返回的是一个临时对象(返回值是一个新的迭代器)。在 iter = list.erase(iter) 中,iter 是一个非 const 的左值引用,而 list.erase 返回的是一个临时的右值迭代器,编译器不允许将临时右值绑定到一个非 const 的左值引用。
3.
for(size_t i=0;i<dir_.size();i++){
dir_.push_back(dir_[i]);
}
如果这么写,毫无疑问会导致死循环,正确的做法是先用变量保存目录的大小
size_t dir_size=dir_.size();
for(size_t i=0;i<dir_size;i++){
dir_.push_back(dir_[i]);
}
测试Debug
1.find和remove函数中错误地将目录大小用桶的数量来表示
运行所有禁用的测试
./test/extendible_hash_table_test --gtest_also_run_disabled_tests
第一次test
DISABLED_SampleTest只有几个样例没通过
DISABLED_ConcurrentInsertTest样例不通过集中在65 和66
进行修改:
将类ExtendibleHashTable中的Find和Remove方法进行了调整:
//将代码变更
if(static_cast<size_t>(num_buckets_)<key_index+1){
return false;
}
//下面的才是对的,因为会出现多个目录指针指向同一个桶的情况,dir_.size()并不等于桶的个数num_buckets_
if (key_index >= dir_.size()) {
return false;
}
2.key_index没有重新计算
SampleTest的测试全部通过,ConcurrentInsertTest的测试依旧没有通过
说明我的代码没有处理好并发问题。
还是未能解决。
按理说我的insert函数在一开始就锁住了,同一时间只有一个线程能insert,不应该会有问题才对。
找到bug了:
确保在分裂后重新计算 key_index
在每次分裂后,重新计算 key_index 以反映最新的目录结构。这可以防止在分裂后使用旧的 key_index 导致错误的分裂。
因为key_index的计算机是基于global_depth的,当global_depth改变时,key_index也需要重新计算。
while(true)
{
size_t key_index=IndexOf(key);
if(dir_[key_index]->Insert(key,value)) {break;}
3. 地址溢出问题
dir_[key_index+(1<<(dir_[key_index]->GetDepth()-1))]=new_bucket;
需要更新指针的桶不止一个,应遍历,全部更新。
LRU-K替换策略
数据结构选型
采用了:
std::unordered_map<frame_id_t, std::list<size_t>> access_history_;//保存每个frame的访问历史
优化成了:
std::unordered_map<frame_id_t, std::deque<size_t>> access_history_;
std::unordered_set<frame_id_t> evictable_frames_;//保存可以逐出的帧集合
但是使用list双向链表来维护访问历史的话,Evict函数中,遍历evictable_frames_时,对于每个帧均要循环k次找到记录时间,平方级复杂度。
所以改成了deque。
std::list:非常适合插入和删除操作,尤其是中间位置的插入删除(通过迭代器),时间复杂度为 O(1)。但是,随机访问的性能较差,需要 O(n) 的时间来遍历。
std::deque:支持高效的随机访问(O(1)),并且在头尾插入和删除也非常高效。但是,在中间插入和删除时性能较差,需要 O(n) 的时间。
因此,如果你需要经常在容器的两端进行插入或删除操作,且不太需要随机访问元素,std::list 是一个不错的选择。如果你需要在两端进行快速插入删除,并且还需要高效的随机访问,std::deque 会是更好的选择。
操作 | std::list | std::vector | std::deque |
---|---|---|---|
访问元素 | O(n)(需要遍历链表) | O(1)(随机访问) | O(1)(随机访问) |
插入到尾部 | O(1) | O(1)(如果没有扩容) | O(1)(常数时间) |
插入到头部 | O(1) | O(n)(需要移动元素) | O(1)(常数时间) |
插入到中间 | O(1)(需要给出迭代器) | O(n)(需要移动元素) | O(n)(需要移动块) |
删除尾部元素 | O(1) | O(1)(常数时间) | O(1)(常数时间) |
删除头部元素 | O(1) | O(n)(需要移动元素) | O(1)(常数时间) |
删除中间元素 | O(1)(需要给出迭代器) | O(n)(需要移动元素) | O(n)(需要移动块) |
空间效率 | O(n)(每个节点有前后指针) | O(n)(元素连续存储) | O(n)(块内元素连续,块间可能不连续) |
内存访问模式 | 不连续(频繁缓存不命中) | 连续(较高的缓存命中率) | 每个块内连续,块间不连续(缓存命中率较高) |
扩容时性能 | O(1)(链表的插入不受影响) | O(n)(需要复制元素到新内存) | O(n)(可能需要扩展多个块) |
适用场景 | 频繁插入删除,低访问要求 | 高效随机访问,低插入删除要求 | 高效头尾操作,支持随机访问 |
Debug
1.指针类型传参数
auto LRUKReplacer::Evict(frame_id_t *frame_id) -> bool {}
对于函数参数为指针类型,如果要赋值:
frame_id = &victim;//这样写并不对
作用:改变 frame_id 指针,使其指向 victim 的地址。
意义:仅在函数内部有效,调用者的指针不会被修改,因为传递的是指针的拷贝。
*frame_id = victim;//应该这样写
作用:将 victim 的值赋给 frame_id 所指向的内存位置。
意义:修改调用者传入的变量,使其值为 victim。
2. Evict中access_history_清除历史
没有注意在Evict函数中,逐出页时,也要把对应历史清除掉
access_history_.erase(victim);
3. 并发的bug(其实没有,是测试用例的问题)***
在提交到https://www.gradescope.com/网站进行评分时,LRUKReplacerTest.Size.ASAN (0/6)测试用例一直无法通过。
为了找出他的问题,我使用了GPT提供的测试用例。
TEST(LRUKReplacerTest, ConcurrentAccess) {
LRUKReplacer lru_replacer(100, 2);
auto set_evictable = [&lru_replacer](int start, int end) {
for (int i = start; i < end; ++i) {
lru_replacer.SetEvictable(i, true);
}
};
auto remove_evictable = [&lru_replacer](int start, int end) {
for (int i = start; i < end; ++i) {
lru_replacer.SetEvictable(i, false);
}
};
std::thread t1(set_evictable, 0, 50);
std::thread t2(set_evictable, 50, 100);
std::thread t3(remove_evictable, 25, 75);
t1.join();
t2.join();
t3.join();
EXPECT_EQ(lru_replacer.Size(), 50);
}
这个测试用例时而通过,时而不通过,我就以为是程序的并发出现了问题。
其实是这个测试的写法不对。
#include <iostream>
#include <shared_mutex>
#include <mutex>
#include <thread>
#include <vector>
std::shared_mutex latch_; // 共享互斥锁
std::vector<int> shared_data(100,0); // 共享数据
void set_evictable_true (int start,int end) {
std::unique_lock<std::shared_mutex> lock(latch_);
for (int i = start; i < end; ++i) {
shared_data[i]=1; // 将指定范围内的元素标记为可驱逐
std::cout << "set_evictable_true: " << i << " = 1\n"; // 调试输出
}
};
void set_evictable_false (int start,int end) {
std::unique_lock<std::shared_mutex> lock(latch_);
for (int i = start; i < end; ++i) {
shared_data[i]=0; // 将指定范围内的元素标记为可驱逐
std::cout << "set_evictable_false: " << i << " = 0\n"; // 调试输出
}
};
int main() {
std::thread t1(set_evictable_true, 0, 50);
std::thread t2(set_evictable_true, 50, 100);
std::thread t3(set_evictable_false, 25, 75);
t1.join();
t2.join();
t3.join();
int num=0;
for(int val:shared_data){
if(val) num++;
}
std::cout<<num<<std::endl;
return 0;
}
单独编写为上述文件,可以发现程序是先执行的t3线程,在执行的t1和t2线程!!!
GPT给的不一定是对的,一定要记住!!!
主要原因:线程执行顺序
在多线程编程中,线程的执行顺序是非确定性的。
由于线程调度的非确定性,导致最终结果是所有元素都被设置为 1。
正确方案:
为了避免这种竞争条件,你可以调整线程之间的执行顺序,确保线程 t3 完成其任务(即设置索引 25 到 74 为 0)之后,再执行线程 t1 和 t2。你可以通过 std::condition_variable 或其他同步机制来控制线程的执行顺序,确保每个线程的执行顺序是合理的。
if (static_cast<size_t>(frame_id) > replacer_size_) {
// throw std::invalid_argument("Invalid frame_id in SetEvictable");
return;
}
4.
在提交到https://www.gradescope.com/网站进行评分时,LRUKReplacerTest.Size.ASAN (0/6)测试用例一直无法通过。size()计算一直有问题。
使用了GPT提供的其他的测试用例,发现在加入的page序号等于replacer的大小时,测试出错。
截取测试用例如下:
TEST(LRUKReplacerTest, EvictAllFrames) {
LRUKReplacer lru_replacer(3, 2);
lru_replacer.SetEvictable(1, true);
lru_replacer.SetEvictable(2, true);
lru_replacer.SetEvictable(3, true);
EXPECT_EQ(lru_replacer.Size(), 3);
}
TEST(LRUKReplacerTest, MixedOperations) {
LRUKReplacer lru_replacer(5, 2);
lru_replacer.SetEvictable(5, true);
EXPECT_EQ(lru_replacer.Size(), 3);
lru_replacer.SetEvictable(3, false);
EXPECT_EQ(lru_replacer.Size(), 2);
}
找到了在SetEvictable()函数中,
线程池实例
函数说明
1. NewPgImp
功能说明
NewPgImp 用于在缓冲池中创建一个新页面。如果缓冲池已满且没有可用的替换帧(即所有帧都被固定),则返回 nullptr。否则,它会:
从自由列表或替换器中选择一个替换帧。
如果选择的帧包含脏页面,则将其写回磁盘。
分配一个新的 page_id 并初始化新的页面。
更新页表和替换器。
将帧的固定计数设为 1(表示被固定)。
实现步骤
- 加锁:获取互斥锁以保护共享数据结构。
- 选择替换帧:
优先从 free_list_ 中获取可用帧。
如果 free_list_ 为空,则从 replacer_ 中选择一个替换帧。 - 检查是否有可用帧:
如果没有可用帧,则返回 nullptr。 - 处理被替换的页面:
如果选择的帧当前包含一个页面,并且该页面是脏的,则将其写回磁盘。
从页表中移除被替换页面的映射。 - 分配新页面:
调用 AllocatePage() 获取一个新的 page_id。
读取新页面的内容(初始化为空或从磁盘读取)。 - 更新页面元数据:
设置页面的 page_id。
重置页面内容。
设置页面为非脏。
固定页面(设置 pin_count 为 1)。 - 更新页表和替换器:
将新页面的 page_id 映射到帧。
将帧从替换器中移除,因为它现在被固定。
解锁并返回:释放锁并返回新页面的指针。
2. FetchPgImp
功能说明
FetchPgImp 用于从缓冲池中获取指定 page_id 的页面。如果页面不在缓冲池中,则需要:
从自由列表或替换器中选择一个替换帧。
如果替换帧中的页面是脏的,则将其写回磁盘。
从磁盘读取所需页面到替换帧中。
更新页表和替换器。
增加页面的固定计数。
如果所有帧都被固定且没有可用帧,则返回 nullptr。
实现步骤
- 加锁:获取互斥锁以保护共享数据结构。
- 查找页面:
在 page_table_ 中查找 page_id 是否存在。
如果存在,获取对应的帧。
增加该页面的 pin_count。
从 replacer_ 中移除该帧(因为它现在被固定)。
返回页面指针。 - 如果页面不在缓冲池中:
从 free_list_ 或 replacer_ 中选择一个替换帧。
如果没有可用帧,则返回 nullptr。 - 处理被替换的页面:
如果选择的帧包含脏页面,则将其写回磁盘。
从页表中移除被替换页面的映射。 - 读取新页面:
从磁盘读取 page_id 对应的页面到选择的帧中。 - 更新页面元数据:
设置页面的 page_id。
重置页面内容(从磁盘读取的数据)。
设置页面为非脏。
固定页面(增加 pin_count)。 - 更新页表和替换器:
将新页面的 page_id 映射到帧。
从 replacer_ 中移除帧(因为它现在被固定)。 - 解锁并返回:释放锁并返回页面指针。
3. UnpinPgImp
功能说明
UnpinPgImp 用于解除对指定 page_id 的页面的固定。如果页面的 pin_count 降至 0,且 is_dirty 为 true,则将页面标记为脏,并将其添加到替换器中,表示该页面可以被替换。
实现步骤
- 加锁:获取互斥锁以保护共享数据结构。
查找页面:
在 page_table_ 中查找 page_id 是否存在。
如果不存在,返回 false。 - 检查 pin_count:
如果 pin_count 已经为 0,返回 false。 - 更新页面元数据:
减少页面的 pin_count。
如果 is_dirty 为 true,将页面标记为脏。 - 更新替换器:
如果 pin_count 降至 0,表示页面现在可以被替换,将其添加到 replacer_。 - 解锁并返回:释放锁并返回 true。
4. FlushPgImp
功能说明
FlushPgImp 用于将指定 page_id 的页面刷新到磁盘,不论该页面是否被标记为脏。刷新后,页面的 dirty 标志被取消。
实现步骤
- 加锁:获取互斥锁以保护共享数据结构。
- 查找页面:
在 page_table_ 中查找 page_id 是否存在。
如果不存在,返回 false。 - 写回磁盘:
使用 DiskManager::WritePage 将页面数据写回磁盘。 - 更新页面元数据:
将页面的 dirty 标志设为 false。 - 解锁并返回:释放锁并返回 true。
5. FlushAllPgsImp
功能说明
FlushAllPgsImp 用于将缓冲池中的所有页面刷新到磁盘。这包括所有脏页面和非脏页面。
实现步骤
- 加锁:获取互斥锁以保护共享数据结构。
- 遍历所有页面:
对于每个页面,如果其 page_id 不为 INVALID_PAGE_ID,则调用 DiskManager::WritePage 将其写回磁盘。
重置页面的 dirty 标志。 - 解锁:释放锁。
6. DeletePgImp
功能说明
DeletePgImp 用于从缓冲池中删除指定 page_id 的页面。如果页面被固定(pin_count > 0),则无法删除,返回 false。否则,删除页面并释放其帧。
实现步骤
- 加锁:获取互斥锁以保护共享数据结构。
- 查找页面:
在 page_table_ 中查找 page_id 是否存在。
如果不存在,调用 DeallocatePage(page_id) 并返回 true。 - 检查 pin_count:
如果页面的 pin_count > 0,则无法删除,返回 false。 - 处理页面:
如果页面是脏的,则将其写回磁盘。
从页表中移除页面的映射。
重置页面的元数据。
将帧添加回 free_list_。 - 释放页面:
调用 DeallocatePage(page_id) 模拟释放磁盘上的页面。 - 解锁并返回:释放锁并返回 true。
Debug
1.段错误
问题根源在于如下代码:
frame_id_t *new_frame_id = nullptr;
// 来到替换池中寻找是否有可逐出的帧
replacer_->Evict(new_frame_id);
new_frame_id 初始化为 nullptr: 你定义了一个指针 new_frame_id,但未分配实际内存,只是将其初始化为 nullptr。
传递空指针给 Evict: 如果 Evict 方法在内部试图通过 *new_frame_id 写入值(如设置要逐出的帧 ID),则会尝试解引用 nullptr,从而导致段错误(Segmentation Fault)。
解决方案
需要让 new_frame_id 指向一个有效的内存地址,供 Evict 写入数据。可以通过以下方式修复:
frame_id_t new_frame_id; // 使用实际变量
if (replacer_->Evict(&new_frame_id)) {
// 使用 new_frame_id
}
2.逻辑问题
在 NewPgImp 和 FetchPgImp 中,如果调用 Evict 成功后,代码没有正确处理 page_table_ 中旧页的移除。旧页的 (page_id, frame_id) 键值对需要在 page_table_ 中移除,否则可能会导致哈希表的键值对残留,进而引发错误。