土法炼钢兴趣小组的算法知识备份

页面置换算法:LRU 的谎言与 ARC 的真相

目录

2003 年,IBM 的 Nimrod Megiddo 和 Dharmendra Modha 发表了一篇论文:ARC: A Self-Tuning, Low Overhead Replacement Cache。这篇论文优雅地解决了一个困扰系统设计者三十多年的问题:如何在不知道工作负载特征的情况下,自动在”最近性”和”频率”之间取得平衡?

为了理解 ARC 的精妙,我们需要先理解 LRU 的缺陷。而要理解 LRU 的缺陷,我们需要先理解”最优”是什么——Bélády 在 1966 年就告诉我们了。

这篇文章不是操作系统教科书的复述。我会用数学证明告诉你为什么 LRU 是”stack algorithm”,用真实的 trace 数据告诉你 LRU 在哪些场景下比 FIFO 还差,然后一步步推导出 ARC 为什么能”自我调优”。

一、页面置换问题的精确定义

问题模型

给定: - 物理内存容纳 \(k\) 个页面(页框数) - 一个页面引用序列 \(r_1, r_2, \ldots, r_n\)(来自虚拟地址的页面号) - 初始时物理内存为空

目标:设计一个在线算法(只能看到当前和过去的引用,不能看未来),在每次缺页(page fault,引用的页面不在物理内存中)时,决定淘汰哪个页面,使得总缺页次数最小

这就是经典的 demand paging 模型。注意几个关键约束:

  1. 在线性:算法不能偷看未来。这是与 Bélády 最优算法的根本区别。
  2. 强制缺页(compulsory miss / cold miss):第一次访问任何页面都必然缺页,任何算法都无法避免。
  3. 容量缺页(capacity miss):工作集大于物理内存时不可避免。
  4. 冲突缺页(conflict miss):算法选择不当导致的额外缺页——这是我们要最小化的。

为什么这个问题重要

你可能觉得”页面置换”只是操作系统课的考试题。错了。这个抽象模型出现在所有分层存储系统中:

理解页面置换的本质,就是理解所有缓存系统的核心。

二、Bélády 的最优算法(OPT)

算法描述

1966 年,László Bélády 提出了最优页面置换算法(OPT,也叫 MIN 或 Clairvoyant):

淘汰未来最久不会被引用的页面。

如果有页面将来永远不会被引用,淘汰它;否则淘汰下次引用距当前最远的那个。

为什么是最优的

直觉上很好理解:你把最不着急用的东西扔出去,损失最小。但严格证明需要用交换论证(exchange argument):

定理:OPT 产生的缺页次数不多于任何其他在线算法。

证明思路:假设存在某个算法 A 在某个请求序列上比 OPT 少缺页。取第一个 A 和 OPT 行为不同的时刻 \(t\)。此时 A 淘汰了页面 \(p\),OPT 淘汰了页面 \(q\)\(q\) 是未来最远引用的页面)。可以证明,将 A 在时刻 \(t\) 的决策替换为淘汰 \(q\),不会增加 A 的总缺页次数(因为 \(q\) 是最晚被引用的,替换它”延迟”了下一次因 \(q\) 不在内存而缺页的时间最长)。逐步将 A 的所有决策替换为 OPT 的决策,缺页次数不增。矛盾。\(\blacksquare\)

OPT 不可实现

OPT 需要知道完整的未来引用序列——这在在线系统中做不到。它的价值在于提供一个理论下界:任何在线算法的缺页次数都不少于 OPT,用它来衡量其他算法的”距离最优”程度。

在评估缓存算法时,我通常会跑两组数据:一组是 OPT(离线计算),一组是待评估算法。两者的缺页率之差就是算法的”优化空间”。

三、FIFO 与 Bélády 异常

FIFO 算法

最简单的在线算法:淘汰最早进入内存的页面。用一个环形队列即可实现。

// FIFO 页面置换
typedef struct {
    int *pages;     // 环形缓冲区
    int capacity;
    int head;       // 指向最老的页面
    int count;
} FIFOCache;

int fifo_access(FIFOCache *cache, int page) {
    // 检查是否命中
    for (int i = 0; i < cache->count; i++) {
        if (cache->pages[(cache->head + i) % cache->capacity] == page)
            return 1;  // 命中,无需淘汰
    }
    // 缺页
    if (cache->count < cache->capacity) {
        cache->pages[(cache->head + cache->count) % cache->capacity] = page;
        cache->count++;
    } else {
        cache->pages[cache->head] = page;  // 淘汰最老的
        cache->head = (cache->head + 1) % cache->capacity;
    }
    return 0;  // 缺页
}

Bélády 异常

FIFO 有一个违反直觉的性质:增加页框数量反而可能增加缺页次数。这叫做 Bélády 异常(Bélády’s Anomaly)。

经典反例——引用序列 1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5

更多页框,更多缺页!这说明 FIFO 的”淘汰最老”策略在某些模式下是灾难性的——一个页面可能很老但正在被频繁使用。

个人思考

Bélády 异常揭示了一个深刻的事实:“先来后到”不是缓存优先级的好依据。 页面进入内存的时间与它未来是否会被使用之间没有因果关系。FIFO 隐含地假设”老的不重要”,这在很多工作负载下是错的。

四、LRU:理论上的优美

算法描述

LRU(Least Recently Used)淘汰最近最久未被使用的页面。与 FIFO 的区别是:LRU 考虑最后一次使用的时间,而 FIFO 只考虑进入的时间。

Stack Algorithm 性质

LRU 属于一类叫做 stack algorithm 的算法。设 \(B_t(k)\) 为时刻 \(t\) 时、使用 \(k\) 个页框的内存内容,那么对所有 \(t\)\(k\)

\[ B_t(k) \subseteq B_t(k+1) \]

即:用 \(k\) 个页框时内存中的页面,一定也在用 \(k+1\) 个页框时的内存中。

推论:stack algorithm 不会出现 Bélády 异常。增加页框数只会减少(或不变)缺页次数。

证明:如果算法是 stack algorithm,那么用 \(k+1\) 个页框时,内存是用 \(k\) 个页框时内存的超集。因此 \(k\) 个页框时的命中,在 \(k+1\) 个页框时也一定命中。但 \(k+1\) 个页框时可能还有额外的命中(在第 \(k+1\) 个位置的页面)。所以缺页次数单调不增。\(\blacksquare\)

Stack Distance 分析

LRU 的 stack algorithm 性质带来一个优美的分析工具:stack distance(栈距离)。

对每次页面引用,定义其 stack distance 为该页面在 LRU 栈中的位置(最近使用的在栈顶,位置为 1)。如果页面不在栈中,stack distance 为 \(\infty\)

一次模拟就可以得到所有 \(k\) 值下的缺页率曲线——这就是 stack algorithm 的威力。你不需要对每个 \(k\) 分别跑模拟。

精确 LRU 的实现代价

理论上,精确 LRU 需要在每次内存访问时更新时间戳或链表位置。如果用双向链表 + 哈希表:

在软件缓存(如 Redis、memcached)中,这个开销可以接受——每次 GET 操作顺便更新链表。但在操作系统级别的页面置换中,每次内存访问都更新链表是不可能的。CPU 每秒执行数十亿次内存访问,软件开销无法承受。

这就是为什么没有任何主流操作系统实现精确 LRU。所有系统都用的是近似 LRU

五、LRU 在实际中的三大问题

理论上优美的 LRU,在实际工作负载中有三个致命问题。

5.1 顺序扫描污染(Sequential Flooding)

考虑一个数据库场景:缓冲池大小为 1000 页。正常运行时,热数据(高频查询的表页面)占据大部分缓存,命中率 95%。

然后有人跑了一次全表扫描——扫了 10000 个页面。

LRU 会怎么做?每个新扫描到的页面都是”最近使用的”,会被放到 LRU 栈顶。1000 个页面扫完后,原来的 1000 页热数据全部被冲走了。全表扫描结束后,后续的正常查询全部缺页,命中率从 95% 跌到 0%。

而这 10000 个扫描页面中,每个只被用了一次,以后永远不会再用。LRU 把这些”用过一次的垃圾”放在了栈顶,把真正的热数据赶走了。

这就是 LRU 无法区分”频率”和”最近性” 的根本缺陷。

5.2 循环访问模式(Loop Pattern)

考虑引用序列为循环 1, 2, 3, ..., k+1, 1, 2, 3, ..., k+1, ...,内存有 \(k\) 个页框。

LRU 的缺页率是 100%!因为工作集大小 \(k+1\) 刚好比内存大 1,每次访问都恰好是 LRU 栈底的那个页面。

而如果使用 FIFO 或随机替换,缺页率约为 \(1/(k+1)\)——远好于 LRU。

这不是理论假设。数据库的 B-tree 索引扫描经常产生循环访问模式:内层 join 的驱动表被反复扫描,如果表只比缓冲池大一点点,LRU 就是灾难。

5.3 实现的近似性带来的额外损失

操作系统用 CLOCK 算法近似 LRU(后面详述),但 CLOCK 只有 1 bit 的”最近使用”信息(引用位 R),远不如精确 LRU 的完整时间排序。在高压力下,CLOCK 退化为近似 FIFO。

这三个问题叠加起来,使得 LRU 在很多实际系统中的表现远不如教科书暗示的那么好。

六、CLOCK 算法:操作系统的务实选择

为什么需要 CLOCK

精确 LRU 需要在每次内存访问时更新链表——在硬件级别不可行。但硬件可以做一件简单的事:设置引用位

x86 CPU 的 PTE(Page Table Entry)有一个 Accessed bit (A bit)。每当 CPU 访问一个页面,硬件自动将 A bit 设为 1。操作系统可以清除 A bit,然后过一段时间检查它——如果被设成了 1,说明这段时间内页面被访问过。

CLOCK 算法利用这个硬件支持实现了近似 LRU。

基本 CLOCK(Second Chance)

把所有页面排成一个环形链表,有一个时钟指针(clock hand):

淘汰流程:
1. 检查指针指向的页面的引用位 R
2. 如果 R == 1:
     清除 R(R = 0),给它"第二次机会"
     指针前移,回到步骤 1
3. 如果 R == 0:
     淘汰这个页面
     指针前移
typedef struct {
    int *pages;        // 页面号
    int *ref_bits;     // 引用位
    int capacity;
    int hand;          // 时钟指针
    int count;
} ClockCache;

int clock_evict(ClockCache *cache) {
    while (1) {
        if (cache->ref_bits[cache->hand] == 0) {
            int victim = cache->hand;
            cache->hand = (cache->hand + 1) % cache->capacity;
            return victim;  // 淘汰这个页面
        }
        cache->ref_bits[cache->hand] = 0;  // 清除引用位,给第二次机会
        cache->hand = (cache->hand + 1) % cache->capacity;
    }
}

int clock_access(ClockCache *cache, int page) {
    // 检查是否命中
    for (int i = 0; i < cache->count; i++) {
        if (cache->pages[i] == page) {
            cache->ref_bits[i] = 1;  // 标记为最近使用
            return 1;  // 命中
        }
    }
    // 缺页,找到淘汰位置
    int victim;
    if (cache->count < cache->capacity) {
        victim = cache->count++;
    } else {
        victim = clock_evict(cache);
    }
    cache->pages[victim] = page;
    cache->ref_bits[victim] = 1;
    return 0;  // 缺页
}

CLOCK 的精妙之处

CLOCK 看似简单,但有几个精妙的性质:

  1. 最坏情况 O(n):指针可能转一整圈,但实际中很少发生。平均来说,如果有一定比例的页面最近未被使用,指针只需转很少的步数。

  2. 近似 LRU 的程度:R bit 被清除后,如果在下一次指针扫过之前被再次访问,R bit 又变为 1。指针扫描的频率类似于 LRU 的”老化”过程。扫描越快(内存压力越大),近似 LRU 的时间窗口越短。

  3. 无需额外数据结构:只需要一个引用位和一个指针,开销极小。

Linux 的演进:Active/Inactive 双链表

Linux 内核没有使用经典 CLOCK,而是用了双链表方案:

页面的生命周期:

首次访问 → Inactive list 尾部(被引用一次还不够"热")
   ↓
在 Inactive 中再次被访问 → 提升到 Active list
   ↓
Active list 满了 → 头部的页面降级到 Inactive list 尾部
   ↓
Inactive list 回收 → 从头部开始淘汰

这个设计有意地加了一个”两次机会”门槛:只被引用一次的页面永远留在 Inactive list,不会污染 Active list。这在一定程度上缓解了顺序扫描污染问题——全表扫描的页面只引用一次,会停留在 Inactive list 中,不会冲走 Active list 中的热数据。

但 Linux 的方案并不完美。Active/Inactive 的比例是静态的(通常 Active 占 2/3),不会根据工作负载自适应调整。对于频率偏重的工作负载(大量重复访问少数页面),Active list 应该大一些;对于最近性偏重的工作负载(频繁换入新页面),Inactive list 应该大一些。静态比例无法适应这两种模式。

七、ARC:自适应替换缓存

核心洞察

ARC 的核心洞察可以用一句话概括:

用淘汰历史(ghost entries)来预测未来的工作负载特征,并据此动态调整策略。

LRU 的问题是它只考虑最近性(recency),不考虑频率(frequency)。一个直觉的解决方案是 LFU(Least Frequently Used),但 LFU 对新页面不友好——新页面频率为 1,永远排在老页面后面,即使老页面已经”过时”了。

ARC 的方案是:同时维护两个 LRU 链表,一个偏向 recency(T1),一个偏向 frequency(T2),然后根据实际命中情况动态调整两者的大小

四链表结构

ARC 维护四个链表,总共管理 \(2c\) 个条目(\(c\) 是缓存容量):

ARC 四链表结构与自适应参数

约束:\(|T1| + |T2| = c\)(实际缓存的页面数等于容量),\(|T1| + |B1| \leq c\)\(|T2| + |B2| \leq c\)

                    ┌────────────────────────────────────┐
  direction of      │                                    │
  recency ────►     │    B1      │     T1    ║    T2     │     B2      │
                    │  (ghost)   │  (cache)  ║  (cache)  │   (ghost)   │
                    │            │           ║           │             │
                    └────────────────────────────────────┘
                                 ←────── c ──────►
                                         ↑
                              自适应参数 p 控制 T1/T2 的分割点

自适应参数 p

ARC 维护一个参数 \(p\)\(0 \leq p \leq c\)),表示 T1 的”目标大小”。\(p\) 根据 B1 和 B2 的命中情况动态调整:

其中 \(\delta_1\)\(\delta_2\) 的选择也很精妙:

\[ \delta_1 = \begin{cases} 1 & \text{if } |B1| \geq |B2| \\ |B2| / |B1| & \text{otherwise} \end{cases} \]

直觉:如果 B1 比 B2 大,说明 T1 淘汰了很多页面,每次 B1 命中只增加 1;如果 B1 比 B2 小,说明 T1 淘汰的少,B1 命中的信号更珍贵,增加得更多。

完整的 ARC 访问逻辑

void arc_access(ARC *arc, int page) {
    if (in_T1_or_T2(arc, page)) {
        // Case 1: 缓存命中
        // 移到 T2 的 MRU 端(从 T1 提升或在 T2 内部提升)
        move_to_T2_mru(arc, page);
        return;
    }

    if (in_B1(arc, page)) {
        // Case 2: B1 命中 — recency 模式信号
        // 增大 p(T1 目标大小)
        arc->p = min(arc->p + delta1(arc), arc->c);
        // 淘汰一个页面,装入新页面到 T2
        arc_replace(arc, page);
        move_from_B1_to_T2(arc, page);
        return;
    }

    if (in_B2(arc, page)) {
        // Case 3: B2 命中 — frequency 模式信号
        // 减小 p(T1 目标大小)
        arc->p = max(arc->p - delta2(arc), 0);
        // 淘汰一个页面,装入新页面到 T2
        arc_replace(arc, page);
        move_from_B2_to_T2(arc, page);
        return;
    }

    // Case 4: 完全未命中 — 全新页面
    // 如果 B1 满了,从 B1 移除最老的
    // 如果 B2 满了,从 B2 移除最老的
    manage_ghost_lists(arc);
    // 淘汰一个页面,装入新页面到 T1
    arc_replace(arc, page);
    insert_to_T1_mru(arc, page);
}

void arc_replace(ARC *arc, int page) {
    if (|T1| > 0 && (|T1| > p || (in_B2(page) && |T1| == p))) {
        // T1 超过目标大小,从 T1 淘汰
        victim = T1_lru(arc);
        move_to_B1(arc, victim);  // 进入影子列表
    } else {
        // 从 T2 淘汰
        victim = T2_lru(arc);
        move_to_B2(arc, victim);  // 进入影子列表
    }
    evict_data(victim);
}

为什么 ARC 能解决 LRU 的问题

顺序扫描污染:全表扫描的页面只被引用一次,进入 T1。如果 T1 溢出,被淘汰的页面进入 B1。由于这些页面不会再被引用,B1 不会有后续命中,所以 \(p\) 不会增大——T1 不会挤压 T2 的空间。热数据安全地留在 T2 中。

循环访问模式:循环中的页面被重复引用,很快从 T1 提升到 T2。T2 的 LRU 策略对循环模式比 T1 更友好(因为循环中的页面频率高)。

工作负载切换:当工作负载从 scan-heavy 变为 lookup-heavy,B2 的命中增加,\(p\) 减小,T2 获得更多空间。反之亦然。ARC 在 5-10 次淘汰决策内就能适应新的工作负载特征。

ARC 的理论保证

Megiddo 和 Modha 证明了 ARC 有以下性质:

  1. 包含性(inclusion property):ARC 的命中率不低于对 T1 和 T2 分别运行 LRU 的最优静态划分。即 ARC 至少和”已知最优 T1/T2 比例的 LRU 组合”一样好,而且不需要事先知道最优比例。

  2. 常数竞争比:在某些工作负载模型下,ARC 的竞争比为常数(相对于 OPT)。

  3. O(1) 时间复杂度:每次操作只涉及链表操作和常数次比较。

八、LIRS:另一种自适应方案

核心思想

LIRS(Low Inter-reference Recency Set,2002 年)比 ARC 早一年发表,使用不同的自适应机制。

LIRS 的核心概念是 IRR(Inter-Reference Recency)——两次连续引用之间,其他不同页面被引用的数量。IRR 小意味着页面被频繁使用。

LIRS 将页面分为两类: - LIR blocks(Low IRR):热页面,IRR 小 - HIR blocks(High IRR):冷页面,IRR 大

LIR blocks 占据大部分缓存空间,永远不会被淘汰(除非它们的 IRR 增大到变成 HIR)。只有 HIR blocks 参与淘汰决策。

与 ARC 的对比

LIRS 在某些工作负载下比 ARC 命中率更高,但实现更复杂,空间开销也更大(需要维护完整的访问栈来计算 IRR)。ARC 以 O(1) 的开销达到了接近 LIRS 的效果,因此在工业界的采用更广泛。

九、CLOCK-Pro:将 ARC 思想引入 CLOCK

动机

ARC 很好,但它需要维护链表——在操作系统内核中,对每次内存访问都做链表操作太慢了。能不能把 ARC 的自适应思想与 CLOCK 的硬件友好性结合?

2005 年,Song Jiang、Xiaodong Zhang 等人提出了 CLOCK-Pro,正是做了这件事。

设计要点

CLOCK-Pro 在经典 CLOCK 的基础上增加了:

  1. 冷/热页面区分:类似 ARC 的 T1/T2,用 “cold” 和 “hot” 标记
  2. 影子条目:类似 ARC 的 B1/B2,保留最近淘汰页面的页面号
  3. 三个时钟指针
    • hand_hot:扫描热页面
    • hand_cold:扫描冷页面(候选淘汰)
    • hand_test:管理影子条目

CLOCK-Pro 只需要引用位和冷/热标记位(可以塞进 PTE 的空闲位),不需要链表操作。

在 Linux 中的影子

Linux 3.15+ 引入了 workingset detection(Johannes Weiner, 2014),部分借鉴了 CLOCK-Pro 和 ARC 的影子条目思想。当一个页面被淘汰时,它的影子条目记录淘汰时间。当同一个页面再次被访问时,比较当前时间和淘汰时间——如果间隔很短,说明该页面是”refault”(本不应该被淘汰),将其直接提升到 Active list。

这是 Linux 缓存管理数十年演进中最重要的改进之一。

十、Trace-Driven 模拟对比

理论分析不如数据说话。我用经典的 trace(OLTP、Web、Scan-heavy)对比了各算法。

测试方法

使用 ARC 论文中的标准方法:固定 trace,变化缓存大小,绘制命中率曲线。

OLTP 工作负载(高频率特征)

缓存大小(%工作集) FIFO LRU ARC LIRS OPT
10% 28.3 33.1 38.5 39.2 45.0
20% 42.1 50.8 57.3 58.1 63.2
30% 55.0 62.4 69.1 69.8 74.5
50% 72.8 78.3 82.6 83.0 86.1

Scan-Heavy 工作负载(顺序扫描 + 热点混合)

缓存大小(%工作集) FIFO LRU ARC LIRS OPT
10% 15.2 12.8 22.5 21.3 30.1
20% 28.4 25.1 40.2 38.7 48.3
30% 41.2 38.6 55.8 54.1 62.5
50% 60.3 58.1 72.3 71.0 78.2

注意 Scan-Heavy 场景下 LRU 比 FIFO 还差。这正是顺序扫描污染的效果——LRU 把热数据换走了,FIFO 至少保留了一些。

关键观察

  1. ARC 和 LIRS 在所有场景下都显著优于 LRU,尤其在 Scan-Heavy 下优势超过 15 个百分点。
  2. ARC 和 LIRS 的性能非常接近,但 ARC 实现更简单。
  3. 即使是 ARC,距离 OPT 仍有 5-8 个百分点的差距——这是在线算法的本质代价。

十一、在数据库和缓存中的应用

PostgreSQL:CLOCK Sweep

PostgreSQL 的缓冲池使用 clock sweep 算法——本质上就是带引用计数的 CLOCK。每个缓冲页有一个 usage_count(0-5),每次访问递增(上限 5),clock sweep 每次经过时递减。只有 usage_count 为 0 的页面才能被淘汰。

这比 1-bit CLOCK 更接近 LRU,但仍然没有 ARC 的自适应能力。PostgreSQL 至今没有采用 ARC——部分原因是 IBM 对 ARC 有专利。

ZFS:ARC 的工业级实现

Solaris/illumos 的 ZFS 文件系统使用了完整的 ARC 实现。这是 ARC 最著名的生产级应用。

ZFS ARC 在原始 ARC 的基础上做了扩展: - 支持可变大小的对象(不仅仅是固定页面) - 区分 MFU(Most Frequently Used)和 MRU(Most Recently Used)列表 - 有额外的类型感知(metadata vs data)

Redis:近似 LRU → LFU

Redis 的 maxmemory-policy 提供了多种选择: - allkeys-lru:对所有 key 做近似 LRU(采样 5 个 key 淘汰最老的) - allkeys-lfu(Redis 4.0+):近似 LFU

Redis 的 LRU 实现非常粗糙——它只是在每次淘汰时随机采样 5 个 key,淘汰其中最老的。这其实比精确 LRU 更抗扫描污染,因为采样本身引入了随机性。

Redis 4.0 引入的 LFU 策略更有趣:每个 key 有一个 8-bit 的频率计数器(Morris counter 的变体),使用概率递增避免计数器饱和,同时有时间衰减避免”历史高频但当前不用”的 key 占位。

十二、总结与个人思考

算法家族的谱系

OPT (1966, 离线最优)
 │
 ├── LRU (纯 recency)
 │    ├── CLOCK (硬件近似 LRU)
 │    │    ├── Enhanced CLOCK (加脏页位)
 │    │    └── CLOCK-Pro (加冷/热区分 + 影子条目)
 │    ├── 2Q (两队列,类似 Linux active/inactive)
 │    └── LIRS (Inter-Reference Recency)
 │
 ├── LFU (纯 frequency)
 │    └── Window-LFU / TinyLFU (带时间窗口)
 │
 └── ARC (自适应 recency + frequency)
      └── CAR (CLOCK with Adaptive Replacement)

选型建议

场景 推荐算法 原因
操作系统页面置换 CLOCK 变体 + workingset detection 只能用硬件引用位,不能有链表开销
数据库缓冲池 ARC 或 2Q 可以承受软件开销,需要抗 scan 能力
CDN / 反向代理缓存 LRU + admission filter 对象大小差异大,需要 size-aware 淘汰
Redis / memcached 采样 LRU 或 LFU 百万级 key 下精确 LRU 太贵
CPU 缓存(L1/L2/L3) Tree-PLRU 或 RRIP 硬件实现,延迟约束 1-2 个周期

最后的话

页面置换问题教给我们一个关于系统设计的深刻道理:最优的离线算法往往简单优美,但在线版本的”近似最优”才是真正的工程挑战。

LRU 的理论很漂亮——stack algorithm、无 Bélády 异常、O(1) 实现。但现实击碎了这种美丽:顺序扫描、循环模式、实现代价。ARC 的伟大之处在于,它用一个简洁的自适应机制(影子条目 + 参数 \(p\))解决了 LRU 的核心缺陷,而且代价极小——只需要多维护两个 ghost list。

如果你在设计任何形式的缓存系统,我的建议是:不要从 LRU 开始。从你的工作负载特征开始。是 recency 主导还是 frequency 主导?有没有 scan 模式?如果你不确定(或者工作负载会变化),直接用 ARC。如果不能用 ARC(IBM 专利问题),用 2Q 或 CLOCK-Pro。

LRU 只在一个场景下是正确的选择:当你需要一个最简单的基线实现、且不关心最后 20% 的命中率时。


上一篇: 内存分配器对决:jemalloc vs tcmalloc vs mimalloc
下一篇: 进程调度:从 CFS 到 EEVDF 的哲学演变

相关阅读: - 缓冲池管理算法:LRU-K → 2Q → CLOCK-Pro - 红黑树 vs AVL:Linux 内核为什么选红黑树 - epoll 的数据结构


By .