开始前,会这些就够了
- 会阅读 Python 循环、列表和集合
- 知道队列先进入的元素先离开
- 需要补基础时:先认识队列与访问顺序
遇到不熟悉的词,先看这 2 个解释
- Belady 异常
- 采用某些淘汰规则时,同一访问顺序下增加容量,未命中次数反而可能更多。
- LRU(最近最少使用)
- 空间不够时,移走距离上次访问最久的那一项;命中也会更新最近访问顺序。
书桌上放不下所有参考书时,你决定每次换走最早搬上桌的那本。多加一个位置似乎只会更方便,但这个规则还受整段搬入次序影响。本文让容量不同的两张桌子收到完全相同的请求,观察哪一本被提前换走。
实验把书换成等大小的页号,只统计命中和未命中。没有修改真实内存或 Redis 配置;要回答的是某种替换规则的结果,不能据此估计电脑会慢多少。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- fifo_cache.py:完整可运行示例
- README.md:运行说明与独立练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和产物验证记录
- SHA256SUMS.txt:内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B fifo_cache.py解压到独立练习文件夹,在含 fifo_cache.py 的目录打开终端运行。等大小页、空缓存、固定访问序列的 FIFO 与精确 LRU 教学模型;无 TTL、预取、真实操作系统缺页或 Redis 接口,不测吞吐和时延。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
多一个槽位,未命中反而更多?
两份缓存从空开始,按完全相同的顺序读取。FIFO 在命中时保留原顺序;LRU 则把刚用过的项移到最后。逐步看清哪个决定改变了后面的结果。
已经读取 0 / 12 项。先预测两种容量的表现,再点“下一步”。
A:3 个槽位
累计未命中 0 次
从左往右:最早放入 → 最晚放入
尚未读取。
B:4 个槽位
累计未命中 0 次
从左往右:最早放入 → 最晚放入
尚未读取。
当前较大缓存包含较小缓存里的全部项。
这里只计算等大小条目、全相联、冷启动的 FIFO 或精确 LRU。没有修改系统缓存,也不计预取、TTL、写回或访问时间。单组输入能展示反例;LRU 增容不会增加未命中的一般结论,需要正文中的集合包含关系论证。
先说清每一格记录什么
缓存里已经有请求的页号,就算命中;没有就算未命中,需要把它放进来。缓存满时先移走一项。FIFO 按进入缓存的先后淘汰,命中不会改变这个次序;LRU 则按最近访问时间淘汰,命中也会把该项移到最近的一端。
本例所有页大小相同,容量按页数计算。每次实验从空缓存开始,第一次遇到某个页也计入未命中。没有 TTL、预取或外部删除,所以观察到的差别只来自容量和替换规则。
十二次相同请求,得到两个相反的容量结果
访问顺序固定为 1、2、3、4、1、2、5、1、2、3、4、5。FIFO 容量 3 时未命中 9 次,容量 4 时却未命中 10 次。它是一个反例:足以推翻“FIFO 增容必定不增加未命中”,并不表示每次增容都会变差。
第 7 次访问页 5 后,3 格缓存从旧到新是 [1,2,5],4 格缓存则是 [2,3,4,5]。小缓存仍留着页 1,大缓存反而没有。第 8 次正好请求页 1,于是小缓存命中,大缓存又要替换。新增位置改变了此前的淘汰时机,后续整串队列状态也跟着改变。
为什么精确 LRU 有不同的容量保证?
对精确 LRU,可以把已经出现过的不同页按最近使用顺序排成一条总名单。容量 3 留最近 3 项,容量 4 留最近 4 项;同一时刻,前者必然包含在后者中。因此小缓存命中的请求,大缓存也会命中。这里需要相同访问序列、相同初始状态,以及这份精确的 LRU 规则。
这条“较小缓存的内容总被较大缓存包含”的性质常叫包含性质或栈性质,和程序的函数调用栈不是一回事。FIFO 的第 7 步已经给出不满足包含关系的现场。当前访问序列中 LRU 的 3 格和 4 格未命中分别是 10、8;3 格 LRU 甚至比 3 格 FIFO 多一次,所以也不能把这个性质读成 LRU 对每一段输入都胜过 FIFO。
运行程序,检查每一步而不只看总数
保存为 fifo_cache.py,执行 python -B fifo_cache.py。paired_trace 每一行同时列出两种 FIFO 容量的 hit、evicted 和 cache;cache 总是从最旧到最新排列,evicted 为 null 表示这次没有淘汰。先在纸上推第 7、8 步,再对照程序。
核对程序另用“每页一个时间戳、需要时找最小值”的办法作参考,不复用主实现的淘汰队列。它枚举 3 个页号、长度 0 到 6 的全部 1,093 条序列,对 1 到 4 格的 FIFO/LRU 逐行比对,并检查相邻 LRU 容量的包含关系。连同输入边界共通过 12,035 项检查;一般性的包含解释来自上一节,不能仅靠这批有限枚举替代。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""FIFO capacity anomaly and exact LRU; pure deterministic teaching models."""
import itertools
import json
from collections import deque
def simulate(references, capacity, policy):
references = tuple(references) # Validate and replay the same input, even for an iterator.
if type(capacity) is not int or not 1 <= capacity <= 64:
raise ValueError("capacity must be an integer from 1 to 64")
if policy not in ("FIFO", "LRU"):
raise ValueError("policy must be FIFO or LRU")
if any(type(page) is not int or page < 0 for page in references):
raise ValueError("page identifiers must be nonnegative integers")
queue = deque() # Oldest insertion (FIFO) or least recent access (LRU).
rows = []
for page in references:
hit = page in queue
evicted = None
if hit:
if policy == "LRU":
queue.remove(page)
queue.append(page)
else:
if len(queue) == capacity:
evicted = queue.popleft()
queue.append(page)
rows.append({"page": page, "hit": hit, "evicted": evicted,
"cache": list(queue)})
return {"misses": sum(not row["hit"] for row in rows), "rows": rows}
def timestamp_reference(references, capacity, policy):
# Independent oracle: stamps and minimum selection, no eviction queue.
stamps = {}
rows = []
for step, page in enumerate(references):
hit = page in stamps
evicted = None
if not hit:
if len(stamps) == capacity:
evicted = min(stamps, key=stamps.get)
del stamps[evicted]
stamps[page] = step
elif policy == "LRU":
stamps[page] = step
rows.append({"page": page, "hit": hit, "evicted": evicted,
"cache": sorted(stamps, key=stamps.get)})
return {"misses": sum(not row["hit"] for row in rows), "rows": rows}
def main():
checks = 0
def check(condition):
nonlocal checks
if not condition:
raise AssertionError("cache check failed")
checks += 1
refs = [1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5]
fifo = {c: simulate(refs, c, "FIFO") for c in (3, 4)}
lru = {c: simulate(refs, c, "LRU") for c in (3, 4)}
check([fifo[c]["misses"] for c in (3, 4)] == [9, 10])
check([lru[c]["misses"] for c in (3, 4)] == [10, 8])
violation = next(i + 1 for i, (small, large) in
enumerate(zip(fifo[3]["rows"], fifo[4]["rows"]))
if not set(small["cache"]) <= set(large["cache"]))
check(violation == 7)
check(simulate([2, 2, 2], 1, "FIFO")["misses"] == 1)
check(simulate([], 1, "LRU")["misses"] == 0)
check(simulate(refs, 5, "FIFO")["misses"] == len(set(refs)))
check(simulate(iter([2, 2, 2]), 1, "FIFO")["misses"] == 1)
sequences = 0
for length in range(7):
for seq in itertools.product(range(3), repeat=length):
sequences += 1
for policy in ("FIFO", "LRU"):
for capacity in range(1, 5):
check(simulate(seq, capacity, policy) ==
timestamp_reference(seq, capacity, policy))
lru_runs = [simulate(seq, c, "LRU") for c in range(1, 5)]
for small, large in zip(lru_runs, lru_runs[1:]):
check(all(set(a["cache"]) <= set(b["cache"])
for a, b in zip(small["rows"], large["rows"])))
for args in [([], 0, "FIFO"), ([], True, "FIFO"), ([], 1, "random"),
([True], 1, "FIFO"), ([-1], 1, "LRU")]:
try:
simulate(*args)
except ValueError:
check(True)
else:
raise AssertionError("invalid input accepted")
paired_trace = []
for step, (a, b) in enumerate(zip(fifo[3]["rows"], fifo[4]["rows"]), 1):
paired_trace.append({"step": step, "page": a["page"],
"FIFO3": {k: a[k] for k in ("hit", "evicted", "cache")},
"FIFO4": {k: b[k] for k in ("hit", "evicted", "cache")}})
print(json.dumps({"checks_passed": checks, "small_sequences": sequences,
"FIFO_misses_3_4": [9, 10], "LRU_misses_3_4": [10, 8],
"first_FIFO_non_inclusion_step": violation,
"paired_trace": paired_trace,
"scope": "equal-sized pages; empty caches; no TTL, prefetch, bytes or real OS faults"},
ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
怎样运行:在独立练习文件夹中执行 python -B fifo_cache.py。2026-10-17 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。等大小页、空缓存、固定访问序列的 FIFO 与精确 LRU 教学模型;无 TTL、预取、真实操作系统缺页或 Redis 接口,不测吞吐和时延。
运行后对照结果
{
"checks_passed": 12035,
"small_sequences": 1093,
"FIFO_misses_3_4": [
9,
10
],
"LRU_misses_3_4": [
10,
8
],
"first_FIFO_non_inclusion_step": 7,
"paired_trace": [
{
"step": 1,
"page": 1,
"FIFO3": {
"hit": false,
"evicted": null,
"cache": [
1
]
},
"FIFO4": {
"hit": false,
"evicted": null,
"cache": [
1
]
}
},
{
"step": 2,
"page": 2,
"FIFO3": {
"hit": false,
"evicted": null,
"cache": [
1,
2
]
},
"FIFO4": {
"hit": false,
"evicted": null,
"cache": [
1,
2
]
}
},
{
"step": 3,
"page": 3,
"FIFO3": {
"hit": false,
"evicted": null,
"cache": [
1,
2,
3
]
},
"FIFO4": {
"hit": false,
"evicted": null,
"cache": [
1,
2,
3
]
}
},
{
"step": 4,
"page": 4,
"FIFO3": {
"hit": false,
"evicted": 1,
"cache": [
2,
3,
4
]
},
"FIFO4": {
"hit": false,
"evicted": null,
"cache": [
1,
2,
3,
4
]
}
},
{
"step": 5,
"page": 1,
"FIFO3": {
"hit": false,
"evicted": 2,
"cache": [
3,
4,
1
]
},
"FIFO4": {
"hit": true,
"evicted": null,
"cache": [
1,
2,
3,
4
]
}
},
{
"step": 6,
"page": 2,
"FIFO3": {
"hit": false,
"evicted": 3,
"cache": [
4,
1,
2
]
},
"FIFO4": {
"hit": true,
"evicted": null,
"cache": [
1,
2,
3,
4
]
}
},
{
"step": 7,
"page": 5,
"FIFO3": {
"hit": false,
"evicted": 4,
"cache": [
1,
2,
5
]
},
"FIFO4": {
"hit": false,
"evicted": 1,
"cache": [
2,
3,
4,
5
]
}
},
{
"step": 8,
"page": 1,
"FIFO3": {
"hit": true,
"evicted": null,
"cache": [
1,
2,
5
]
},
"FIFO4": {
"hit": false,
"evicted": 2,
"cache": [
3,
4,
5,
1
]
}
},
{
"step": 9,
"page": 2,
"FIFO3": {
"hit": true,
"evicted": null,
"cache": [
1,
2,
5
]
},
"FIFO4": {
"hit": false,
"evicted": 3,
"cache": [
4,
5,
1,
2
]
}
},
{
"step": 10,
"page": 3,
"FIFO3": {
"hit": false,
"evicted": 1,
"cache": [
2,
5,
3
]
},
"FIFO4": {
"hit": false,
"evicted": 4,
"cache": [
5,
1,
2,
3
]
}
},
{
"step": 11,
"page": 4,
"FIFO3": {
"hit": false,
"evicted": 2,
"cache": [
5,
3,
4
]
},
"FIFO4": {
"hit": false,
"evicted": 5,
"cache": [
1,
2,
3,
4
]
}
},
{
"step": 12,
"page": 5,
"FIFO3": {
"hit": true,
"evicted": null,
"cache": [
5,
3,
4
]
},
"FIFO4": {
"hit": false,
"evicted": 1,
"cache": [
2,
3,
4,
5
]
}
}
],
"scope": "equal-sized pages; empty caches; no TTL, prefetch, bytes or real OS faults"
}教学队列保留了什么成本?
本例用 deque 的两端操作展示淘汰顺序,但判断某页是否在队列中、以及 LRU 命中后移除中间那一项,仍可能扫描容量 C 个元素。保存每一步完整队列也要复制状态。对 R 次请求,这份带完整轨迹的实现需要 O(RC) 量级的时间与轨迹空间,不能因 popleft 很快就把整个查询器说成 O(1)。
缓存的当前状态只占 O(C),保存全部轨迹才让空间随请求数增长。若只做计数,可以不保留旧行;若要提高真实缓存吞吐,还需结合哈希索引、链表、并发和内存开销重新设计。这个练习先把规则写清楚。
从这个反例能带走什么,不能带走什么?
比较缓存时,应固定请求序列、容量单位、冷启动是否计数和替换策略,再看结果。容量增加后的命中率不能仅凭直觉填写;如果页大小不同、访问还会触发预取,当前模型也需要修改。
操作系统未必使用这份 FIFO,Redis 的近似 LRU 也不是这里维护的精确 LRU。真实性能还取决于每次未命中的成本、并发、维护开销和实际工作负载。本篇没有跑操作系统缺页测量或 Redis 压测。
记住这三点
- FIFO 命中不刷新进入次序,容量变化可以改变后续淘汰链。
- 相同输入下的精确 LRU 具有容量包含关系,FIFO 未必具有。
- 未命中次数、维护成本和真实耗时应分别测量。
别把例子用过头
- 把增容必定改善命中率当成所有替换策略的通则。
- 命中时移动 FIFO 项,悄悄把规则改成 LRU。
- 用有限枚举或教学计数宣称真实系统性能已经验证。
自己试一小步
换成你自己的访问序列后,FIFO 与 LRU 的差别出现在哪一步?
- 自选至少 4 个页号,写一串含重复访问的 8 至 14 次请求。
- 分别画容量 2 和 3 的 FIFO、LRU 状态;明确命中是否移动位置。
- 运行自己的变体,标出第一次淘汰分歧,并核对未命中总数。
做到这些,就算完成
- 保持四次实验的输入顺序与初始空状态相同。
- 若这一串没有出现 FIFO 增容变差,不要把它当作反例失效。
- 解释观察到的是当前序列的结果还是一般保证;本节不附答案。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- OSTEP 第22章:页面替换策略与 Belady 异常 (新标签页)↗
支持 FIFO、LRU、容量包含性质及十二项经典反例。
- OSTEP 作者配套实验:paging-policy.py (新标签页)↗
核对 FIFO 命中不移动、LRU 命中移至最近端,以及满容量时的队首淘汰。
- Python 3.14:collections.deque (新标签页)↗
支持两端追加/移除及按值移除接口,说明容器操作与完整查询成本的区别。