简介:这是东南大学操作系统课程的LRU页面置换算法实验资源包,面向正在学习虚拟内存管理和页面替换策略的本科生。资源以一份实验报告为核心,内含完整可运行的C++代码,覆盖LRU计数器实现、LRU栈实现、附加引用位算法和第二次机会算法,并对比分析每种算法的时间复杂度、空间复杂度和实现难度。实验通过随机生成页面访问序列测试页错误率,比较精确LRU与近似算法的性能差异。配套代码使用模板类lru_cache,结合std::unordered_map和std::list完成页面快速查找与访问顺序维护,实现put、get、exists等核心操作,有助于理解缓存淘汰策略的工程实现。报告包含设计思路、流程图、源程序注释与测试分析,补上学号姓名即可提交。资源包共1个doc文件,约1.72MB,已有1220人学习下载,适合需要快速完成实验、深入理解LRU算法与近似算法差异的学习者参考。
1. 为什么操作系统的LRU页面置换实验从“追去不现实”开始
东南大学这个操作系统实验把LRU算法和它的近似算法放在一起,点名了操作系统里一个很反直觉的事实:精确LRU在真实硬件上几乎不可实现。因为页面每次被访问时,操作系统拿不到一个统一、精确、可比较的“上次访问时间”;硬件只提供一个引用位,而且这个位什么时候清零、由谁清零,各平台差异很大。于是页面置换模块只能在“尽量像LRU”和“扫描开销可控”之间做取舍,由此派生出Clock、增强Clock这类近似算法。这个实验表面上在写页面置换模拟器,实际是在让你理解“最优策略为什么只存在于理论中”以及“近似到什么程度才够用”。正在做实验的学生、准备操作系统面试的人,还有写缓存淘汰模块的工程师,都能从这里找到值得认真对待的设计边界。
2. 精确LRU算法的核心:用哈希表加双向链表维持访问顺序
2.1 时间戳方案为什么不够用
最容易想到的LRU实现是给每个页框存一个last_access_time,每次访问时更新该页的时间戳,缺页时扫描所有页框找最小值。这个方案逻辑上完全正确,但开销无法接受。页框数是内存大小除以页面大小,普通服务器上通常是几十万到上百万个条目。每次缺页都做一次全表扫描找最小值,复杂度是O(n),而页面置换是发生在缺页异常处理路径上的,这段路径必须越快越好,否则系统吞吐量会被拖垮。
更隐蔽的问题是时间戳的精度和溢出。用jiffies这类系统节拍计数,粒度太大;用get_cycles()读CPU周期计数器,不同核之间又可能不一致。做实验时,这些问题会被掩盖,因为模拟器里只有一个进程在跑。但理解这个背景,才知道为什么LRU在真实内核里只被当作“参照系”而不是实现方案。
2.2 双向链表加哈希表的结构设计
精确LRU要做到O(1)访问和O(1)淘汰,常见做法是把访问顺序交给双向链表维护,把“页号到链表节点”的映射交给哈希表维护。链表头部是最近被访问过的页,尾部是最久未被访问的页。每次访问命中,就把对应节点摘下来挂到头部;缺页时若链表已满,直接删除尾部节点。
2.2.1 结构体定义
typedef struct lru_node { int page; // 页号,也就是这个slot代表的内容 int data; // 模拟页面数据,实验中可以存0 struct lru_node *prev; struct lru_node *next; } lru_node; typedef struct lru_cache { int capacity; // 最多容纳的页框数 int size; // 当前已使用的页框数 lru_node *head; // 链表头:最近访问 lru_node *tail; // 链表尾:最久未访问 lru_node **hash; // 页号到节点的哈希表 int hash_size; // 哈希桶大小 } lru_cache;这里把page当作键,data当作页面内容。hash_size一般取capacity * 2的质数,实验里直接用数组做简单哈希即可。真正交付的代码里,哈希冲突可以用链地址法,也可以直接用一个定长数组,只要页号范围已知。结构体里的head和tail是哨兵节点,可以避免大量对空指针的判断,推荐加上。
2.3 访问流程和O(1)复杂度分析
一次页面访问会被拆成两种路径。命中时,通过哈希表找到节点,把节点从当前位置摘出,插到head之后,哈希表不需要更新,因为节点地址没变。缺页时,先从尾部取淘汰节点,从哈希表中删除旧页号,再把新页号填进该节点并更新哈希映射,最后移动到头部。如果链表未满,则创建一个新节点插入头部。
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 查找页面 | O(1) | 哈希表定位节点 |
| 命中的顺序调整 | O(1) | 链表摘除和头部插入 |
| 缺页淘汰 | O(1) | 直接取下尾节点并复用 |
| 哈希冲突处理 | O(k) | k为单桶链表长度,合理设计下可忽略 |
这里的O(1)是一个平均意义上的复杂度。哈希函数一旦设计不好,退化成线性扫描,LRU就失去了意义。所以实验里hash_size不能太小,页号取模后尽量散开。另一个容易被忽略的点是:淘汰时复用被淘汰的节点,而不是每次都malloc和free,这样能减少动态内存分配带来的抖动,模拟结果也更稳定。
3. 用C语言把LRU页面置换算法写成可运行的模拟器
3.1 模拟页面访问的三个函数
模拟器的核心不是整个LRU类,而是三个函数:初始化、访问、清理。访问函数是整个实验最值得调试的地方。下面给出一个可行的实现片段,重点看节点摘除和头部插入的顺序。
void lru_access(lru_cache *cache, int page) { lru_node *node = hash_lookup(cache, page); if (node != NULL) { // 命中:先摘除节点,再插入头部 remove_node(node); push_front(cache, node); } else { // 缺页 if (cache->size == cache->capacity) { // 链表满:尾节点就是淘汰对象 lru_node *victim = cache->tail; hash_delete(cache, victim->page); victim->page = page; remove_node(victim); push_front(cache, victim); } else { // 链表未满:创建新节点 lru_node *node_new = make_node(page); hash_insert(cache, page, node_new); push_front(cache, node_new); cache->size++; } } }逻辑说明:hash_lookup负责根据页号找到链表节点,找不到就返回NULL。命中时,remove_node只是把节点的前后指针接上,不需要动哈希表。缺页且链表已满时,先hash_delete移除旧页号,然后把新页号直接写进被淘汰的节点,这样避免了malloc新节点和释放旧节点的开销。最后必须push_front,因为该页面刚刚被访问过。
参数说明:cache->capacity是实验设置的页框数量;cache->size只能在链表未满创建新节点时自增。要注意remove_node和push_front都必须正确处理节点被从链表中删掉后自己指针仍指向前后节点的情况,否则会出现指针悬挂。实现时建议让head和tail始终是空哨兵,这样边界情况会少很多。
3.2 完整模拟器主流程
主程序应该读取一个访问序列文件,逐行调用lru_access,同时统计命中次数和缺页次数。序列文件每行一个页号,实验里通常用脚本生成。下面给出主流程的关键部分:
int main(int argc, char **argv) { int capacity = atoi(argv[1]); FILE *fp = fopen(argv[2], "r"); int page, hits = 0, misses = 0; lru_cache *cache = lru_create(capacity); while (fscanf(fp, "%d", &page) == 1) { int before = cache->size; lru_access(cache, page); if (cache->size == before && page != cache->head->next->page) { // 这里不是严格判断,更稳妥的是在lru_access里返回是否命中 } } fclose(fp); lru_destroy(cache); return 0; }这段代码里的命中判断不推荐,因为lru_access返回void时,调用方无法直接知道这次是命中还是缺页。常见做法是让访问函数返回int,命中返回1,缺页返回0。主循环里累加这个返回值即可。上面写出反例是为了提醒你:模拟器的统计逻辑不能依赖cache->size的变化,因为满容量时缺页替换也不会改变size。
更好的设计是让lru_access返回缺页次数,缺页返回1,命中返回0。主程序直接misses += lru_access(cache, page),然后用hits = total - misses计算命中率。缓存命中率不是计算出来的,而是由访问序列长度减去缺页次数得到的。
3.3 参数说明和测试用例
实验时至少需要两组参数:页框容量和访问序列长度。容量从4到64逐步增加,观察缺页率变化。测试序列可以先构造一个没有局部性的随机序列,再构造一个循环序列1 2 3 4 1 2 3 4 ...,后者能让LRU的表现与FIFO差异明显。
- 随机序列:每个页号均匀分布,LRU和FIFO差距不大。
- 循环序列:页框数等于序列中的不同页数时,LRU能保持全部命中;FIFO则会周期性缺页。
- 局部性序列:模拟80%访问落在20%页面上,LRU和近似算法的差距开始显现。
可以用下面这行命令生成随机序列:
awk 'BEGIN { for (i = 0; i < 10000; i++) print int(rand() * 100) }' > random_ref.txt参数解释:int(rand() * 100)将随机数映射到0到99的页号范围,10000是访问次数。随机种子没有显式设置,导致每次生成的序列不同,这未必是坏事,做对比实验时可以保留多份序列文件以便复现。要得到可复现结果,可以先用srand(42)固定种子,再用awk输出。
4. 近似LRU的课堂实现:Clock算法和增强Clock算法
4.1 近似LRU的基本思想:引用位代替时间戳
精确LRU无法直接落地的根本原因是:操作系统无法对每次页面访问记录精确时间,但硬件可以在页表项或页框描述符里维护一个引用位。页面被访问时,这个位自动置1。近似LRU的核心思想就是用引用位来区分“最近被访问过”和“很久没被访问”,替代链表里的访问顺序。严格说,这不再是一个按访问顺序排列的算法,而是一个“粗粒度LRU”的估计器。
最常见的估计器是Clock算法,也叫时钟算法。它把页框排成一个环形,指针像时钟一样循环移动。缺页时,检查指针当前指向的页框:如果引用位为1,说明这个页最近被访问过,不能淘汰,把引用位清0并移动指针;如果引用位为0,就淘汰它。这种做法相当于给每个页面第二次机会,因此也叫二次机会算法。
4.2 二次机会算法和Clock指针的循环扫描
实现Clock算法只需要一个环形数组和一个指针,代码比双向链表加哈希表简单很多。下面给出一个聚焦淘汰逻辑的代码片段:
int clock_replace(int *reference_bits, int *pages, int num_frames, int *hand) { while (1) { if (reference_bits[*hand] == 0) { // 找到可以淘汰的页框 int victim = pages[*hand]; reference_bits[*hand] = 1; // 新页面置为已引用 *hand = (*hand + 1) % num_frames; return victim; } else { reference_bits[*hand] = 0; // 给它第二次机会,继续向前 *hand = (*hand + 1) % num_frames; } } }逻辑说明:reference_bits数组每个元素对应一个页框,hand是指针索引。当所有页框的引用位都是1时,clock_replace会经过最多num_frames次扫描,把所有位全部清0,最终还是会淘汰某个页,所以不会死循环。参数说明:最坏情况下扫描一整圈,复杂度O(n),但通常只需要扫几步,因为局部性会让大部分页框的引用位是0或刚被清掉。
这里有一个实验里很容易看走眼的细节:Clock算法在“页框全部被反复访问”的条件下,会退化成一个接近随机淘汰的算法。因为每次缺页都要把所有引用位清一遍,失去区分度。实际运行时引用位不需要实验者手动定期清零,而是在缺页替换时顺带清零,这是Clock算法高效的关键。在真实Linux内核中,页表项的访问位主要由硬件维护,软件只在回收页面时读取和清除。
4.3 增强型Clock:结合脏页位的四种状态
单纯的Clock算法只考虑“是否被访问过”,不考虑“内存里的内容是否与磁盘一致”。如果被淘汰的页面是脏页,需要回写磁盘,淘汰代价远高于干净页。增强型Clock把脏页位也放进判断,形成四种状态,优先级从高到低排列:
| 状态 | 访问位A | 脏位D | 含义 | 淘汰优先级 |
|---|---|---|---|---|
| 0 | 0 | 0 | 未访问、干净 | 最高 |
| 1 | 0 | 1 | 未访问、脏 | 次高 |
| 2 | 1 | 0 | 已访问、干净 | 较低 |
| 3 | 1 | 1 | 已访问、脏 | 最低 |
增强型Clock的扫描过程分多轮。第一轮找(0,0),如果没找到,第二轮找(0,1),顺便把遇到的(1,0)和(1,1)的访问位置0。第三轮再找(0,0)和(0,1)。实际上一次缺页可能扫描很多轮,但好处是脏页回写次数减少。这个算法在Linux早期被clock-proportional类似机制演进过,但课堂实验仍然以它作为理解“置换代价”的入口。
代码实现上,建议用一个struct page_frame { int page; int referenced; int dirty; }数组,替换函数里用四层循环分别对应上面四个优先级。注意每轮扫描时,访问位清零后的状态会落入下一轮优先级,因此不能用简单的if/else一次处理四个状态,一定要按轮次推进,否则会提前淘汰脏页。
5. 实验设计与结果对比:缺页率、命中率、有效访问时间
5.1 生成有局部性的访问序列
实验要让人信服,不能只跑一种序列。精确LRU和Clock算法的差别在局部性强的序列上最明显。可以用Python生成一个符合80/20法则的序列,即20%的页面贡献80%的访问。下面这段脚本生成一个访问序列文件:
import random random.seed(42) local_pages = list(range(20)) # 高频页面集合 cold_pages = list(range(20, 100)) # 低频页面集合 with open("locality_ref.txt", "w") as f: for _ in range(10000): if random.random() < 0.8: f.write(str(random.choice(local_pages)) + "\n") else: f.write(str(random.choice(cold_pages)) + "\n")逻辑说明:random.seed(42)固定随机种子,保证每次生成一致,方便对比不同算法的结果。参数说明:0.8是局部性强度,页面范围是0到99,其中高频页面只有20个。你可以调整local_pages的数量来观察近似LRU在不同局部性下的退化情况。
5.2 跑通三组算法的模拟脚本
有了C语言模拟器和Clock实现,可以写一个外层脚本批量对比。为了不重复编译,我一般让C程序从命令行接收算法类型参数。例如:
./page_sim lru 16 locality_ref.txt ./page_sim clock 16 locality_ref.txt ./page_sim enhanced_clock 16 locality_ref.txt参数说明:第一个参数是算法名,第二个是页框数,第三个是访问序列文件。程序输出格式可以统一为algorithm frames hits misses hit_rate。这样后续用awk比较就方便。如果不想写C,用Python直接模拟也可以,但需要保证三种算法共享同一个引用串和相同的页框数。
5.3 参数表:缺页率、命中率、有效访问时间
比较实验至少记录三组指标。缺页率是缺页次数除以总访问次数,命中率是它的补数。有效访问时间可以按照公式计算:
EAT = (1 - p) * memory_access_time + p * page_fault_timep是缺页率。课堂实验里可以设内存访问时间为100ns,缺页处理时间为5000000ns,也就是5ms。这个公式不是实测,而是用来放大缺页率差异对性能的影响,让结果直观。
| 算法 | 页框数=8 缺页率 | 页框数=16 缺页率 | 页框数=32 缺页率 |
|---|---|---|---|
| FIFO | 0.112 | 0.087 | 0.064 |
| 精确LRU | 0.095 | 0.071 | 0.050 |
| Clock | 0.101 | 0.078 | 0.056 |
上面是模拟得到的示例数据,不是理论推导。注意观察:页框数增大后,各算法差距缩小,因为当能容纳的工作集变大,几乎每种算法都能把活跃页面放进内存。实验报告里真正值得写的不是“LRU优于Clock”,而是“在什么容量和什么局部性条件下,近似算法能逼近精确LRU,以及何时会失效”。
这里的常见误区是只对比缺页率,忽略Clock指针的扫描次数。扫描次数代表替换开销,实验时可以让Clock算法在每次替换时输出hand移动的步数,统计平均值。如果步数接近页框数,说明引用位基本全为1,Clock已经失效,这时应该考虑增大页框数或者采用更细粒度的分段LRU。
6. 验证和进阶:写一个可复用的LRU缓存淘汰模块
6.1 用随机不变性和并发测试验证实现
页面置换模拟器最怕的是“看起来对,但换成其他序列就错”。一个有效的验证方法是做随机测试:生成大量随机访问序列,对比LRU模拟器的命中率和朴素时间戳算法一致。朴素时间戳算法虽然慢,但逻辑简单清晰,可以作为基准。随机生成100组序列跑100次,只要有一组不一致,就能立刻定位到链表摘除或哈希更新的bug。
// 朴素算法作为基准:每次访问后把所有页面时间戳加1,记录当前页时间戳这个方法不要求LRU实现本身有多快,只要求正确性。另一个验证点是并发场景的边界,虽然课堂模拟器是单线程的,但真实缓存会被多线程共享。你可以给lru_access加一个互斥锁,再开几个线程反复访问同一个缓存,观察节点指针是否出现悬挂。这属于进阶检查,却能让实验报告多一个“工程化”维度,也会在面试里成为切入点。
6.2 LRU-K和2Q:LRU近似算法在真实系统里的演进
实验结束时值得把眼光放宽一层:Linux并没有直接为每个内存页维护一个精确LRU链表,而是用lruvec把页面分成活跃和非活跃两个链表,再配合引用位做近似。数据库缓存则常用LRU-K,它记录每个页面的最近K次访问时间,只有访问次数超过K的页面才会被放入高优先级区域。还有2Q算法,用两个队列模拟出“短时扫描”不被提升的效果。
这些算法本质上是把“最近访问”和“访问频率”拆开处理。如果你已经完成了精确LRU和Clock,那么再读Linux的shrink_active_list代码会顺畅很多,因为里面的逻辑不过是在一个环形扫描框架里处理引用位和脏位。做实验时,我建议保留一份模拟器的CLI接口,以后想测新算法,只需要实现一个replace函数并接上同样的统计框架,而不是每次重写一遍模拟器。
验证到最后,可以打印一个五行的对比表,包含算法名、页框数、缺页率、Clock平均扫描步数、每次替换的回写次数。如果Clock的平均扫描步数突然超过页框数的一半,说明引用位分布太密,这时把hand指针改成随机起点比固定顺序更容易分散淘汰压力。这个技巧看起来不起眼,却是真实嵌入式系统里避免老化路线上的热点页被误删的常见做法。
本文还有配套的精品资源,点击获取