CMU 15-445 Bustub Project 1 - Buffer Pool Manager - Note
本文最后更新于 2026年9月28日 上午
前言
因为可能有机会去做与云数据库相关的工作,所以捡起这门之前一直想学但是没学的课做一下。
感觉难度确实很超标。
我基本是 AI 的“半推半就”下完成的,纯手写确实有点超出我的难度阈值了...(和 xv6 一样)。为自己的手写能力哀悼。
配环境
我的开发机是 Arch Linux,系统工具链是 clang 22 和 libstdc++ 16,而 Fall 2025 的 BusTub 使用 clang 15,但是 AUR 也不提供了,除非自己编译一个,但是这要太久了。
最后使用 Ubuntu 22.04 Docker 镜像统一编译环境。镜像安装 clang、clang-format、clang-tidy 15、CMake 和 BusTub 的构建依赖,仓库挂载进容器,构建产物放在独立的 build/ubuntu 目录。
仓库中的 build_support/run_in_ubuntu.sh 会在首次运行时自动构建镜像。先配置项目:
./build_support/run_in_ubuntu.sh cmake -S . -B build/ubuntu \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_C_COMPILER=/usr/bin/clang-15 \
-DCMAKE_CXX_COMPILER=/usr/bin/clang++-15 \
-DCLANG_FORMAT_BIN=/usr/bin/clang-format-15 \
-DCLANG_TIDY_BIN=/usr/bin/clang-tidy-15Debug 构建默认启用 AddressSanitizer。P1 的公开测试可以直接在容器内编译和运行:
for test in arc_replacer_test disk_scheduler_test page_guard_test buffer_pool_manager_test; do
./build_support/run_in_ubuntu.sh cmake --build build/ubuntu --target "$test" -j 4
./build_support/run_in_ubuntu.sh "./build/ubuntu/test/$test"
done提交前再检查格式和 clang-tidy:
./build_support/run_in_ubuntu.sh cmake --build build/ubuntu --target check-format
./build_support/run_in_ubuntu.sh cmake --build build/ubuntu --target check-clang-tidy-p1Project 的前置知识
其实也不多,只需要明晰「page」和「frame」之间的关系即可:
- page 是磁盘中的实际存在的页,虽然在 OS 里面被称作 Block
- frame 是内存里面对于磁盘的 page 的缓存,虽然这个在 OS 里面被叫 page
Task 1
Task 1 主要是要理清楚这个驱逐算法的根本。为了理清楚 ARC 的内涵:MRU、MFU 和两个 Ghost 列表之间的转换,这里画个状态机:
stateDiagram-v2
state "未记录" as None
state "MRU" as MRU
state "MFU" as MFU
state "MRU Ghost" as MRUG
state "MFU Ghost" as MFUG
[*] --> None
None --> MRU: 首次访问
MRU --> MFU: 再次访问
MFU --> MFU: 再次访问
MRU --> MRUG: Evict
MFU --> MFUG: Evict
MRUG --> MFU: 再次访问,增大 MRU 目标
MFUG --> MFU: 再次访问,减小 MRU 目标
MRUG --> None: 历史记录被裁剪
MFUG --> None: 历史记录被裁剪
MRU --> None: Remove
MFU --> None: Remove
图里画的是一个页目前在哪个列表里。MRU、MFU 记录还在内存中的页,Ghost 只留下被驱逐的页号,与 frame 无关。某个页此刻能不能被驱逐,由 evictable_ 控制。
理清楚之后其实就是翻译代码了。
不过,理解为什么要这样做,比单纯把自然语言翻译成代码要更有益。如果真的理解了 ARC,那么 MRU、MFU 和 Ghost 之间的转换都很自然:
- 命中 MRU Ghost,就说明刚被驱逐的页又被访问了,MRU 的目标大小往上调;
- 命中 MFU Ghost 则往下调。
- 驱逐时:如果
|mru_| >= mru_target_size_: 优先从mru_淘汰 否则: 优先从mfu_淘汰。BusTub 注释里有两个特殊点:mru_.size() == mru_target_size_时,也从mru_淘汰。- 如果优先侧全是
pinned / non-evictable,就尝试另一侧。 综上,简单来说就是根据mru_.size()和mru_targetsize决定优先淘汰侧,如果找不到就找对方,都没有就返回
我一开始的实现都是使用 std::find() 去链表里找,时间复杂度很差。后面在 FrameStatus 里面加了对应的迭代器:
- 节点放进列表时,将其列表中的迭代器存入,以后通过 map 直接找到。
- map 的查找平均是 $O(1)$,而且移动后原来的迭代器仍然有效。
不过,这只是省去了查找节点的那遍扫描;Evict() 找可驱逐节点时仍可能遍历列表。还有可优化的空间,但是我没做。
和 LLM 的问答记录里面总结的错过的点
alive_map_用frame_id查当前在内存中的记录,ghost_map_用page_id查历史记录,所以 Ghost 命中后不能直接把存着page_id的节点搬进存frame_id的 MRU/MFU。splice()的目标位置也必须是目标列表的迭代器。- 优先扫描的列表里没有可驱逐节点,不代表另一个列表也没有。
Task 2
Task 2 的话,其实理解了这些 API 之后就不难。换句话说,我觉得理解难度主要落在对框架代码和 C++ 不熟悉上面。
Disk Scheduler 有一个工作线程负责处理排队的请求。Schedule() 把 DiskRequest 放进 Channel,StartWorkerThread() 在后台取出请求,再调用 DiskManager 的 ReadPage() / WritePage()。对于 Go 程序员来说,Channel 其实很熟悉。这里工作线程退出靠的是往队列里放一个 std::nullopt,析构函数 join(),然后读到了 std::nullopt 就结束。
还有需要注意的是 std::promise<bool> 和 std::future<bool>。它们用来在线程之间传一次处理结果:
- 发起请求的线程调用
future.get(),结果还没准备好时就阻塞; - 工作线程做完 I/O,再通过 request 里的
promise.set_value(true)通知它,有点像用 Go 的 Channel 等一个结果。 - 不过我觉得和 JavaScript 的 Promise 毫无关系就是了,虽然名字一样。
Task 3
Task 3 的话就变得复杂起来了。主要还是代码之间的依赖太过于复杂,单纯读也理不清,让 AI 把调用链画出来也记不住,还不如先上手写,这样就自然了。
核心就是 PageGuard 和 BufferPoolManager。PageGuard 是访问一个页的唯一合法途径,里面封装了对页的操作。本来是分成 Read 和 Write 的,不过我拆成了继承的模式,把共同的成员和函数挪进抽象基类 PageGuard 里。
还有一个重点就是需要加成员变量,因为注释里面有说到 PageGuard 需要时刻持锁:
However, the existence of any
ReadPageGuardon a page implies that no thread can be mutating the page's data.
所以,读 Guard 持有页面的共享锁,写 Guard 持有独占锁:
// ReadPageGuard
std::shared_lock<std::shared_mutex> read_lock_{frame_->rwlatch_};
// WritePageGuard
std::unique_lock<std::shared_mutex> read_lock_{frame_->rwlatch_};需要注意的是 PageGuard 持有页面锁是从构造到 Drop() 的整段时间,因此不需要任何的临时上锁。
然后对于 BufferPoolManager,最复杂的还是 CheckedReadPage() 和 CheckedWritePage()。注释里说了三种情况,考虑是:
page_table_里已经有这个页:找到对应的 frame,不用读盘。- 页不在内存,但是
free_frames_里有空闲的 frame:拿来装这个页,需要读盘。 - 没有空闲的 frame:让 ARC 找一个可以驱逐的 frame,旧页如果是脏的就先写回磁盘,再读入要访问的页;如果谁都不能驱逐,就返回
std::nullopt。
里面并发锁的控制也很麻烦。最值得注意的,是 pin 的增减要和池锁配合:在 pin 从 0 变成 1、或者从 1 变成 0 时,还要同步修改 ARC 对这个 frame 的可驱逐状态。
不过我还是觉得 Flush 系列的函数最复杂,主要还是因为并发代码太难写了。什么时候上锁、什么时候不上,池锁和帧锁之间的关系,确实很复杂。
这里最值得注意的是:PageGuard::Flush() 可以依赖 Guard 已经持有的帧锁;BPM 的 FlushPage() 没有 Guard,所以要先在池锁下找到 frame、临时 pin++ 并设置不可驱逐,放开池锁后再取得帧锁写盘,最后把 pin-- 并按需设置可驱逐。
FlushPageUnsafe() 不允许持帧锁,但允许用池锁,来维护 page_table_ 访问的线程安全性。