本次实验的要求是实现Buffer Pool Manager中的相关核心功能,总体来说相对于p0难度上升了不少:不仅涉及更复杂的系统逻辑,也是第一次较集中地使用c++特性、锁机制以及多线程并发,导致debug时间远远长于代码编写的时间。本文主要对整个实验过程进行回顾和总结,记录一下关键思路、踩过的坑以及相应的经验教训,为后续实验提供参考。
实验一介绍
本实验的目标是完成Buffer Pool Manager的具体实现。实验框架已经给出了完整的函数接口和整体结构,需要补充其内部逻辑实现。整体上,实验主要由以下三个部分组成:
Adaptive Replacement Cache(ARC)
Disk Scheduler
Buffer Pool Manager
下文将按照模块划分,分别总结各部分的设计思路,以及在实现过程中涉及到的C++用法和并发相关细节。
Arc Replacer
ARC Replacer主要负责记录页面的访问行为,并据此决定在页面换入与换出时应当淘汰的候选页。其核心目标是在不同访问模式下动态平衡最近访问与频繁访问页面,从而提升整体缓存命中率。
Arc Replacement Algorithm:
ARC的目标是在LRU(近期性)和LFU(频率性)之间自适应切换,避免人为设定比例,在不同访问模式下都能保持稳定性能。它的本质是通过ghost list观察“被淘汰后是否马上被访问”,动态调整MRU与MFU的比例(mru_target_size_),在LRU和LFU之间自动找到平衡点。
| 类型 | 含义 | 是否占用 frame |
|---|---|---|
| MRU | 最近只访问过一次的页面 | ✅ |
| MFU | 访问次数 ≥ 2 的页面 | ✅ |
| MRU ghost | 最近被淘汰、原本在 MRU 的页面 | ❌ |
| MFU ghost | 最近被淘汰、原本在 MFU 的页面 | ❌ |
当访问一个页面时,通过RecordAccess函数记录页面访问,四种情况如下:
Case 1:page在MRU或者MFU中(真实命中):将该页移动到MRU头部
Case 2:page在MRU ghost中命中:说明该页刚被淘汰又被访问,MRU不够大了,需要增大MRU target,
若|MRU ghost|≥|MFU ghost|:+1,否则:+|MFU ghost|/|MRU ghost|,上限replacer_size_,等于上限时该算法退化为LRU
Case 3:page在MFU ghost中命中:说明工作集更偏向频繁访问,需要减小MRU target,
若|MFU ghost|≥|MRU ghost|:−1,否则:−|MRU ghost|/|MFU ghost|,下限:0,等于下限时算法退化为LFU
Case 4:page完全不在replacer中(真miss)
4-a:MRU + MRU ghost == capacity
MRU相关历史已满,必须清理ghost,删除MRU ghost尾部,新page加入MRU头部
4-b:MRU + MRU ghost < capacity
若4表之和 == 2 x capacity:删除MFU ghost尾部,新page加入MRU头部;否则新page直接加入MRU头部
[!CAUTION]
mru_target_size_是如何影响决策过程?
mru_target_size_不直接决定谁被淘汰,它决定的是:当需要淘汰时,是优先从MRU还是MFU开刀。
它真正起作用的在Evict函数中:
1 | const bool use_mfu_first = mru_.size() < mru_target_size_; |
如果MRU比期望大,优先从MRU中淘汰;如果MRU比期望小,优先从MFU中淘汰
具体实现
C++ 匿名函数(Lambda)的使用
Evict()函数负责选择一个牺牲帧,并返回帧号,由于先从MRU中搜索和先从MFU中搜索代码结构相似,这里在Evict()内部定义了匿名函数。相比于单独定义成员函数,lambda函数有以下优势:
作用域更小:匿名函数仅在当前函数内部可见,不会污染类接口或命名空间
语义更集中:相关逻辑与其使用位置紧密相邻,阅读代码时更容易理解整体流程
天然支持捕获上下文:可以直接访问当前对象的成员变量或局部状态,避免额外的参数传递
在 C++ 中,lambda 通过 捕获列表(capture list) 指定其可访问的外部变量。常见的捕获方式包括:
按值捕获 [x]:在定义 lambda 时拷贝变量的值,适合只读或不依赖外部变化的场景
按引用捕获 [&x]:直接引用外部变量,允许在 lambda 内部修改其状态
隐式捕获 [=] / [&]:分别表示按值或按引用捕获当前作用域内所需的所有变量
捕获 this / [*this]:允许在 lambda 中访问类的成员变量,其中 this 捕获的是指针,而 *this(C++17 起)则按值拷贝当前对象
减少样板代码:在不牺牲可读性的前提下显著降低重复代码量
一般来说,当某段逻辑只在当前函数中有意义、且与当前上下文强相关时,使用 lambda 往往是更合理的选择;而当逻辑本身具有独立语义或需要被多处调用时,仍应考虑将其提升为普通成员函数。
[!NOTE]
捕获列表的引入使得 lambda 不再只是语法糖形式的函数,而成为一种能够携带定义时上下文状态的轻量级函数对象。这一机制避免了通过冗长参数列表或额外成员函数来传递局部状态,使得局部逻辑的封装更加自然且安全。
std::list查找效率的优化
在 ARC Replacer 的实现过程中,MRU 和 MFU 采用了 std::list 以支持 O(1) 的插入、删除和移动操作。然而,std::list 本身并不支持高效的随机访问或查找:若仅通过遍历链表来定位某个元素,其时间复杂度为 O(n),在高频访问场景下会带来明显的性能开销,无法通过实验的性能要求。
为了解决这一问题,引入了额外的 位置索引结构,即为每个链表维护一个从 frame_id 到对应链表节点迭代器的映射(总共4个映射)。通过这种方式,可以在 O(1) 时间内定位目标节点,并配合 std::list::splice 实现节点在不同链表之间的高效移动。
1 | std::list<frame_id_t> mru_; |
这是一种典型的“链表 + 哈希索引”的组合设计模式,代价是额外的内存开销以及在插入、删除、移动节点时需要同步维护索引结构,但在性能敏感的场景下,这一权衡是合理且必要的。
Disk Scheduler
这一部分主要负责将读磁盘与写磁盘请求统一调度并提交给 DiskManager 执行。其核心职责在于对外提供异步接口,并将具体的 I/O 请求封装后放入任务队列中,由后台线程顺序取出并执行。在实现过程中需要正确处理线程编程相关的问题,包括后台工作线程的管理,以及基于 std::promise / std::future 的异步结果传递机制。
具体实现
std::thread的工作机制与使用总结
std::thread 是 C++ 标准库对操作系统原生线程的轻量级封装。每个 std::thread 对象对应一个实际的系统线程,线程之间共享进程地址空间中的堆、全局变量以及打开的系统资源,这是能用&对变量进行捕获的「前提条件」
std::thread 在构造时接收一个可调用对象作为线程入口,该对象可以是普通函数、成员函数指针,或更常见的 lambda 表达式。在工程实践中,lambda 由于能够自然地捕获上下文状态,往往是最常用的线程入口形式。
std::thread 采用的是**“构造即启动”**的执行模型:一旦 std::thread 对象被成功构造,线程便会立即向操作系统注册并进入可调度状态
后台线程的创建与启动过程:
1 | background_thread_.emplace([&] { StartWorkerThread(); }); |
这行代码在语义上完成以下三个步骤:
在
std::optional内部原地构建一个std::thread对象;在构建过程中,向操作系统请求一个新的线程;
新创建的线程立即开着并执行传入的可调用对象。
主线程可以通过以下两种方式管理线程的结束:
调用 join(),阻塞当前线程,直到目标线程执行完毕并完成资源回收;
调用 detach(),将线程与 std::thread 对象分离,由系统在其结束后自动回收资源。
std::promise,std::future的工作机制
promise 和 future 是线程之间结构传递和同步的机制,promise / feature 的一切行为都是在操作同一块「共享状态」:
1 | ┌──────────────────────────┐ |
1 |
|
promise已经拥有shared state,get_future提供一个future句柄,指向同一块state,state引用+1,此后future和promise各自独立
promise.set_value()写结果+唤醒等待着,如:
1 | promise.set_value(true); |
获取shared state的锁,然后写入结果,shared_state.value = true, 如果是异常则 shared_state.exception = exception,然后设置就绪状态,然后唤醒所有等待线程,最后释放锁
future.wait()只等,不取
1 | future.wait(); |
获取shared state的锁,检查ready状态,ready==true则返回,否则阻塞等待,set_value()将其唤醒后再次检查。wait不会消耗结果,可以调用多次。
future.get()等 + 取 + 销毁读权限
1 | bool x = future.get(); |
如果还没有ready,则等待,ready后读取结果,之后放弃shared state,所以get()只能调用一次,之后shared state可能随即被销毁(如果promise已经被销毁了)
Buffer Pool Manager
BufferPoolManager负责在磁盘与内存之间调度数据库页,并管理它们在内存中的生命周期。具体来说:
从磁盘读page到内存
将脏页写回内存
内存满时,选择并淘汰page
对外提供安全的page访问接口
1 | DiskManager ← DiskScheduler ← BufferPoolManager → ArcReplacer |
DiskManager: 负责实际的磁盘读写工作
DiskScheduler: 负责异步调度disk read / write.
ArcReplacer: 负责决定 Evict 哪个frame
FrameHeader: 表示一个内存 Frame, 所有对 page 的访问必须通过 FrameHeader.
具体实现
CheckReadPage / CheckWritePage 实现
定位: CheckReadPage和CheckWritePage逻辑几乎相同, 负责由page_id → 找到/分配frame → 必要时flush/读入 → 返回对应PageGuard.
两把锁: bpm_latch_保护BPM的共享结构, frame rwlock保护每个frame的页内容与该frame的读写并发
基本锁顺序: 先拿bpm_latch_ 找 frame, 然后再拿rwlock
核心逻辑过程:
1 | 拿到 bpm_latch_ 后: |
[!CAUTION]
必须保证: 提交 IO 之前, 不能释放 bpm_latch_, 必须在持有 bpm_latch_ 的情况下发起 IO 请求.
假设 IO 不在 bpm_latch_内, 会出现语义不一致, 情景如下
1 | T1: 开始 eviction |
T2 以为自己读到的是 old_page 的最新内容, 但 dirty 数据尚未 flush, 磁盘上的 old_page 是旧内容
如果这时候已经提交了 IO 申请, 那么 T2 在读 old_page 磁盘内容时, 磁盘调度器知道这一页有一个 flush 请求, 它就能正确处理
NewPage: 并发安全生成 page_id
NewPage 需要在多线程下返回唯一的 page_id, 避免两个线程拿到同一个 id, 最开始没有把这个函数写成并发安全的, 导致没有通过测试
C++的原子计数器是可以把”自增+返回旧值”做成一个原子操作, CPU/编译器保证其为一个原子动作.
1 | page_id_t pid = next_page_id_.fetch_add(1); |
实验总结
随机初始化和ASan冲突
由于 ASLR, 在进程启动早期,libc / PIE 被随机映射到了虚拟地址空间中的某些位置, 如果这些映射恰好落在 ASan 预期的 shadow 区间内, 那么当 ASan runtime 随后尝试 mmap 该 shadow 区时, 就会发现地址冲突并直接 abort.
关闭 ASLR 后, 主程序与 mmap 区域落在固定, 可预测的位置, ASan就可以稳定映射它的shadow memory, 可用setarch关闭地址随机:
1 | setarch $(uname -m) -R ./program |
ASan的工作机制:
编译时(-fsanitize=address), 编译器在每一次内存访问前后插入检查代码, 这些检查会在运行时验证是否越界, 是否use-after-free, 是否访问了非法区域
shadow Memory: ASan 为程序的真实内存建立一份影子内存, 映射关系固定,例如:
8 bytes real memory → 1 byte shadow memory
shadow byte 记录这 8 字节区域是否:可访问, 部分可访问, 不可访问, 从而触发错误报告
PIE与ASLR:
PIE(Position Independent Executable) 是一种编译/链接属性, 它决定生成的不是传统可执行文件, 而是ELF Type = ET_DYN 文件, 它决定的是这个程序能不能被随机加载
非 PIE 程序: ELF Type = ET_EXEC, 链接时已确定固定虚拟地址
PIE 程序: ELT Type = ET_DYN, 链接时不绑定固定基址, 由 loader 运行时决定映射地址
ASLR(Address Space Layout Randomization)是操作系统/内核级机制, 为新进程随机选择地址空间布局, 它决定的是程序要不要随机加载
为什么ASLR会与ASan冲突
Linux 4.12 之后, 内核将 ET_DYN(PIE)可执行文件纳入与共享库相同的高地址 ASLR 随机区间, 这显著提高了进程早期映射落入 ASan shadow 区的概率,
从而导致 ASan 在初始化阶段发生地址空间冲突.
用到的同步工具总结
| 工具 | 解决的问题 | BusTub 中的典型用途 |
|---|---|---|
std::atomic |
单变量的并发读写与可见性保证 | pin_count_ 等计数或轻量状态, 避免频繁加锁 |
std::mutex / std::shared_mutex |
保护多变量组成的共享不变量(临界区) | page_table_、free_list_、replacer 内部结构;frame 的读写锁 |
std::thread |
提供独立的执行流 | DiskScheduler 的后台 worker 线程, 持续处理 I/O 请求 |
std::promise / std::future |
跨线程的结果传递与完成通知 | 磁盘读写请求的完成信号, 前台等待后台 I/O 结束 |