先说一个很常见的场景:你手上有一个几百 MB 甚至几个 GB 的大文件,比如一份日志、一个数据库文件、一张离线地图。你并不需要一次性把它全部读进内存——那既不现实也浪费——你只是反反复复地读取其中一小段:一会儿看第 100 个偏移处的数据,一会儿读第 200000 个偏移附近的记录。

麻烦来了。磁盘很慢,内存很快。 如果你每次都老老实实地用 read() 从文件里把那段数据重新读一遍,那你每一次请求都要付出一次磁盘 I/O 的代价。哪怕这段数据一小时前刚被读过,只要没人记住它,就得再从磁盘搬一遍。这种浪费,专业一点说就是"重复 I/O"。

缓存(cache)就是冲着这个问题来的:把最近用过的数据暂时留在内存里,下次再来要的时候,直接内存返回,不再碰磁盘。 而 Linux 里恰好有个非常适合做"把文件的某一段映射进内存"的系统调用,叫 mmap。

这篇文章我们就一边讲原理、一边手把手写一个"用 mmap 实现文件块缓存"的小工程,并且让它带着 LRU 淘汰策略跑起来。你要准备的只有:一台 Linux 机器、一个 GCC 编译器、以及"愿意把这些概念一个个掰开揉碎"的耐心。

今天我们要解决什么问题

先把目标说清楚,免得写代码时迷失方向。我们要完成的东西大概是这样的:

  • 把一个大文件切成很多个固定大小的小块,每个块 4KB(4096 字节)。这里"块"的英文是 block,所以按块做缓存就叫 block cache(块缓存)。
  • 我们提供一个接口 GetBlock(off),传入一个文件里的字节偏移 off,返回"包含这个偏移的那一块数据"。
  • 内存不可能无限大,所以我们给缓存一个容量上限,比如最多同时缓存 3 个块。缓存满了,又来新块,就必须淘汰一个旧块腾地方。
  • 淘汰谁?谁最久没用就淘汰谁。这就是 LRU(Least Recently Used,最近最少使用) 的核心思想。
  • 用什么把块"放到内存"?用 mmap,让内核帮我们把文件的那一小块和进程的一块虚拟地址空间绑定起来,之后访问它就像访问普通内存一样。

一句话概括:mmap 负责"把文件块搬进内存",LRU 负责"缓存装不下的时决定丢掉谁"。

为什么读文件要大费周章做缓存?因为真实的程序里这样的访问模式极其普遍:一个程序可能在某段时间内,反复命中同一批很小的数据(比如索引、配置、热数据)。把这热的一小撮留在内存里,绝大部分请求就都从内存回了,命中率一高,性能就上来了。而我们的重点,是亲手把这个机制实现出来,看看它到底是怎么运行的。

缓存世界必须搞懂的三个名词

在往下写代码之前,有三个词必须先弄明白,不然看代码像看天书。

第一个:缓存命中率(cache hit rate)。 它很简单,就是一个比值:

缓存命中率 = 命中缓存的次数 ÷ 总访问次数

假设我们访问了 100 次块,其中 90 次是直接在内存缓存里命中的、没碰磁盘,只有 10 次真正去读了磁盘,那命中率就是 90%。命中率越高,说明缓存越"有用",平均每次访问的耗时就约低;反之如果命中率低到离谱,缓存就没起作用,白占内存。

第二个:block cache(块缓存)。 它指的是缓存的一种组织粒度。我们不把文件当一整块来管理,而是切成一块一块(比如 4KB 一块),以"块"为单位做命中、加载、淘汰。为什么按块而不是按整个文件?因为文件太大,没法整体都留内存;而且业务访问往往只落在文件的局部,按块缓存可以只留"被访问过的那些个小片区域",精确而省内存。块大小选 4KB 也很有讲究——它基本和系统的"页"大小一致,具体原因我们到"页"那一节再讲。数据库、文件系统底层到处都是这种 block cache 思想。

第三个:LRU(Least Recently Used,最近最少使用)。 它是一个淘汰算法,回答的是"缓存满了,该扔掉哪一块"这个问题。LRU 的哲学是:如果一块数据很久没被碰过,说明它将来被碰的概率也不大,那就优先淘汰它。 反过来,最近刚被访问过的数据,我们认为它"热点尚存",要尽量保住它。可以把内存想象成一张很窄却很整齐的"最近使用排行榜":每次用到一个块,就把它提到榜首;当榜单挤满、又要上新号时,就把垫底的(最久没用的)赶下去换新人。这个"提到榜首 / 淘汰垫底"的操作,就是 LRU 的全部。

这三个名词,后面我们会反复用到,尤其是 LRU——它还会引出双向链表和哈希表这对搭档,我们细讲。

到底什么是 mmap 和 munmap

先把今天的主角请出来。mmap 全称是 memory map,中文叫"内存映射"。它是一个 Linux 系统调用,作用一句话:把一个文件(或者别的东西,比如一块匿名内存区域)映射到进程的虚拟地址空间里。映射完成之后,你对这块虚拟内存的读写,就等价于对这个文件的读写,不需要你再走 read() / write() 那套繁杂的系统调用,也不用自己管理缓冲区。

mmap 的原型长这样:

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

逐项拆开看:

  • addr:你希望映射在哪块虚拟地址,通常填 NULL,让内核帮你挑一块合适的空闲区域。它只是个"建议",内核可以不理会。
  • length:映射多少字节。
  • prot:保护标志(protection),决定这段内存的访问权限。常见取值有 PROT_READ(可读)、PROT_WRITE(可写)、PROT_EXEC(可执行)、PROT_NONE(什么都不能做)。我们要读写,就用 PROT_READ | PROT_WRITE。
  • flags:映射的类型,决定映射的语义。最常用的两个是 MAP_SHARED(对映射的修改会同步回文件,常用于跨进程共享和文件读改写)和 MAP_PRIVATE(写时复制,修改只对本进程可见,常用于把文件当只读映射或代码段)。我们做缓存并且希望能把改动写回文件,用 MAP_SHARED。
  • fd:被映射的文件描述符。
  • offset:从文件的哪个偏移开始映射。这条极其关键:offset 必须是系统"页大小"的整数倍。 为什么,我们在下一页专门讲。

它返回映射区的起始虚拟地址。如果失败,返回一个特殊值 MAP_FAILED(它本质上是 (void *) -1),同时设 errno。

有"映射"自然就有"解映射"。当你用完这段映射,或者不想再让它占着地址空间时,调用 munmap(memory unmap)把它卸掉:

int munmap(void *addr, size_t length);

参数就是你 mmap 时拿到的那两个值:起始地址 addr 和长度 length。成功返回 0,失败返回 -1。

让我们写一个最小的 mmap 例子,亲手感受一下"像读写内存一样读写文件"是种什么体验。假设我们先用 dd 造一个 16 字节的名字为 demo.txt 的文件(内容全是零):

# 造一个 16 字节、内容全 0 的文件
dd if=/dev/zero of=demo.txt bs=1 count=16

然后写下这段 mmap 演示代码:

// mmap_demo.c
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <string.h>
#include <stdlib.h>
 
int main(void)
{
    int fd = open("demo.txt", O_RDWR);   // 以可读写方式打开,才能写回
    if (fd < 0) { perror("open"); exit(1); }
 
    struct stat st;
    if (fstat(fd, &st) < 0) { perror("fstat"); exit(1); }
    size_t len = (size_t)st.st_size;     // 拿到文件大小
 
    // 把整个文件映射进地址空间
    char *p = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    if (p == MAP_FAILED) { perror("mmap"); exit(1); }
 
    // 现在可以像用普通内存一样操作这块区域
    strcpy(p, "HELLO");                  // 写进"内存",等于写进文件
    p[5] = '!';                          // 再修改一个字节
    printf("content: %s\n", p);          // 从"内存"读出来
 
    if (munmap(p, len) < 0) { perror("munmap"); exit(1); }
    close(fd);
    return 0;
}

编译运行后你会发现,demo.txt 的内容真的被改成了 HELLO!。整个过程中你几乎没感受到"这是文件"——它看起来就是一段普普通通的、能读能写的内存。这就是 mmap 最迷人的地方:它把"文件 I/O"这件事,简化成了"内存访问"。

编译命令很简单:

gcc mmap_demo.c -o mmap_demo
./mmap_demo

我们在这个例子里用的是"把整个文件映射进来"。但在真实大文件场景下,你不该一次性映射整个文件(那等于把文件全搬进虚拟空间,页表开销巨大)。所以做缓存时,我们只映射"被请求的那一小块"——这正好和"块缓存"的思路完全吻合。

页、页表与缺页中断:理解 mmap 的"懒"

为什么刚才说 mmap 的 offset 必须按页对齐?要回答它,得先搞清楚操作系统管理内存的一条基础单位——页(page)。

页(page) 是操作系统把虚拟内存和物理内存划分成的一个个固定大小的块,常见的页大小是 4KB。你可以把"按页管理内存"理解成:操作系统不关心你字节级的碎账,它以 4KB 为最小记账单位来管理"哪些页面被用了、哪些空闲、虚拟页对应到哪块物理页"。

为了让"虚拟地址 → 物理地址"能相互翻译,每个进程都有一张叫页表(page table) 的数据结构,它记录着这个进程每一个虚拟页对应到哪一块物理页。你程序里访问一个内存地址时,CPU 的硬件(MMU,Memory Management Unit,内存管理单元)会查页表,把虚拟地址翻译成物理地址,再去真正取数据。

问题来了:如果程序访问了一个"虚拟页在页表里根本映射不到任何物理页"的地址,会发生什么?CPU 会触发一个异常,Linux 管这个异常叫缺页中断(page fault)。缺页中断不是错误,而是一种"机制":内核收到这个异常后,会去把这个页真正准备好——比如从磁盘把数据读进物理内存、填好页表——然后让刚才那条访问指令重新执行一遍。

有了页和缺页中断这两个概念,mmap 的真面目就清楚了。mmap 的时候,内核其实只做了很轻的活:在进程的虚拟地址空间里划出一块区域,登记好"这块虚拟区域将来对应文件里的哪一段"(也就是建好一个叫 VMA 的记录),并不立刻把文件内容读进内存。 真正把数据搬进内存的那一下,发生在你第一次去访问这块虚拟地址、触发缺页中断的时候——内核的缺页处理程序发现"啊,这块虚拟内存对应文件里的某一段,我还没读呢",这才去磁盘把对应的页读进来、填好页表、让你继续访问。

这就是为什么我们说 mmap 是"懒"的(lazy):它建立的是"文件和内存之间的映射关系",而不是"立即拷贝数据"。这个懒加载的好处是:如果一段数据永远没人访问,那它永远不占物理内存。你映射了一个 1GB 的文件,但只要只访问其中 100 个零散的页,那物理内存里就真的只多了那 100 个页,剩下的只是一个"映射关系"在那里排队,一点都不浪费。

现在回头看那两个约定了:

  • 为什么 offset 必须页对齐? 因为文件是按页读进内存的,一次至少读一页,所以文件侧的起点必须从某一个页的开头开始,也就是页大小(4KB)的整数倍。你在代码里常见的 off & ~0xFFF 这种位运算(也叫 BLOCK_ADDR_ALIGN),作用就是把一个任意偏移"向下对齐"到 4KB 的整数倍——去掉低 12 位,结果就是 4KB 的倍数。假如你给 mmap 传一个没对齐的 offset,内核直接给你返回错误 EINVAL(参数非法)。
  • length 也要留个心眼。 内核做映射时,映射区的范围会按页向上取整。也就是说你映射 4040 字节,内核实际给你的是 4096 字节(一页)。这多出来的零点几页属于"映射到了文件末尾之外"的区域,虽然占着虚拟地址空间,但一旦你去访问超出文件部分的地方,会触发 SIGBUS(总线错误)——这一点在我们后面写缓存、处理"文件末尾不足一个整块"时会直接碰到,得提前有意识。

理解到这,我们已经有了写块缓存所需的全部底层认知:块大小选 4KB(== 一页),可用 mmap 把"文件里某个对齐偏移处的一页"映射进内存,命中就用、淘汰时用 munmap 卸掉。

LRU:最近最少使用算法

接下来实现软件层的"淘汰策略"。我们先说清 LRU 的两条核心法则:

  1. 一个数据被访问(读取、命中)时,就认为它"最近使用过",把它挪到最前面。
  2. 数据不足(需要新缓存的块进来但容量满了)时,从最后面淘汰掉那个最久没碰过的。

为了做到"查找快、挪动快、淘汰快",经典 LRU 用一个双向链表加一个哈希表来配合:

  • 双向链表(doubly linked list):用来存真正缓存的数据,并按"访问的先后顺序"排好——头部是最近访问的,尾部是最久未访问的。链表的好处是:把一个节点从中间摘下来、再插到头部,都只要常数次指针操作,非常快。
  • 哈希表(hash table):用来快速回答"某个数据在不在缓存里、在的话它的链表节点在哪"。它解决了纯链表的一个痛处——在链表中找一个节点需要从头遍历 O(n),太慢。哈希表的值存的是"指向链表节点的指针(迭代器)",这样"查找到底在不在"就从 O(n) 降到了 O(1)。

为什么非得两者结合?单用链表:插入/淘汰都方便,但"查找在不在"慢。单用哈希表:查找快,但"按访问顺序排序"无从谈起,没有"谁最久没用"这个概念。所以二者一拍即合:哈希负责"找得秒",链表负责"排得清"。

两个核心操作:

  • Get(获取):先在哈希表里找。找到了 → 把这个节点搬到链表头部(表示最近用过了)→ 返回值。找不到 → 返回"未命中"。
  • Put(插入):先在哈希表里找。已存在 → 更新值,并搬到头部。不存在 → 新建节点插到头部,并在哈希表登记。插入后如果超出容量上限 → 把链表尾部的节点(最久未用)删掉,同时从哈希表里也删掉它。

这样,无论 Get 还是 Put,每一步关键动作都是 O(1):哈希查找是 O(1),链表的头插、删除、把任意节点摘到头部也全在常数时间内完成。所以 LRU 的 Get 和 Put 时间复杂度都是 O(1),空间复杂度是 O(n)(n 是缓存中的数据量,哈希表和链表各存一份)。

下面是 LRU 最经典、最教科书式的一种实现。它先把键值都当作整数,把"算法骨架"摆得明明白白,方便你一眼看懂——我们后面那个用 mmap 做文件块缓存的真实工程,正是这个骨架换了一层"衣服"而已:

// lru_demo.cpp —— 经典 LRU 结构骨架(整数键值示例)
#include <iostream>
#include <list>
#include <unordered_map>
using namespace std;
 
class LRUCache {
public:
    explicit LRUCache(int cap) : _cap(cap) {}
 
    int get(int key) {
        // 1. 哈希表查找:在不在缓存里?
        auto it = _map.find(key);
        if (it == _map.end()) return -1;              // 未命中
 
        // 2. 命中:把节点搬到链表头部(最近使用)
        _list.splice(_list.begin(), _list, it->second);
        return it->second->second;                    // 返回其 value
    }
 
    void put(int key, int value) {
        auto it = _map.find(key);
        if (it != _map.end()) {                       // key 已存在
            it->second->second = value;               // 更新 value
            _list.splice(_list.begin(), _list, it->second); // 搬到头部
            return;
        }
        _list.emplace_front(key, value);              // 新节点头插
        _map[key] = _list.begin();                    // 记录其链表迭代器
        if (_map.size() > (size_t)_cap) {             // 超容量
            _map.erase(_list.back().first);           // 从哈希删掉尾部 key
            _list.pop_back();                         // 从链表弹出尾部
        }
    }
 
private:
    int _cap;
    // 双向链表:pair<key,value>,头部最新、尾部最旧
    list<pair<int,int>> _list;
    // 哈希表:key -> 指向链表节点的迭代器,实现 O(1) 定位
    unordered_map<int, list<pair<int,int>>::iterator> _map;
};
 
int main(void)
{
    LRUCache c(2);              // 容量为 2
    c.put(1, 10);               // 缓存 [1]
    c.put(2, 20);               // 缓存 [2,1]
    cout << c.get(1) << endl;   // 命中 1,链表变 [1,2],输出 10
    c.put(3, 30);               // 超容量,淘汰最久未用的 2
    cout << c.get(2) << endl;   // 2 已被淘汰,输出 -1
    cout << c.get(1) << endl;   // 输出 10
    cout << c.get(3) << endl;   // 输出 30
    return 0;
}

std::list::splice 是 C++ 标准库给我们的一张好牌:它把 _list 中由第三个参数(迭代器 it->second)所指的那个节点,整段搬到 _list 的头部(_list.begin() 之前),且常数时间完成。这样"命中后搬到头部"一行就搞定了,也正是双向链表最大的价值——能在常数时间内,把一个节点从链表任意位置摘下来再挪走。

编译运行:

g++ lru_demo.cpp -o lru_demo
./lru_demo

输出会是:10 -1 10 30。

到这里,我们对 LRU 已经有理论、有骨架。接下来就是本文的正题:把这个 LRU 骨架,"嫁接"到用 mmap 管理文件块的真实工程上,让缓存的数据不是普通 int,而是一个个"映射进内存的文件块"。

整体设计:用 mmap 实现文件块缓存

现在我们把三层知识(mmap 的懒加载、页与对齐、LRU 的双链表+哈希)揉进一个完整的类。为了好讲,也为了能和手头的代码一一对上,我们按源工程的结构拆成两个类、两套职责:

  • DataBlock(一个块):描述"文件里的某一小块"。它只负责两件关于自己的事——DoMap(把自己的区间用 mmap 映射进内存)和 DoUnmap(用 munmap 卸载);另外带着自己的身份信息(在文件里的偏移 _off、大小 _size、映射到的地址 _addr)和一个"当前状态"。
  • FileCache(缓存中枢):负责调度一切——打开文件、知道文件总大小、判断某个偏移是否合法、某个块是否已缓存、缓存是否已满、需要时加载新块、需要时执行 LRU 淘汰。内部持有那张 LRU 的双向链表 _cache 和那张快速定位的哈希表 _hash。

为什么要拆成两个类?这就叫职责分离 / 解耦:DataBlock 是"数据 + 硬件(系统调用)打交道"的角色,FileCache 是"策略 + 调度"的角色。块不知道自己是缓存中的老几、不负责维护顺序;缓存不关心某块具体怎么映射、映射到哪。双方各管一端,将来想换映射方式(比如不用 mmap 改用 pread),只需要动 DataBlock 一个类;将来想换淘汰策略(比如改成 LFU),只需要动 FileCache,互不牵连。这种"把一个完整流程拆成各自独立、边界清晰的组件"的思想,正是工程上天天说的"解耦"——每个类只回答一个领域的问题,修改一个不牵动另一个。

关于"解耦",多说一句重要的:缓存这种场景里,"什么时候淘汰"与"淘汰后对这块数据做什么后续处理"往往也是两个不同的关注点。比如一个块被淘汰时,可能要先把它写回磁盘(因为它是脏的),可能要释放它关联的其他资源。一个解耦做得好的设计,应该允许"缓存负责决定淘汰时机",然后把"淘汰后的善后动作"通过回调函数(callback) 暴露出去,交给外界去定。因为"怎么写回""释放什么资源"是业务逻辑,不该硬编码进缓存类里。我们在"边界与坑"一节会用一个代码片段专门演示这种回调解耦,现在先埋个伏笔:把状态/动作与具体处理分离,是贯穿整个工程的一条主线。

在这个工程里,解耦通过一个巧妙的状态机来落地。我们给每个块一个 _status 状态位,用不同的位组合表示它正处于哪个阶段,然后在统一的 DoLRU 入口里,根据状态决定"该晋级还是该淘汰"。这与"回调解耦"是同一思想的两副面孔:被访问方只负责把自己的诉求标记成状态,处理方在统一入口按状态分发处理。 这样,触发方(GetBlock/插入)和决策方(DoLRU)彻底分开,逻辑清晰好维护。

DataBlock:单个块的"存取模型"

先写 DataBlock。它管着一个块最底层的两只手:给系统发指令。

类里有几个成员:

  • _off:这块在文件里的起始偏移。注意,它一定是 4096(4KB)对齐的——因为我们总是从某个块的边界开始映射。
  • _size:这块实际有多大。除了文件末尾那一块可能不足 4KB,其余块都是整 4KB。末尾块的处理是我们后面要重点讲的"坑"。
  • _addr:这块映射进进程地址空间后的起始地址。它是 mmap 的返回值。
  • _status:状态位。我们用独立的位表示不同状态。

状态位我们这样定义,每个状态占一个独立的二进制位,这样可以用位运算组合和判断:

#define NORMAL (1 << 0)   // 普通状态:已经在链表中正确位置,暂时无需移动
#define NEW    (1 << 1)   // 新插入:刚被加载进缓存,等待首次状态判定
#define VISIT  (1 << 2)   // 被访问:本次 Get 命中了它,需要晋升到链表头部
#define DELETE (1 << 3)   // 待淘汰:准备从缓存移除

注意一个极容易踩的坑:这几个状态必须各自占用完全不同的位。 源课件里 VISIT 和 DELETE 都写成了 (1 << 2),等于给两个不同含义的状态用了一样的位——这是典型的"复制粘贴忘了改"事故。它平时不太显眼(因为 DELETE 在实际淘汰流程里没被使用),一旦将来想在淘汰时干点什么,用位判断就会把"被访问"和"待淘汰"混为一谈。正确写法就是上面这种每个状态独立占位。我在讲解时顺手把它修正过来,后面代码都以 DELETE = (1 << 3) 为准。

下面是 DataBlock 的完整实现。状态位相关的几个位运算操作我们讲清楚:UpdateStatus 是"先清零、再置为新状态"(所以状态是互斥的,同一时刻只有一个状态生效);ConfirmStatus 是"检查某个位是否置位"。

// DataBlock.hpp —— 放在一个块属于自己的逻辑
class DataBlock
{
private:
    // 用一个新状态覆盖旧状态:先清除全部位,再只置上这次的状态
    void UpdateStatus(unsigned status)
    {
        _status = 0;
        _status |= status;
    }
 
    // 检查某个状态位是否为 1(返回非 0 即命中该状态)
    bool ConfirmStatus(unsigned status)
    {
        return _status & status;
    }
 
public:
    DataBlock(off_t off, off_t size)
        : _off(off), _size(size), _addr(nullptr), _status(NEW)
    {
    }
 
    // 把自己的区间从文件映射进进程的虚拟地址空间
    bool DoMap(int fd)
    {
        // mmap:起始地址交给内核(NULL);映射 _size 字节;
        // 可读可写;MAP_SHARED 保证修改能写回文件;fd 是文件;_off 是已对齐偏移
        _addr = ::mmap(nullptr, _size,
                       PROT_READ | PROT_WRITE,
                       MAP_SHARED,
                       fd,
                       _off);
        if (_addr == MAP_FAILED)          // 返回 MAP_FAILED 说明失败
        {
            perror("mmap");
            _addr = nullptr;
            return false;
        }
        std::cout << "mmap 加载 off=" << _off << " 成功" << std::endl;
        return true;
    }
 
    // 解除映射:把这一个块从地址空间里卸掉
    bool DoUnmap()
    {
        if (_addr == nullptr) return true;  // 从没映射过,无需处理
 
        int n = ::munmap(_addr, _size);     // 解除从 _addr 起的 _size 字节映射
        if (n < 0)
        {
            perror("munmap");
            return false;
        }
        std::cout << "munmap 移除 off=" << _off << " 成功" << std::endl;
        _addr = nullptr;   // 置空,防止外界残留指针继续访问一块已失效的区域
        return true;
    }
 
    // ---- 状态设置 ----
    void Status2Normal() { UpdateStatus(NORMAL); }
    void Status2New()    { UpdateStatus(NEW); }
    void Status2Visit()  { UpdateStatus(VISIT); }
    void Status2Delete() { UpdateStatus(DELETE); }
 
    // ---- 状态确认 ----
    bool IsNormal() { return ConfirmStatus(NORMAL); }
    bool IsNew()    { return ConfirmStatus(NEW); }
    bool IsVisit()  { return ConfirmStatus(VISIT); }
    bool IsDelete() { return ConfirmStatus(DELETE); }
 
    // ---- 属性获取 ----
    off_t Off()  { return _off; }   // 该块在文件中的起始偏移
    void *Addr() { return _addr; }  // 映射进内存后的起点
    off_t Size() { return _size; }  // 该块真实大小
 
    // 调试打印:把块的各个字段/状态打出来
    void DebugPrint()
    {
        std::cout << "_off=" << _off
                  << " _size=" << _size
                  << " _addr=" << _addr
                  << " _status=";
        if (IsNormal()) std::cout << "NORMAL ";
        if (IsNew())    std::cout << "NEW ";
        if (IsVisit())  std::cout << "VISIT ";
        if (IsDelete()) std::cout << "DELETE ";
        std::cout << std::endl;
    }
};

注意 DoUnmap 里那句 _addr = nullptr;——这里藏着一个非常重要的安全细节:munmap 之后,_addr 指向的地址区间已经不再属于本进程了,任何再往那个地址的读写都是"未定义行为"(极大概率是段错误 SIGSEGV)。 所以卸载之后立刻把 _addr 置空,并且规定"地址为空的块不允许被访问",是从源头扼杀悬垂指针的习惯。这个坑我们专门留了一节细讲。

FileCache:管理所有块的"大脑"

FileCache 是工程的调度中心。它手里攥着几样东西:

  • _file:文件名;_fd:打开后的文件描述符;_total:文件总大小;_cacheMaxNum:最多缓存的块数(容量)。
  • _cache:一个 std::list<std::shared_ptr<DataBlock>>,就是那根 LRU 的双向链表,头部最新、尾部最旧。
  • _hash:一个 std::unordered_map<off_t, std::shared_ptr<DataBlock>>,用块的偏移当键,快速回答"这个块缓存里有没有、在哪个节点"。它存的 value 和链表里存的是同一个 shared_ptr,所以两边共享同一份块对象、只是组织方式不同——这正是哈希表 + 链表配合的更"软"一点的形式:链表管顺序、哈希管查找,但指向的都是同一批对象。

为什么用 shared_ptr 而不裸指针?一方面自动回收内存、规避内存泄漏;另一方面,shared_ptr 的"引用计数"天然就能支持这种"同一个块同时被多个地方共享持有"的场景——哈希和链表都持有它,只要还有一处引用它,块就不会被销毁。这会在后面聊"munmap 与生命周期"时产生一个很有意思的微妙点,记在心里。

初始化(构造函数里)做几件开门的事:

  1. open(_file.c_str(), O_RDWR) 以读写方式打开那个文件——文件必须事先存在,否则缓存无意义。
  2. fstat(fd, &st) 拿到文件大小 st.st_size,存进 _total。
  3. _cacheMaxNum = gCapacity,容量固定(比如 3)。

然后是一组"助手函数"(判断类的),它们都小而直白:

// 这个偏移是否落在文件范围内
bool IsOffLegal(off_t off)      { return off < _total; }
 
// 这个块是否已经被缓存了(哈希表里找得到它)
bool IsCached(off_t off)        { return _hash.find(off) != _hash.end(); }
 
// 缓存是不是已经满了(注意这里是比较 "> 容量",表示"再多一块就要淘汰")
bool IsCacheFull()              { return _cache.size() > (size_t)_cacheMaxNum; }

接着是取块大小、真正加载块、执行 LRU 这三个核心函数。

先看"算块大小" GetSizeFromOff。 这里藏着一个非常容易错、也最容易被人忽略的地方。直觉上,被请求的块默认就是整 4KB。但如果请求的偏移已经紧贴文件末尾,这一块可能装不满 4KB——比如文件总大小是 10000 字节,它不是 4096 的整数倍,那么从偏移 8192 开始的那一块,只剩下 10000 - 8192 = 1808 字节,而不是整 4096。正确写法应该是"文件剩下的字节数和整块大小取较小者":

// 根据对齐后的起始偏移,算出这一块的真实字节数
off_t GetSizeFromOff(off_t off)
{
    off_t size = gBlockSize;                // 默认一整块(4096 字节)
    if (off + gBlockSize > _total)          // 这一块已经顶到文件末尾了
        size = _total - off;                // 末尾还剩下多少,就映射多少
    return size;
}

这里要特别点名一个细节:别想当然地写 size = _total % gBlockSize。_total % gBlockSize 只表示"文件总大小对 4KB 取余",和"这一块实际还剩下多少"并不是一回事。 只有当 off 恰好处在文件最后一个块的起点时,两者数值才碰巧一样;一旦请求的是别的块,% 算出来的结果就是错的,会导致 mmap 的长度不对(甚至可能为 0,mmap 长度为 0 会返回失败)。所以正确的语义就是 _total - off(剩余字节),既直观又稳。这也是"可靠理解问题胜过背结论"的一个例子。

再看"真正加载一块" DoCache。 它是新块进入缓存的完整流程:算出真实大小 → 构造块对象(初始状态设为 NEW)→ mmap 映射进内存 → 登记进哈希表 → 头插到链表:

void DoCache(off_t off)
{
    // 1. 计算这一块的真实字节数(末尾块可能不足 4KB)
    off_t size = GetSizeFromOff(off);
 
    // 2. 构造一个 DataBlock 对象:记录偏移和大小,初始状态为 NEW
    std::shared_ptr<DataBlock> block =
        std::make_shared<DataBlock>(off, size);
 
    // 3. 真正用 mmap 把这一块映射进地址空间(懒加载,内容在首次访问时才读进内存)
    block->DoMap(_fd);
 
    // 4. 登记进哈希表,方便以后 O(1) 判断"在不在缓存"
    _hash.insert(std::make_pair(off, block));
 
    // 5. 头插进双向链表,表示"它刚被加进来,是最新的"
    _cache.push_front(block);
}

最后看 LRU 的执行入口 DoLRU。 这是整个工程里逻辑密度最高的一段。它的套路是:根据这一块当前的状态位,分两种情况处理。为什么要分状态?因为"命中一个已有块"和"新插入一个块"是两种完全不同的情境,前者要晋升到头部,后者要检测是否已经装满、要不要淘汰尾部。把它们放在一个统一入口里按状态分发,就是前面说的"状态机解耦":

void DoLRU(off_t off)
{
    if (!IsCached(off))      // 没这个块?那不用处理
        return;
 
    // 情况 A:这是一个刚被插入的 NEW 块
    if (_hash[off]->IsNew())
    {
        // 先转正:它现在正式占用一个缓存位了
        _hash[off]->Status2Normal();
 
        // 如果缓存已经满了,就得淘汰最久未用的那一个(链表尾部)
        if (IsCacheFull())
        {
            // 1. 把尾部块从地址空间里卸载(munmap)
            _cache.back()->DoUnmap();
            std::cout << "cache 淘汰 off=" << _cache.back()->Off() << std::endl;
 
            // 2. 从哈希表里删掉这个被淘汰的块
            _hash.erase(_cache.back()->Off());
 
            // 3. 从链表的尾部弹出它
            _cache.pop_back();
        }
    }
    // 情况 B:这一块刚被访问命中
    else if (_hash[off]->IsVisit())
    {
        _hash[off]->Status2Normal();     // 状态先归位
        _cache.remove(_hash[off]);       // 先在链表中把它摘下来
        _cache.push_front(_hash[off]);   // 再放到链表头部(表示最近使用)
        std::cout << "将 off=" << off << " 移到链表头部" << std::endl;
    }
    // 情况 C:其他状态(如已是 NORMAL 且没有新请求),什么都不用做
}

这里请你慢慢品两件事。

第一,"满了才淘汰"的时机。注意 DoCache 里新块 push_front 之后,链表 size 变成了 容量 + 1(如果本来已经满了的话),于是 IsCacheFull()(.size() > _cacheMaxNum)此时为真,才触发淘汰尾部。也就是说,缓存不是"到了容量就立刻淘汰",而是"先多放一个,再在这个时机把旧的淘汰掉",从而始终保持缓存里的存活量等于容量。这是一种"插入后统一清算"的实现策略,逻辑简单,行为正确。

第二,"命中即晋级"的语义。当我们 Get 命中一块时,GetBlock 先把它的状态置成 VISIT,等下调用 DoLRU 时,检测到 VISIT 就把它从链表中间摘下、插到头部。这正是 LRU 的核心动作"最近使用过的,提到最前面"。而 NEW(新插入)的块我们只做"满则淘汰",不做"挪到头部"——它本来就在头部(DoCache 里刚 push_front),无需再动。

这里还涉及一个 std::list 的细节:_cache.remove(_hash[off]) 是"按值匹配删除所有等于该节点的元素",而我们想让它在 O(1) 内精确摘掉当前这个节点,其实更地道的写法是用 splice 或 erase 直接指向的迭代器。不过对教学工程,remove 简单直观、也够用。为了更贴合标准 LRU 的教科书式 O(1),我在"完整工程"里改用 std::list::splice(和前面 lru_demo 一致),把"命中位移到头部"写成一行,读者可以对比两种写法。

最后是核心的对外接口 GetBlock(off),它把整个流程串起来:

// 对外主入口:给定一个字节偏移,返回包含它的那个缓存块
std::shared_ptr<DataBlock> GetBlock(off_t off)
{
    // 1. 偏移越界(超出文件大小)直接失败
    if (!IsOffLegal(off))
        return nullptr;
 
    // 2. 把请求的任意字节偏移,向下对齐到 4KB 块边界
    off = BLOCK_ADDR_ALIGN(off);
 
    // 3. 判断命中
    if (_hash.find(off) != _hash.end())   // 在缓存里 -> 命中
    {
        // 标记"本次被访问",稍后 DoLRU 会把它晋升到头部
        _hash[off]->Status2Visit();
    }
    else                                   // 不在缓存里 -> 未命中,加载
    {
        DoCache(off);
    }
 
    // 4. 统一做一次 LRU 处理(校验顺序、必要时淘汰尾部)
    DoLRU(off);
 
    // 5. 返回这个块
    return _hash[off];
}

GetBlock 的流程和前面经典 LRU 的 get 是一一对应的:查哈希(命中/未命中)→ 命中则晋级、未命中则加载 → 统一维护链表顺序。唯一的差别是,它操作的对象是"文件块",而不是"整数键值对"。

到这里,类的主体就齐了。我们把代码串成一个完整的、能编译、能跑的工程。

完整可运行的工程代码

我们把上面所有零碎拼成三个文件:LRUCache.hpp(含 DataBlock 与 FileCache)、Main.cc(测试程序)、Makefile(构建脚本)。代码里我沿用了源工程的命名习惯(_off、_size、_addr、_status、DoMap、DoUnmap),并做了修辞上的完善:修正了 DELETE 位冲突、末尾块大小用 _total - off、命中晋升用 splice 保证 O(1)。每个类、每个函数都写了逐行注释,方便对照阅读。

首先是头文件中的配置常量与状态宏,以及 DataBlock:

// 本示例的全局配置与状态定义(放在头文件顶部)
const int gDefaultFd  = -1;       // 无效文件描述符的标记
const off_t gBlockSize = 4096;    // 一块 4KB,与系统页大小一致
const int gCapacity   = 3;        // 最多同时缓存 3 个 block(取小便于观察)
 
// 把偏移向下对齐到 4KB:清除低 12 位
#define BLOCK_ADDR_ALIGN(off) ((off) & ~((off_t)0xFFF))
 
// 状态位(每个状态占用独立的一 bit)
#define NORMAL (1 << 0)   // 普通:已在链表中正确位置,无需移动
#define NEW    (1 << 1)   // 新插入:刚被加载,等待首次判定
#define VISIT  (1 << 2)   // 被访问:本次命中,需要晋升到头部
#define DELETE (1 << 3)   // 待淘汰:准备移出缓存

然后是完整的 DataBlock:

// DataBlock —— 描述文件里的单个块,负责与系统调用打交道
class DataBlock
{
private:
    void UpdateStatus(unsigned status) { _status = 0; _status |= status; } // 重置并置新状态
    bool ConfirmStatus(unsigned status) { return _status & status; }       // 判断某位是否置位
 
public:
    DataBlock(off_t off, off_t size)
        : _off(off), _size(size), _addr(nullptr), _status(NEW)
    {
    }
 
    // 把这块从文件映射进进程地址空间
    bool DoMap(int fd)
    {
        _addr = ::mmap(nullptr, _size,
                       PROT_READ | PROT_WRITE,  // 可读可写
                       MAP_SHARED,              // 修改可写回文件
                       fd, _off);               // 该块在文件中的起始偏移(已对齐)
        if (_addr == MAP_FAILED)
        {
            perror("mmap");
            _addr = nullptr;
            return false;
        }
        std::cout << "mmap 加载 off=" << _off << " 成功" << std::endl;
        return true;
    }
 
    // 解除这块的映射
    bool DoUnmap()
    {
        if (_addr == nullptr) return true;   // 没映射过就不处理
        if (::munmap(_addr, _size) < 0) { perror("munmap"); return false; }
        std::cout << "munmap 移除 off=" << _off << " 成功" << std::endl;
        _addr = nullptr;                     // 置空,防止悬垂访问
        return true;
    }
 
    void Status2Normal() { UpdateStatus(NORMAL); }
    void Status2New()    { UpdateStatus(NEW); }
    void Status2Visit()  { UpdateStatus(VISIT); }
    void Status2Delete() { UpdateStatus(DELETE); }
 
    bool IsNormal() { return ConfirmStatus(NORMAL); }
    bool IsNew()    { return ConfirmStatus(NEW); }
    bool IsVisit()  { return ConfirmStatus(VISIT); }
    bool IsDelete() { return ConfirmStatus(DELETE); }
 
    off_t Off()  { return _off; }    // 块在文件中的起始偏移
    void *Addr() { return _addr; }   // 映射后的起始地址
    off_t Size() { return _size; }   // 块真实大小
 
    void DebugPrint()
    {
        std::cout << "_off=" << _off << " _size=" << _size
                  << " _addr=" << _addr << " _status=";
        if (IsNormal()) std::cout << "NORMAL ";
        if (IsNew())    std::cout << "NEW ";
        if (IsVisit())  std::cout << "VISIT ";
        if (IsDelete()) std::cout << "DELETE ";
        std::cout << std::endl;
    }
 
private:
    off_t _off;    // 该块在文件中的起始偏移(4KB 对齐)
    off_t _size;   // 该块的大小(末尾块可能不足 4KB)
    void *_addr;   // 映射进内存后的虚拟地址起点
    unsigned _status; // 状态位
};

接着是 FileCache:

// FileCache —— 缓存中枢:调度加载、命中、晋升、淘汰
class FileCache
{
private:
    // ---- 成员:一张表(哈希)+ 一条链(LRU 双向链表)----
    std::string _file;       // 文件名
    int _fd;                 // 文件描述符
    off_t _total;            // 文件总大小
    int _cacheMaxNum;        // 容量(最多缓存几个块)
 
    std::list<std::shared_ptr<DataBlock>> _cache;        // 双向链表:头部最新、尾部最旧
    std::unordered_map<off_t, std::shared_ptr<DataBlock>> _hash; // 哈希:快速定位块
 
    // 偏移是否合法(在文件范围内)
    bool IsOffLegal(off_t off) { return off < _total; }
 
    // 该块是否已被缓存
    bool IsCached(off_t off) { return _hash.find(off) != _hash.end(); }
 
    // 缓存是否已满(放不下更多块)——用 ">" 因为可能暂时多出一个待淘汰块
    bool IsCacheFull() { return _cache.size() > (size_t)_cacheMaxNum; }
 
    // 计算该块真实大小;最后一个块可能不足一整块
    off_t GetSizeFromOff(off_t off)
    {
        off_t size = gBlockSize;
        if (off + gBlockSize > _total)   // 已到文件末尾
            size = _total - off;         // 还剩多少就映射多少
        return size;
    }
 
    // 是否已缓存,若是且命中/新插入,则按状态执行 LRU 的晋升或淘汰
    void DoLRU(off_t off)
    {
        if (!IsCached(off)) return;
 
        if (_hash[off]->IsNew())            // 情况 A:新插入的块
        {
            _hash[off]->Status2Normal();    // 转正,正式占用缓存位
            if (IsCacheFull())              // 满了就淘汰最久未用的(尾部)
            {
                auto &tail = _cache.back(); // 尾节点
                tail->DoUnmap();            // 1. 先卸载它的映射
                std::cout << "cache 淘汰 off=" << tail->Off() << std::endl;
                _hash.erase(tail->Off());   // 2. 从哈希表移除
                _cache.pop_back();          // 3. 从链表弹出尾部
            }
        }
        else if (_hash[off]->IsVisit())     // 情况 B:刚被访问命中
        {
            _hash[off]->Status2Normal();    // 状态归位
            auto it = std::find(_cache.begin(), _cache.end(), _hash[off]);
            if (it != _cache.end())         // 找到后搬到头部
                _cache.splice(_cache.begin(), _cache, it);
            std::cout << "将 off=" << off << " 移到链表头部" << std::endl;
        }
        // 情况 C:NORMAL 等无需处理的状态,什么都不做
    }
 
    // 真正加载一块:构造块、mmap 映射、登记哈希、头插链表
    void DoCache(off_t off)
    {
        off_t size = GetSizeFromOff(off);   // 该块真实大小
        std::shared_ptr<DataBlock> block =
            std::make_shared<DataBlock>(off, size);  // 构造(初始 NEW)
        block->DoMap(_fd);                  // mmap 映射进内存
        _hash.emplace(off, block);          // 登记哈希表
        _cache.push_front(block);           // 头插链表
    }
 
public:
    FileCache(const std::string &file)
        : _file(file), _fd(gDefaultFd), _total(0), _cacheMaxNum(gCapacity)
    {
        _fd = ::open(_file.c_str(), O_RDWR);          // 文件需事先存在
        if (_fd < 0) { perror("open"); return; }
        struct stat st;
        if (::fstat(_fd, &st) < 0) { perror("fstat"); return; }
        _total = st.st_size;                          // 拿到文件总大小
    }
 
    // 主入口:按字节偏移取块,命中/加载后统一走一次 LRU
    std::shared_ptr<DataBlock> GetBlock(off_t off)
    {
        if (!IsOffLegal(off)) return nullptr;          // 越界
        off = BLOCK_ADDR_ALIGN(off);                   // 对齐到块边界
        if (IsCached(off))                             // 命中
            _hash[off]->Status2Visit();                // 标记被访问
        else                                           // 未命中
            DoCache(off);                              // 加载
        DoLRU(off);                                    // 统一做 LRU 维护
        return _hash[off];
    }
 
    // 打印当前缓存内容(调试用)
    void PrintCache()
    {
        std::cout << "--------- cache 内容 ----------" << std::endl;
        for (auto &it : _cache)
        {
            it->DebugPrint();
            std::cout << "|" << std::endl;
        }
        std::cout << "nullptr" << std::endl;
        std::cout << "-------------------------------" << std::endl;
    }
 
    ~FileCache()
    {
        if (_fd != gDefaultFd)
            ::close(_fd);
    }
};

然后是测试程序 Main.cc。它先用一个循环依次请求不同偏移的块,观察"加载、装满、淘汰"的过程;再进入一个交互循环,让你输入任意字节偏移,亲眼看看"命中后某个块被晋升到头部":

// Main.cc —— 测试程序
#include <iostream>
#include "LRUCache.hpp"
 
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        std::cerr << "Usage: " << argv[0] << " filename" << std::endl;
        return 1;
    }
 
    FileCache fc(argv[1]);
 
    // 第一部分:依次请求 10 个不同的块(偏移每隔 4096),观察加载与淘汰
    int count = 0;
    while (count < 10)
    {
        fc.GetBlock(count * 4096);
        fc.PrintCache();
        count++;
    }
 
    // 第二部分:交互式输入偏移,观察命中晋升
    while (true)
    {
        off_t off;
        std::cout << "Please Enter Off# ";
        std::cin >> off;
        auto b = fc.GetBlock(off);
        if (b)
            std::cout << "block addr: " << b->Addr() << std::endl;
        fc.PrintCache();
    }
    return 0;
}

这里还要说明一点:源工程(以及很多生产代码)会给这套类套一个命名空间(namespace) 外壳,比如 namespace LRUCache { ... },然后在外面写 LRUCache::FileCache fc(...) 来使用,既避免和标准库撞名,也方便将来扩展。本文为了聚焦核心、让你能无障碍照抄后直接编译运行,有意省略了这层命名空间外壳,直接用 FileCache。如果你理解了命名空间,完全可以按生产习惯把它包回去。

编译脚本 Makefile:

# 目标文件由 Main.cc 编译而来
lrucache: Main.cc LRUCache.hpp
	g++ -o $@ $< -std=c++17 -g
 
# 清理
.PHONY: clean
clean:
	rm -f lrucache

接下来,我们造一个测试文件,并运行起来看看。造文件的经典命令是 dd:它从一个输入流按固定大小生成文件内容。我们生成一个 10 个 4KB 块、共 40960 字节、内容全 0 的文件:

# 造一个 40KB 的文件(4096 字节 × 10),内容用 /dev/zero 填 0
dd if=/dev/zero of=log.bin bs=4096 count=10
 
# 编译
make
 
# 运行(把 log.bin 当作缓存对象传给程序)
./lrucache log.bin

运行后第一部分你会看到类似这样的输出(注意我们的容量是 3):

打开 log.bin 成功, 大小 40960 字节
mmap 加载 off=0 成功
--------- cache 内容 ----------
_off=0 _size=4096 _addr=0x7f... _status=NORMAL
|
nullptr
-------------------------------
mmap 加载 off=4096 成功
--------- cache 内容 ----------
_off=4096 _size=4096 ... _status=NORMAL
|
_off=0 _size=4096 ... _status=NORMAL
|
nullptr
-------------------------------
...

访问到第 4 个块(off=12288)时,缓存已满,于是最久未用的 off=0 那个块被 munmap 卸载、并从哈希表和链表里移除,新的块 push_front 顶上。读满第一轮的输出,你能直观看到整个"加载 → 装满 → 淘汰尾部 → 加载新人"的循环。这一过程每一步都有打印,是绝佳的调试学习素材。

你也可以用 GDB 的 info proc mappings 查看进程的地址空间映射,亲眼看到这些块所占的那些 4KB 映射区确实是当前驻留在缓存里的这几个块(源课件就是这么演示的)。当某个块被淘汰(munmap)后,它的那段地址范围就会从 info proc mappings 的输出里消失——这就是"映射进内存"与"卸载出内存"最直接的可视化证据。

边界、坑与正确姿势

做缓存这件事,"跑通"是一回事,"跑对跑稳"是另一回事。这一节我们把藏在工程里的边界和坑一个个揪出来,每一个都是在生产里真实出现过的。

坑一:映射区大小要按页"心理预期"对齐

我们已经提过两处:

  1. mmap 的 offset 必须页对齐(否则返回 EINVAL),所以 GetBlock 里进门前先 BLOCK_ADDR_ALIGN。
  2. mmap 的 length 会向上取整到页。你映射 1808 字节,内核实际占用一页(4096)。这多出来的空间,如果位于文件内容范围之内,读写随意;如果填的是"文件末尾之外",那么一访问就会触发 SIGBUS(总线错误)——这不是段错误,是总线错误,内核拒绝让你碰那个"映射到了不存在的文件部分"的地址。因此我们 GetSizeFromOff 用 _total - off 精确算好"文件里实际还有多少字节",虽然内核仍会占用整页,但只要你只访问 _addr[0 .. _size-1] 这个范围,就绝不会越到文件外去。

坑二:munmap 之后再去访问那个地址

这是最危险、最隐蔽的一个坑,我们已经在 DoUnmap 里埋了"解药"(卸载后 _addr = nullptr),但你要理解它为什么重要。

munmap 执行后,那块虚拟地址区间就归还给内核、不再属于这个进程了。任何对那个地址的读写,都是碰到一块"不存在的映射",操作系统会给你一个 SIGSEGV(段错误),进程直接崩溃。微妙之处在于:我们用的是 std::shared_ptr<DataBlock>,当缓存把某个块淘汰(munmap + 从链表和哈希移除)之后,如果外部还握着一个指向同一个 DataBlock 的 shared_ptr,块对象本身并不会被销毁——因为引用计数还大于 0。于是你会得到一种尴尬的处境:DataBlock 对象还"活着",但它内部的 _addr 已经被 munmap 了,你拿着它去 ->Addr() 然后访问,照样段错误。

这其实暴露出 shared_ptr + 提前 munmap 组合的一个内在张力:共享所有权延迟了"对象销毁",却没有延迟"地址空间的卸载"。 两者节奏不一致,就可能出现"对象存活、地址已死"的中间状态。对我们这个教学工程,几条规避原则:

  • 约定"被淘汰的块不得再被 Get 使用",因为新的访问走 IsCached 会重新加载、得到新映射;
  • 外部使用方不要长期缓存再使用"已被淘汰块的 _addr",用一次拿一次;
  • 若真要"安全共享",应改用"最后一次访问结束才释放"的强引用策略,或者干脆在卸载前保证没有其他引用。

所以,"munmap 后地址无效"这件事,是使用映射内存最基本的底线,任何时候都要假定"映射地址只在其生命周期内有效"。

坑三:状态位必须互不冲突

前面已经点到:状态位 VISIT 和 DELETE 用成同一个 (1 << 2) 是源课件里的一个笔误。它平时不爆雷,是因为 DELETE 实际不在主流程里被 IsDelete 判断;可一旦将来的维护者依赖它,ConfirmStatus(DELETE) 会因为 DELETE == VISIT 而把"被访问过的块"误判成"待淘汰的块",逻辑立刻错乱。状态机的铁律是:每个语义不同的状态,必须分配一个独一无二的位。 我们用 1 << 0 / 1 << 1 / 1 << 2 / 1 << 3 四个独立的位,杜绝这种同值冲突。

坑四:末尾块的大小计算

我们在 GetSizeFromOff 里强调了 _total - off 而不是 _total % gBlockSize。前者语义是"这一块在文件里实际还剩多少",后者只是"总大小对 4KB 取余"。二者在"off 恰好在文件最后一个块起点"时数值一致,但这是偶然的巧合,换任何其他块都会得出错误长度。get 到正确的大小,mmap 的长度才正确,访问才安全。 这也是整篇工程里"正因为懂页的概念才能写对"的一个好例子。

坑五:缓存污染(cache pollution)

什么叫缓存污染(cache pollution)?当一个程序突然一口气访问大量"只用一次就不再需要"的数据(比如一次性扫描整个文件、批量导入、大个的临时数据),这些一次性数据会涌进缓存、占满容量,把原本高频访问的热点数据全部挤出去。等热点数据再被访问时,已经"不在缓存里",只能重新读盘——命中率瞬间暴跌,性能雪崩。因为 LRU 会把"刚访问过的"一律视为"热",这些一次性数据恰恰也"刚被访问过",所以会堂而皇之地被 LLU 当成 VIP 留在缓存头部,把真正的一张热点挤掉。这就是 LRU 面对突发大块扫描时的一个经典缺陷。

如何缓解缓存污染?常见思路:

  • 分段 LRU(如 2Q、ARC):把缓存分成"只住一次数据"的临时区,和"反复命中才入住"的受保护区;一次性的数据只进临时区,很快就被挤走,进不了受保护区,从而保护热点。
  • LRU-K:一个新数据被访问满 K 次(比如 K=2)才真正进入缓存,否则不缓存;这样"只用一次"的数据根本没资格占用缓存位。
  • 按访问频率(LFU,Least Frequently Used) 淘汰:统计的是"谁被用得最频繁"而不是"谁最近用过",天然抵抗一次性大扫描。
  • 策略上的穷尽办法:对已知的一次性大对象,根本不缓存,用自己的临时内存读完就扔。

这一节要记住的核心:缓存不是越大越好,缓存是用"空间换时间"的博弈,而 LRU 只是"如何取舍"的一种朴素策略,它有自身的天花板。 当命中率上不去时,别只埋怨缓存太小,可能是淘汰策略本身和你的访问模式不匹配。

扩展思路:把"淘汰后的善后"用回调解耦

最后,回到我们埋的伏笔——回调函数解耦。设想一个真实系统:某个块被淘汰之前,需要先把它"写回磁盘"(因为它是脏的、被改过)、可能还要释放它关联的其他资源。这些"善后动作"是业务逻辑,如果硬编码在 FileCache::DoLRU 里,缓存类就被业务污染了,将来换业务没地方下手,测试也困难。

解耦的做法是:缓存类只负责"决定何时淘汰、淘汰谁",至于"淘汰后具体做什么",通过一个回调函数(callback) 暴露给外界注册。下面这段小程序演示这个思路(块我们用简单的编号代替,重点是"缓存不知善后细节"):

// callback_demo.cpp —— 回调解耦演示
#include <functional>
#include <iostream>
using namespace std;
 
// 被缓存的数据块(示意:用一个编号代替 mmap 地址)
struct DataBlock {
    int id;
};
 
// 缓存类:只负责"淘汰时机",不喊"善后动作"
class FileCache {
public:
    // 注册一个回调:缓存决定淘汰某块时,调用它
    void SetEvictCallback(function<void(const DataBlock&)> cb)
    {
        _onEvict = std::move(cb);
    }
 
    // 缓存决定淘汰 block 时,通过回调通知外部去善后
    void Evict(DataBlock &block)
    {
        if (_onEvict)            // 有回调才调用
            _onEvict(block);     // "怎么善后"由注册方决定
    }
 
private:
    function<void(const DataBlock&)> _onEvict;   // 保存回调
};
 
int main(void)
{
    FileCache fc;
 
    // 外部(业务层)注册:被淘汰的块,先模拟"落盘"再提示
    fc.SetEvictCallback([](const DataBlock& b) {
        cout << "evict block #" << b.id
             << " (simulate write-back to disk)" << endl;
    });
 
    DataBlock b{ 42 };           // 一个要被淘汰的块
    fc.Evict(b);                 // 缓存内部决定淘汰,善后动作交给回调
    return 0;
}

你看,FileCache::Evict 完全不知道"写回磁盘"这回事,它只负责调用回调;真正"怎么落盘、释放什么"在 main 里注册的那个 lambda 里。这就是回调解耦:把"何时做什么"和"具体怎么做"分开了。 将来业务变了,只改注册的回调,缓存类一行不动;缓存类要好测试,也能传入一个无副作用的假回调。在我们的 DataBlock 状态机里,同样的思想体现为"块只管把诉求写成状态,DoLRU 统一按状态分发"。

为什么值得这样设计:性能收益与取舍

聊完实现,回头想想它到底值不值。用 mmap 做文件缓存,比传统 read() 读一次拷贝一次,到底强在哪?

最核心的收益,是 mmap 消除了"内核态到用户态"的一次数据拷贝。传统 read(fd, buf, len) 的路径是这样的:内核先读磁盘→放到内核的 page cache(页缓存)→再用 copy_to_user 把数据从内核缓冲区拷贝到你的用户态 buf。这里有一次实打实的数据搬运。而 mmap 的路径是:磁盘页直接驻留在内核的 page cache 里,同时这个页的物理页框直接映射到你的用户态虚拟地址(经过页表)。你读的时候根本不用搬数据——你虚拟地址指向的那块物理内存,就是 page cache 里那块文件页。这就是所谓的"零拷贝"感受。

换句话说,用 mmap:

  • 命中时零系统调用:一旦页已驻留,读它就等价于读普通内存(一次访存),不必经过任何系统调用。
  • 懒加载省物理内存:映射建立了但页没实际占用物理内存,只有访问到的页才落地(缺页中断才读盘),天然是"用多少占多少"。
  • 复用内核 page cache:文件页本来就缓存在内核 page cache 里,mmap 直接复用,不会像用户态缓存那样"你又存一份、内核又存一份"地双重缓存。
  • 随机访问极其友好:因为访问退化成了指针解引用 + 页表查找,跳着读(比如只读文件里零散偏移处的小段)非常自然,这正适合我们"按块随机取文件片段"的场景。

但 mmap 不是银弹,它有明显的代价与取舍(这一块是业界反复讨论的经典话题):

  • 缺页开销:每次冷访问(页不驻留)都要走一次缺页中断 → 上下文切换 → 磁盘读 → 填页表 → 返回。相比 pread() 的"一次系统调用直达",mmap 冷访问的链路更重。这正是大家在"storage engine 该不该用 mmap"上争论的焦点——比如 RocksDB 等数据库就明确不用 mmap 存数据,而改用 pread() + 应用层 block cache,理由包括:无法控制页缓存淘汰优先级(页事实被其他数据挤掉)、容易形成"应用层缓存 + 页缓存"的双重缓存、预读不可控、并发大映射下 TLB 压力大等。
  • TLB(Translation Lookaside Buffer,页表高速缓存)压力:TLB 是 CPU 缓存"最近用过的虚拟地址→物理地址"翻译结果的小缓存,容量有限。如果一个进程映射了海量地址碎片、到处跳着访问,TLB 会被频繁打散(miss),每次 miss 都要走一次相对昂贵的页表查询。
  • 更新/持久化的语义:MAP_SHARED 的写通常由内核异步写回磁盘,什么时机落盘不由你完全掌控;想强制落盘需要显式 msync()。所以对"写入有严格一致性要求"的场景,mmap 未必合适。

说小结:"该不该用 mmap"没有标准答案,取决于你的访问模式、命中率、是否并发、是否要写、一致性要求。 对"大文件 + 高命中率 + 随机小块访问"这类场景,mmap + 用户态 LRU 缓存是优雅且高效的组合;对"顺序流式读取一遍过、强一致写、超大规模并发映射"这类场景,传统 read() + 明确缓存、甚至 pread() + O_DIRECT 反而更可控。这一整节想传达的是:理解"为什么这样设计"和"这套设计的代价在哪",比背住"mmap 比 read 快"一句话重要得多。(关于 mmap 的深层机制与性能分析,可参考 kernel-internals 上的 "mmap as an I/O Mechanism" 一文,以及 RocksDB 关于为何不用 mmap 存储数据的技术说明,两篇都讲得非常透。)

课后思考与详解

下面几个问题,请你先自己想一想,再看答案。

思考题 1:为什么 mmap 的 offset 必须是页大小(4KB)的整数倍?

答案:因为内存和文件之间的映射,内核是以"页"为最小单位进行的——一次至少映射/读入一整页,而且页有对齐约束。文件侧的起点如果不在一个页的开头,就无法把一个"完整页"和文件里的一段对应起来(页内的内容不完整),内核无法给出合理的映射语义,于是直接返回 EINVAL。我们代码里 BLOCK_ADDR_ALIGN 把任意偏移向下对齐到 4KB,正是为了满足这个约束。这也顺带解释了为什么"块大小"常与页大小保持一致:都对齐成 4KB,一个块恰好覆盖一页,映射、缺页、淘汰都最干净。

思考题 2:munmap 之后再去访问原来的地址会怎样?为什么我们要在 DoUnmap 里把 _addr 置空?

答案:munmap 把那段地址区间归还给内核,此后访问它属于访问"无效映射",会触发 SIGSEGV(段错误),进程崩溃;在极端情况下(地址被内核重新分配给别的用途)可能出现数据错乱等未定义行为。DoUnmap 里把 _addr = nullptr 是一个防御性习惯:虽然它并不能阻止"你把之前保存的地址值留在别处再去用",但至少让"这个对象自己的记录"失效,避免类内部后续通过 _addr 重新误用。更完整的防御要求"拿一次用一次"、不在淘汰后长期持有并使用旧地址。这也是"shared_ptr 对象存活、但 mmap 地址已卸载"这种中间态要特别注意的原因——对象活着不代表映射还在。

思考题 3:LRU 为什么用"哈希表 + 双向链表"两个数据结构?时间复杂度为何是 O(1)?

答案:分开看,单一结构都有短板。纯双向链表:插入、把某节点挪到头部都 O(1),但"查找一个数据在不在、具体在哪个节点"需要从头遍历,是 O(n)。纯哈希表:查找 O(1),但它不保序,无法回答"谁最久没被访问"。二者结合后,哈希表负责"查得准——数据在不在、节点在哪"(O(1)),双向链表负责"动得快——头插、删除、把任意节点摘到头部"(因为知道前驱/后继,O(1))。于是 Get(查找 + 可能晋级)和 Put(查找 + 头插 + 可能淘汰尾部)都只需常数步操作,整体时间复杂度 O(1);空间上哈希表和链表各存一份节点,共 O(n)。

思考题 4:源课件里 VISIT 与 DELETE 都写成了 (1 << 2),这会导致什么后果?你如何避免这类错误?

答案:状态位的值相同,意味着用位运算"确认状态"时两者无法区分——ConfirmStatus(DELETE) 与 ConfirmStatus(VISIT) 行为完全一样。假如模块后续依赖 IsDelete() 来判断"待淘汰的块",那么"刚被访问(VISIT)"的块也会被误判为"待淘汰",触发错误的淘汰逻辑。这次它在主流程里没被用到所以没爆雷,属于"藏着的地雷"。避免方法:给每个状态分配独一无二且不相交的位(1<<0、1<<1、1<<2、1<<3),尽量用 enum 命名而非魔数,并写单元测试覆盖"置状态→确认状态"的回环,确保每个状态都能被正确设置和识别。

思考题 5:什么是缓存污染?它为什么会让 LRU 失效?怎么缓解?

答案:缓存污染指"一次性、低频、大批量"的数据涌入缓存、把真正高频的热点数据挤走的现场,导致命中率骤降。它对 LRU 特别不友好,是因为 LRU 把所有"最近被访问过"的数据都一视同仁地视为"热点":一次性大扫描的每一块都在"刚被访问",所以会被允许进入/留在缓存头部,不断把真正的热点挤到尾部再逐出。等热点再被用到时已经不在缓存,只能重新读盘。缓解手段:分段 LRU(把一次性数据和反复命中数据分区管理,如 2Q/ARC)、LRU-K(数据被访问满 K 次才进缓存,挡掉一次性数据)、LFU(按访问频率而非"最近"淘汰)、以及业务层对已知的一次性大对象直接不缓存。核心认知:缓存是空间换时间的博弈,淘汰策略必须与访问模式匹配,命中率才是检验缓存成败的唯一硬指标。

思考题 6:为什么末尾块的大小应该用 _total - off,而不是 _total % gBlockSize?

答案:结构上,_total - off 表示的是"这一块从 off 起、在文件里实际还剩多少字节",它精确表达了"这块真实有多长",把这个长度交给 mmap 才正确。而 _total % gBlockSize 只是"文件总大小对块大小取余"的常量,它压根不关心 off,只在 off 恰好等于文件最后一个块的起点时才和正确值碰巧相等,换个块就会得出错误(甚至为 0)的长度。mmap 的长度错误,轻则映射范围不对,重则返回失败或映射到错误范围。所以记住:"剩余字节"与"总长取余"是两个语义完全不同的量,别混用。


到这里,我们用 mmap 完整实现了一个"文件块 + LRU 缓存"的小工程,并且把一路上出现的每一个概念——页、缺页中断、mmap、munmap、LRU、双向链表、哈希表、缓存命中率、缓存污染——都掰开揉碎地讲透了。回头看这一路,你会发现它其实是把几门底层知识打通了:懂"页"才懂 mmap 为什么要对齐、为什么懒加载;懂 LRU 才懂缓存怎么淘汰;而最后你亲手写出、亲眼看它跑起来的那 80 行代码,就是这三者碰撞出的产物。

希望你再回头把 GetBlock 和 DoLRU 那两段反复读几遍,配合打印整个模拟流程跑一跑,你会有一种"噢,原来读一个文件、缓存一段数据,背后是这么精巧的一套协作"的畅快感。这篇加餐就到这里,祝你编码愉快。