qinghuan
← 回到知识探索

计算机趣闻 / 可下载小项目

缓存多留一个位置,为什么反而要重新读取更多次?

同一串访问、同一种 FIFO 规则,只把容量从 3 改为 4,未命中却从 9 增加到 10。逐步查看被换走的是谁。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先说清每一格记录什么
  4. 十二次相同请求,得到两个相反的容量结果
  5. 为什么精确 LRU 有不同的容量保证?
  6. 运行程序,检查每一步而不只看总数
  7. 教学队列保留了什么成本?
  8. 从这个反例能带走什么,不能带走什么?
  9. 记住这三点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 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 项检查;一般性的包含解释来自上一节,不能仅靠这批有限枚举替代。

展开收起代码 · 小实验python · 110 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""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 的差别出现在哪一步?

  1. 自选至少 4 个页号,写一串含重复访问的 8 至 14 次请求。
  2. 分别画容量 2 和 3 的 FIFO、LRU 状态;明确命中是否移动位置。
  3. 运行自己的变体,标出第一次淘汰分歧,并核对未命中总数。

做到这些,就算完成

  • 保持四次实验的输入顺序与初始空状态相同。
  • 若这一串没有出现 FIFO 增容变差,不要把它当作反例失效。
  • 解释观察到的是当前序列的结果还是一般保证;本节不附答案。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • OSTEP 第22章:页面替换策略与 Belady 异常 (新标签页)↗Remzi H. Arpaci-Dusseau / Andrea C. Arpaci-Dusseau · 英文

    支持 FIFO、LRU、容量包含性质及十二项经典反例。

    2026-10-17 实际打开核对:第 22.3、22.5 节和第 5 页 Belady's Anomaly 旁注;没有把教材的内存访问时间示例用作本机结果。

  • OSTEP 作者配套实验:paging-policy.py (新标签页)↗Remzi Arpaci-Dusseau / OSTEP homework · 英文

    核对 FIFO 命中不移动、LRU 命中移至最近端,以及满容量时的队首淘汰。

    2026-10-17 实际打开核对:访问循环中的命中分支、FIFO/LRU victim 选择和 memory.append;本篇程序为原创实现,没有声称运行了该配套脚本。

  • Python 3.14:collections.deque (新标签页)↗Python Software Foundation · 英文

    支持两端追加/移除及按值移除接口,说明容器操作与完整查询成本的区别。

    2026-10-17 实际打开核对:deque 的 append、popleft、remove、membership 和索引说明;文档维护版与本机 3.14.0 运行版本分列。