☰
页面置换算法实验:Belady异常、缺页中断与LRU/FIFO/CLOCK对比
2026/11/5 3:04:33 网站建设 项目流程

简介:这份操作系统课程实验资料包面向正在学习虚拟内存与页面置换算法的本科生及实验课学习者,围绕在示例程序基础上补充“增强二次机会”等置换算法、输入不同页面引用串和实存帧数观察置换效果与性能这一任务展开,可用于课程实验复现、算法对比与实验报告撰写。压缩包共5个文件,约31KB,包含C++源码与头文件、项目工程文件以及Word实验报告文档,源码便于直接编译调试,报告文档可作为结果整理与分析的参照。目前已有472人学习下载。读者可据此完成FIFO、LRU与增强二次机会算法的模拟与横向比较,理解缺页率、命中率等指标随帧数和引用串变化的规律;也可在改进后的程序中随机生成内存页面引用串,动态观测各算法性能差异,并借鉴其中的实验组织方式与排错思路。

1. 页面置换:加内存反而更慢,这套实验到底在验证什么

把物理帧数从 3 加到 4,FIFO 的缺页次数不降反升——这条反直觉的记录就是 Belady 异常,它戳破了一个常见的错觉:页面置换算法的优劣不由名字决定,而由它对「未来」和「过去」的近似方式决定。OPT 偷看了未来所以最优却不可实现,LRU 精确记录过去但硬件付不起代价,FIFO 只看进入顺序于是会在某些引用串上翻车。操作系统实验七的页面置换实验,价值不在于把四种算法各写一遍交差,而在于给你一把可复现的尺子:同一引用串、同一帧数、同一统计口径下,把 OPT 当下界、LRU 当近似、FIFO 当反例,亲手量出缺页率的差距究竟从哪一步产生。搞懂这条链路,再回头看真实系统里的时钟算法和工作集模型,就不会觉得它们只是课本上的无奈妥协。

2. 缺页中断全链路:一次 page fault 到底发生了什么

2.1 页表项里那几个位,决定了算法能拿到什么信息

CPU 每次访存都是一次「虚拟地址查表」:MMU 先查 TLB,未命中就沿页表逐级走,找到页表项(PTE)后看有效位。有效位为 0 时硬件不会自己去补,它做的是抛异常——把出错的虚拟地址存进一个专用寄存器,把控制权交给内核的缺页处理程序,然后什么都不做。真正把页补进来的是软件。这个分工决定了置换算法能用的全部输入,只有页表项里那几位:有效位(present)、修改位(dirty)、访问位(accessed/referenced)以及物理帧号。

关键在访问位的更新时机。硬件为了避免每次访存都写内存,通常只在 TLB 未命中、真正读页表项那一次把访问位置 1,之后靠 TLB 命中就把这件事省了。这意味着内核看到的访问位是「粗糙的、被缓存污染的」,粒度可能粗到几毫秒。精确 LRU 要求每次访存都记录时间戳,硬件代价直接爆炸;退一步用软件模拟,每次访存插一条指令,性能又崩。CLOCK 算法之所以存在,根子就在这一位引用位上——它把「精确排序」降级成「给一次机会」,用一位信息换掉了链表和计数器。

# 一个 32 位页表项的典型位域划分(具体宽度随体系结构变化) PTE_PRESENT = 1 << 31 # 有效位:0 表示不在内存,访问触发缺页 PTE_DIRTY = 1 << 30 # 修改位:写操作时由硬件置 1,淘汰前必须回写 PTE_ACCESS = 1 << 29 # 访问位:读或写都置 1,CLOCK/老化算法靠它 PTE_WRITE = 1 << 28 # 权限位:只读页被写会产生保护异常而非缺页 FRAME_SHIFT = 12 # 低 12 位不用,页内偏移由虚拟地址低 12 位提供 FRAME_MASK = (1 << 20) - 1 def decode(pte: int) -> dict: return { "present": bool(pte & PTE_PRESENT), "dirty": bool(pte & PTE_DIRTY), "accessed": bool(pte & PTE_ACCESS), "frame": (pte >> FRAME_SHIFT) & FRAME_MASK if pte & PTE_PRESENT else None, }

这段位运算本身没什么难度,值得看的是它后面的约束:一个页表项只留得出 3 个控制位,其中访问位只有 1 位、修改位只有 1 位,没有任何空间放「上次访问的时间戳」。所以 LRU 在真实硬件上从来不是「实现得不好」,而是「信息量不够」。实验里你可以用一个 Python 字典存任意精度的时间戳,那是模拟器的特权,不是硬件的现实。写代码时把这层差别记在心里,后面看到 CLOCK 的近似就不会觉得是偷懒。

2.2 缺页处理程序的骨架:六步,少一步就死锁

从触发异常到指令重新执行,内核要走完一条固定链路。第一步校验地址合法性:这个虚拟地址落在进程的哪个区域?有没有读/写权限?越界或越权必须报段错误,而不是傻乎乎去分配帧——很多模拟器把这步省了,于是「野指针」也能跑出漂亮的缺页率,那是假的。第二步在空闲帧链表里取一帧。第三步链表空了,才轮到置换算法出场挑受害者。第四步若受害者脏位为 1,先把它写回后备存储,这一步可能阻塞。第五步改页表项、把受害者从 TLB 里刷掉(否则旧映射还在,下一次访存直接读到别人的数据)。第六步返回用户态重新执行那条触发异常的指令。

def handle_page_fault(proc, vaddr, is_write): vpn = vaddr >> PAGE_SHIFT region = proc.find_region(vaddr) if region is None: raise SegFault("地址未映射") # 第一步:合法性 if is_write and not region.writable: raise ProtectionFault("写只读页") # 保护异常 ≠ 缺页异常 if proc.pte[vpn].present: return "SPURIOUS" # 有效位是 1,说明是竞态或 TLB 陈旧 frame = proc.free_list.pop() if proc.free_list else None if frame is None: # 第二步:没有空闲帧 victim_vpn = proc.policy.pick_victim() # 第三步:置换策略 victim_pte = proc.pte[victim_vpn] frame = victim_pte.frame if victim_pte.dirty: # 第四步:脏页回写 proc.swap_out(victim_vpn, frame) victim_pte.present = False victim_pte.frame = None proc.tlb_flush(victim_vpn) # 第五步:绝不能漏 proc.stat.evictions += 1 proc.pte[vpn].frame = frame proc.pte[vpn].present = True proc.pte[vpn].accessed = True proc.pte[vpn].dirty = is_write if proc.swap_in(vpn, frame): proc.stat.faults += 1 # 第六步:只有真缺页才计数 return "RETRY"

参数含义值得逐条对照:is_write决定脏位初值,直接影响后续回写次数,也就影响「有效访问时间」而不只是缺页次数;proc.tlb_flush漏掉会让模拟器出现「页已经换了但读到的还是旧帧」的鬼数据,而且是概率性的,极难排查;stat.faults只在真缺页时自增,因为SPURIOUS分支如果也计数,缺页率会凭空高出几个百分点。

注意:脏位为 0 的页被淘汰时可以直接丢弃,不必回写。这条优化在实验里通常只是省几次计数,但在真实系统里它是写时复制、文件映射页共享的基石。

2.3 空闲帧耗尽之前:分配策略先于置换策略

多数实验框架一开始就把帧数固定成 3 或 4,直接进入置换循环。这个简化隐藏了一整层机制:帧分配。物理内存要在多个进程之间分,等分最简单但最不公平——一个 20 帧的进程和一个 200 帧的进程各拿一半,前者够用后者挨饿。按比例分配(按进程大小或按缺页率动态调)更合理,代价是要周期性地重算配额。分成「局部置换」和「全局置换」是另一条岔路:局部置换只允许从本进程的帧里挑受害者,好处是进程之间互不干扰,坏处是某个进程一旦进入抖动就自己出不来;全局置换允许抢别人的帧,吞吐更高,但一个疯狂缺页的进程会把所有进程一起拖下水。

真实系统里这一层和置换算法同样重要,而实验里几乎不碰。理解这一点,你就明白为什么课本上的「LRU 比 FIFO 好 5%」放到多道程序环境里可能完全反号——比较的前提是帧分配固定,一旦分配策略参与进来,算法优劣会被抖动效应淹没。做实验时如果想加一点深度,可以固定总帧数、让两个进程共享一个全局帧池,观察全局置换下总缺页率的波动,那个曲线的抖动程度会让你对「工作集」这个概念产生完全不同的体感。

3. 四种算法的实现骨架:同一份引用串下的四种近似

3.1 OPT:向后扫描的标尺,只能当标尺

OPT 的规则一句话说完:淘汰「未来最晚被用到」的那一页,从未再用的页天然是首选。它的实现不需要任何运行期状态,只需要预先知道整条引用串——这也正是它不可实现的原因。写它的目的是拿一个绝对下界:任何算法的缺页次数都不可能低于 OPT,所以 OPT 减 LRU 的差值就是「近似付出的代价」,OPT 减 FIFO 的差值就是这个引用串对「老页」的惩罚程度。

INF = float("inf") def opt(refs, n_frames): """最优置换:每次淘汰未来最晚被访问的页。refs 是页号序列。""" positions = {} for i, p in enumerate(refs): positions.setdefault(p, []).append(i) cursor = {p: 0 for p in positions} # 每页的下次出现位置在其列表中的下标 def next_use(p, now): lst, c = positions[p], cursor[p] while c < len(lst) and lst[c] <= now: # 跳过当前时刻及之前的出现 c += 1 cursor[p] = c return lst[c] if c < len(lst) else INF resident, faults = set(), 0 for i, page in enumerate(refs): if page in resident: continue # 命中不产生缺页 faults += 1 if len(resident) < n_frames: resident.add(page) continue victim = max(resident, key=lambda p: next_use(p, i)) resident.discard(victim) resident.add(page) return faults

cursor单调前进是这段代码的关键:每页只会被查询一次「下一次出现」,所以总复杂度是 O(引用串长度 × 帧数) 而不是 O(n²)。如果偷懒写成每次都从当前位置往后线性扫一遍,引用串一长就跑到分钟级,实验体验直接崩掉。next_use里的<= now而不是< now也要小心:当前这次访问自身必须被跳过,否则 OPT 会认为「这个页马上就要被用到」而永远不淘汰它。

3.2 FIFO:环形队列本身没错,错在它把命中当没发生

FIFO 用队列维护进入内存的先后顺序,淘汰队头。实现上有两个地方最容易写错:一是命中时不能把页移到队尾,二是队头淘汰后新页必须入队尾。第一点正是 FIFO 与 LRU 的唯一分界线——如果命中就刷新位置,你写出来的其实是 LRU。很多同学交上去的「FIFO」跑出来跟 LRU 一模一样,就是这个原因。

from collections import deque def fifo(refs, n_frames): resident = deque() # 左侧最老,右侧最新 in_mem = set() # 用于 O(1) 命中判断 faults = 0 for page in refs: if page in in_mem: continue # 命中:位置不动,这正是 FIFO 的定义 faults += 1 if len(resident) == n_frames: in_mem.discard(resident.popleft()) resident.append(page) in_mem.add(page) return faults

Belady 异常就藏在这几行里。设想引用串1 2 3 4 1 2 5 1 2 3 4 5,3 帧时 FIFO 缺页 9 次,加到 4 帧反而变成 10 次。原因不在代码,在 FIFO 的判据是「何时进入」而不是「何时用过」:帧数一变,整个淘汰时序就错位,一批本来会留在内存的热页被提前踢出去。这提醒一件事——FIFO 的缺页次数对帧数不是单调不增的,画「帧数-缺页率」曲线时别想当然地画成下降曲线,得实跑。

3.3 LRU:链表、栈、时间戳,三种实现的取舍

LRU 的语义是「最近最少使用」,翻译成可执行规则就是:维护一个按访问时间排序的结构,淘汰最久没被碰过的那一页。教科书给的标准实现是栈式——每访问一页,把它从栈中抽出压到栈顶,栈底永远是受害者。这个操作每次访问都是 O(帧数),帧数一多就慢。工程上更常见的是双向链表加哈希表,命中时把节点摘下来挂到表头,Python 的OrderedDict正好是这套结构的现成封装。

from collections import OrderedDict def lru(refs, n_frames): resident = OrderedDict() # 表头 = 最久未使用,表尾 = 最近使用 faults = 0 for page in refs: if page in resident: resident.move_to_end(page) # 命中即刷新年龄,与 FIFO 的唯一差别 continue faults += 1 if len(resident) == n_frames: resident.popitem(last=False) # 淘汰表头 resident[page] = True return faults

move_to_end那一行就是全部成本所在:真实硬件每次访存都要做一次等价操作,等于给每条访存指令加了一条链表操作,还要保证多核下这个链表不被并发写坏。可行的折中是硬件只提供一位访问位、软件定期扫描并「老化」——把所有页的计数器右移一位、把访问位塞进最高位,数值最小的就是最久未用的近似。老化的精度取决于扫描周期,周期太长会把热页误判成冷页,太短则开销压不住。

3.4 CLOCK:用一位引用位撑起的二次机会

CLOCK 把帧排成一个环,指针 hand 顺时针扫描。命中时只把该帧的引用位置 1,不动指针;缺页需要淘汰时,看 hand 指的那一帧:引用位是 1 就清零、指针前进,给它「第二次机会」;引用位是 0 就淘汰它。本质上是把「精确排序」降级成「一轮扫描内的粗排序」——被扫过一轮还没被再次访问的页,确实可以认为是冷的。

def clock(refs, n_frames): frames = [None] * n_frames ref_bit = [0] * n_frames hand = 0 in_mem = {} # page -> 帧下标 faults = 0 for page in refs: if page in in_mem: ref_bit[in_mem[page]] = 1 # 硬件职责:命中就置引用位 continue faults += 1 if len(in_mem) < n_frames: # 还有空帧,直接填 while frames[hand] is not None: hand = (hand + 1) % n_frames else: while ref_bit[hand] == 1: # 扫描,给第二次机会 ref_bit[hand] = 0 # 不清零会死循环 hand = (hand + 1) % n_frames del in_mem[frames[hand]] frames[hand] = page ref_bit[hand] = 1 in_mem[page] = hand return faults

两个细节会明显影响数值。第一,清零那一行如果漏了,while会在全 1 的环上转不停,表面上程序卡死,实际上是最典型的实现 bug。第二,淘汰后新页填入 hand 位置、hand 是否立即前进,不同教材的约定不一致:不前进等于给新页当场的二次机会,前进则不给。小帧数下这个差别能到 1 到 2 次缺页,做对比实验时要在报告里写清用的是哪种约定,否则和别人复现出来的数字对不上。

4. 实验框架:引用串、帧分配与统计口径

4.1 引用串从哪来:均匀随机是最大的陷阱

算法写完了,接下来决定结论可信度的其实是数据。最省事的做法是random.randrange(pages)生成一条均匀分布的引用串,然后跑四种算法。这种做法得出的结论几乎一定失真:均匀随机下,每个页的访问概率相同,LRU 的「近期性」优势发挥不出来,和 FIFO 的差距常常压到 1% 以内,甚至会因为随机波动而反号。而真实程序的访存是强局部性的——90% 的访问落在 10% 的页上,工作集随时间缓慢漂移。局部性越强,LRU 相对 FIFO 的优势越明显,这个差距才是实验要展示的东西。

import random def gen_refs(n, pages, ws_size=8, drift=0.02, seed=7): """带工作集漂移的引用串:访问集中在 ws_size 个热页上,偶尔换血。""" rng = random.Random(seed) working_set = rng.sample(range(pages), ws_size) refs = [] for _ in range(n): if rng.random() < drift: # 慢漂移:模拟阶段切换 i = rng.randrange(len(working_set)) working_set[i] = rng.randrange(pages) working_set = list(dict.fromkeys(working_set)) # 去重,避免窗口塌缩 refs.append(rng.choice(working_set)) return refs

drift这个参数值得反复调:设成 0 就是完美的静态工作集,LRU 几乎打满,OPT 与它的差距极小;设成 0.2 就变成工作集疯狂抖动,此时 OPT 也救不了,所有算法的缺页率一起起飞。把drift从 0 扫到 0.2,画四条曲线,你会直观看到「局部性强度」如何决定算法之间的相对排序,这比一句「LRU 优于 FIFO」有价值得多。list(dict.fromkeys(...))那行是血泪经验:不去重的话工作集可能被同一个页填满,退化成单页循环,测出来的缺页率低得离谱。

4.2 统计口径:缺页、置换、回写是三件事

缺页次数(page faults)、置换次数(evictions)、回写次数(write-backs)是三个不同的量。冷启动缺页不可避免,任何算法在引用串开头都要为每个首次访问的页付一次费,这部分差异反映的是「页数」而不是「算法」。真正拉开差距的是容量缺页——内存装不下工作集时被迫淘汰。少数情况下还有冲突缺页,即内存明明有空闲但映射受限装不进去,实验里一般不考虑。做对比时,把冷启动缺页单独拎出来看,或者干脆从「前 N 个不同页都已加载」的时刻开始计数,结论会干净很多。

回写次数则只在有脏位概念时才有意义。纯页号模拟(不区分读写)根本产生不了脏页,回写恒为 0,这时候谈「有效访问时间」就是空谈。想把这层补上,就在引用串里附带读写标记,命中时若为写就置脏位,淘汰时若脏就计一次回写。这一步加上去,FIFO 和 LRU 的差距会被放大,因为写页的回写成本远高于读页的丢弃成本。

def compare(refs, max_frames=7): rows = [] for f in range(1, max_frames + 1): rows.append({ "frames": f, "OPT": opt(refs, f), "LRU": lru(refs, f), "CLOCK": clock(refs, f), "FIFO": fifo(refs, f), }) return rows def find_belady(refs, max_frames=8): """返回满足 FIFO(f+1) > FIFO(f) 的帧数,即 Belady 异常的证据""" seq = [fifo(refs, f) for f in range(1, max_frames + 1)] return [f for f in range(1, max_frames) if seq[f] > seq[f - 1]]

find_belady是这套框架里最有说服力的验证器:只要它返回非空列表,你就拿到了「帧数增加、缺页反而变多」的实证,比任何文字描述都硬。用它扫多条不同drift的引用串,还能看出 Belady 异常在什么条件下更容易出现——局部性越弱、引用串越接近循环模式,FIFO 越容易翻车。

提示:验证 LRU 和 CLOCK 实现是否正确,最省事的办法是拿一条手算得出的短引用串(10 次访问、3 帧)对一遍,而不是盯着随机数据怀疑人生。

5. 避坑清单:五个把缺页率结论带偏的实现细节

现象一:交上去的 FIFO 曲线和 LRU 完全重合。原因几乎总是命中分支里顺手刷新了位置——resident.remove(page); resident.append(page)这种写法看着无害,实际已经变成 LRU。解决方法很直接:在命中分支里加一句assert False跑一遍,如果断言没被触发,说明这条路径根本没走;或者打印每条引用串对应的淘汰序列,两种算法在同一引用串下的淘汰序列必须不同,相同就是写错了。

现象二:加了帧数,OPT 的缺页次数也跟着涨。这在数学上不可能,出现即说明next_use的状态被污染了。最常见的原因是cursor复用同一个字典但没有在每次opt()调用时重建,第二次调用接着上次的位置往下走,查到的「未来」全是错的。解决方法是把positions和cursor都放在函数内部构造,绝不跨调用共享;或者在入口处显式重置。

现象三:CLOCK 偶尔卡死,CPU 跑满但不出结果。扫描时忘记把引用位清零,while ref_bit[hand] == 1在满环全 1 的情况下无解。另一种可能是frames里有None但代码走了满帧分支,in_mem里查不到对应项,del抛异常被吞掉后进入死循环。解决方法:清零那行单独加注释,跑之前先断言len(in_mem) == n_frames才进置换分支。

现象四:所有算法在帧数足够大时缺页率不归零。引用串里不同页的数量如果没有统计过,很容易出现「帧数=8 但引用串有 12 个不同页」的情况,此时任何算法都必然继续缺页,和算法无关。解决方法:先算len(set(refs)),把它当作横轴上限,超过这个值的实验点没有意义,画图时直接截断。

现象五:换台机器跑,数字变了。用了random却没固定种子,或者用了set的迭代顺序去挑受害者——max(resident, key=...)在键值相同时的返回结果依赖集合的迭代顺序,而 Python 的字符串哈希默认带随机化。解决方法:受害者选择时对候选集排序后再比较,或者用min配一个显式的次序键,保证平局时有确定解。

6. 设计取舍:为什么真实系统宁愿要一个「不准」的 LRU

如果把 OPT 当满分、FIFO 当不及格,LRU 看上去是个标准答案。可它在真实系统里几乎从不精确实现,原因不在算法本身,而在它要求的那条「每次访存都更新次序」的隐式契约。这条契约的代价随 CPU 频率线性增长,而收益只是把缺页率从 12% 降到 10%——当一次缺页的代价是几毫秒的磁盘 I/O 时,省下这 2% 的缺页确实值钱,但为此让每条访存指令多花几个周期,整体吞吐反而下降。于是工程上选了一条折中路:硬件只维护一位访问位,软件周期性扫描做老化,用「近似 LRU」换掉「精确 LRU」。CLOCK 是这条路的最小实现,工作集模型是它的另一支——不追踪单页的年龄,只估计进程当前需要多少帧,帧给够了,算法差一点也无所谓。

这个取舍在实验数据里能直接看到。固定帧数跑 LRU 和 CLOCK,帧数小的时候 CLOCK 明显吃亏(环太小,二次机会的粒度太粗),帧数大到能装下整个工作集时两者几乎打平——因为此时缺页主要由冷启动和漂移决定,淘汰策略退居次要。也就是说,CLOCK 的「不准」在帧数充足时根本不算缺点,而在帧数不足时,任何算法都救不了抖动。真正决定性能的是帧分配,不是置换策略的名字。想验证这个判断,把drift调大、帧数压到工作集以下,你会看到四条曲线一起陡峭上升,OPT 也不例外,那一刻算法优劣的排序变得毫无意义。

我踩过最深的坑是拿均匀随机引用串跑出「FIFO 略优于 LRU」的结论,还认真写了半页分析。后来把引用串换成带工作集的模型,同一套代码、同一批帧数,排序整个翻转,那半页分析全废。从那以后我每次做这类对比实验,都强制走一遍「先看len(set(refs))、再看drift扫描、最后用find_belady找反例」的流程,确认数据本身站得住,才敢信曲线。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询