当前位置:首页>Linux>Linux 如何用一棵树管住百万页缓存:内核 Page Cache 的面纱

Linux 如何用一棵树管住百万页缓存:内核 Page Cache 的面纱

  • 2026-10-11 05:56:01
Linux 如何用一棵树管住百万页缓存:内核 Page Cache 的面纱

Linux 如何用一棵树管住百万页缓存:内核的 Page Cache 面纱

1. 从一个数字说起

一台 8GB 内存的 Linux 机器,通常有 3~5GB 被 Page Cache 占据——换算成 4KB 页面就是 75万~130万页。内核必须随时回答一个问题:“文件 X 的第 N 页在不在内存里?” 回答这个问题的速度直接决定了一次 read() 是 100 纳秒还是 100 微秒——差 1000 倍。

答案藏在一棵树里。


2. 全局视角:三个核心数据结构

先看从文件到缓存页的完整链路:


  
    
    
    
  
  c
struct inode {                    // 代表磁盘上的一个文件
    struct address_space *i_mapping;  // 指向该文件的缓存管理器
    ...
};
struct address_space {            // 文件的"缓存管家"
    struct inode    *host;            // 反指所属文件
    struct xarray   i_pages;          // 核心!页面索引树
    unsigned long   nrpages;          // 当前缓存页总数
    const struct address_space_operations *a_ops; // 文件系统回调
};
struct folio {                    // 一个物理 4KB 页的描述符
    struct address_space *mapping;    // 我属于哪个文件
    pgoff_t              index;       // 我是文件的第几页
    unsigned long        flags;       // 状态标志 (dirty/uptodate等)
};

关系一句话概括:inode → address_space → XArray(树)→ folio。多个进程打开同一文件,共享同一个 address_space 和同一份缓存——这就是"第二次打开秒开"的原因。


3. XArray 基数树:一棵什么样的树

address_space.i_pages 是一个 XArray——一棵分层的基数树。每个内部节点(xa_node)有 64 个 slot(由 XA_CHUNK_SHIFT=6 决定),每层消耗 key 的 6 个 bit。

XArray 树查找路径示意图

图示说明:查找 index=131 时,将 131 拆为 64 进制 [0][2][3],逐层走 slots[0] → slots[2] → slots[3],直达 folio。3 次数组取值,零比较零遍历。

3.1 为什么可以分层查找?

核心就一行代码(lib/xarray.c):


  
    
    
    
  
  c
offset = (index >> node->shift) & 63;   // 取 index 某 6 bit 作为数组下标
entry  = node->slots[offset];           // 直接跳到子节点

原理:每个节点 64 = 2^6 个 slot,每层恰好"消耗" index 的 6 个 bit。等价于把 index 看作 64 进制数,每一位就是一层的 slot 下标:


  
    
    
    
  
  bash
131 (十进制) = 0×64² + 2×64¹ + 3×64⁰

64 进制: [0] [2] [3]
        ↓   ↓   ↓
        层1  层2  层3 的 slot 下标

和十进制查表完全类似——如果你有一个 1000 格的柜子,找第 457 格,你可以拆成"第 4 排、第 5 列、第 7 格"逐层定位,而不需要从第 1 格数到第 457 格。XArray 就是把这个思路换成了 64 进制。

对比红黑树/哈希表:红黑树每层要比大小决定走左/走右;哈希表要处理冲突链。XArray 用位运算直接算出路径——3 次右移 + 3 次取数组元素,CPU 几乎不用"思考"。

3.2 树的容量

key(pgoff_t)是 unsigned long,64-bit 系统为 64 位。每层消耗 6 bit,最大深度 = ⌈64 / 6⌉ = 11 层。

文件大小 最大 index 树深度 说明
256 KB 63 1 层 单个 xa_node 即可
16 MB 4,095 2 层 大部分配置文件
1 GB 262,143 3 层 常见日志/数据文件
1 TB ~2.7亿 5 层 大型数据库
理论极限 2^64-1 11 层 永远够用

树按需生长——空 slot 不分配 xa_node,稀疏文件几乎零开销。


4. 如何判断一个页是否已缓存

就看 xas_load() 的返回值:非 NULL = 已缓存,NULL = 未缓存。


  
    
    
    
  
  c
// mm/filemap.c
void *filemap_get_entry(struct address_space *mapping, pgoff_t index)
{
    XA_STATE(xas, &mapping->i_pages, index);
    struct folio *folio;

    rcu_read_lock();              // RCU 读锁:零竞争
    folio = xas_load(&xas);      // 沿树逐层走到叶子
    if (folio && !xa_is_value(folio))
        folio_try_get(folio);    // 防止并发回收
    rcu_read_unlock();
    return folio;                // NULL = 未缓存,非 NULL = 已缓存
}

没有标志位、没有布尔变量——树的 slot 里有 folio 指针就是缓存了,是 NULL 就是没缓存。整个判断只需沿树走 2~3 层,不遍历任何链表,不比较任何 key。

性能数字:Page Cache 命中约 100 纳秒,SSD 随机读需 50,000~100,000 纳秒——命中与未命中差 500~1000 倍。


5. 插入:缓存一个新页

当 read() 未命中时,内核从磁盘读取数据后把新 folio 挂进树里:


  
    
    
    
  
  c
// mm/filemap.c
int __filemap_add_folio(struct address_space *mapping,
                        struct folio *folio, pgoff_t index, ...)
{
    XA_STATE(xas, &mapping->i_pages, index);

    folio->mapping = mapping;    // 反向指针:folio 属于哪个文件
    folio->index = index;        // 反向记录:folio 是第几页

    xas_lock_irq(&xas);         // 写操作需要加锁
    xas_store(&xas, folio);     // 沿路径插入(必要时分配新 xa_node)
    mapping->nrpages++;
    xas_unlock_irq(&xas);
}

如果路径上缺少中间 xa_node,xas_store 会自动分配——树的"按需生长"就发生在这里。


6. 双向映射:正查 + 反查


  
    
    
    
  
  c
struct folio {
    struct address_space *mapping;    // → 所属文件的 address_space
    pgoff_t              index;       // → 文件中第几页
};
  • 正向:read() 时用——mapping->i_pages[index] → folio*
  • 反向:回收时用——folio->mapping → 哪个文件;folio->index → 哪个位置

每个缓存页只多花 16 字节(一个指针 + 一个 ulong)就实现了双向寻址。


7. 标记系统:一棵树如何追踪脏页

xa_node 内置 tag 位图:


  
    
    
    
  
  c
struct xa_node {
    void __rcu *slots[64];           // 64 个子指针
    unsigned long tags[3][1];        // 3 组 tag,每组 64 bit 位图
};
Tag 含义 使用场景
PAGECACHE_TAG_DIRTY 页已修改 writeback 线程找脏页
PAGECACHE_TAG_WRITEBACK 正在写回 防止重复写回
PAGECACHE_TAG_TOWRITE 待写入 批量写回调度

标记为 DIRTY 时,从叶子到根的每一层都设置对应 bit——遍历脏页时可以跳过所有没有脏子节点的子树。百万页中找 100 个脏页不需要遍历百万次。


8. 实战:一次 read() 的完整路径


  
    
    
    
  
  bash
read(fd, buf, 4096)   // 读文件偏移 8192 处的 4KB
│
├─ index = 8192 / 4096 = 2
│
├─ filemap_get_read_batch(mapping, index=2)
│   └─ xas_load() 在 XArray 中查 slot → folio*
│
├─ 命中 (folio != NULL && uptodate)
│   └─ copy_to_user(buf, page_address(folio), 4096)
│   └─ return 4096                     ← ~100ns 完成
│
└─ 未命中 (folio == NULL)
    ├─ page_cache_sync_ra()            ← 触发预读(多读几十页)
    │   ├─ alloc_pages()               ← 分配物理页
    │   ├─ __filemap_add_folio()       ← 插入 XArray
    │   └─ a_ops->readahead()          ← 文件系统从磁盘读取
    ├─ 等待 IO 完成 → set_folio_uptodate()
    └─ copy_to_user() → return 4096   ← ~50-100μs(含磁盘)

9. 内存回收:树不会无限膨胀


  
    
    
    
  
  bash
首次访问 folio
    → 放入 inactive list(观察区)
        → 再次被访问 → 晋升 active list(保护区)
        → 长期不访问 → 留在 inactive 尾部
            → 内存紧张时从尾部开始回收:
                clean page → 直接丢弃(磁盘有原始数据)
                dirty page → 先 writeback,再丢弃

回收 folio 时,xas_store(&xas, NULL) 把树的对应 slot 置空——之后查找该 index 就回到"未命中"路径。


10. 指针低位复用

folio 的 mapping 字段除了指向 address_space,还要标记页面类型。利用地址对齐:


  
    
    
    
  
  c
struct address_space { ... } __attribute__((aligned(8)));
// 8 字节对齐 → 地址低 3 bit 永远是 000

#define PAGE_MAPPING_ANON  0x1   // bit0=1 → 匿名页(堆/栈)

if (folio->mapping & 0x1)  → 匿名页
else                       → 文件页(Page Cache)

address_space *as = (void *)(folio->mapping & ~0x3);  // 还原指针

百万个 folio,每个省 8 字节 = 省 8MB 内存。


11. 文件系统在哪里?

整棵 XArray 树、LRU 回收、脏页追踪——全部是通用层(mm/filemap.c)做的。文件系统只需实现一组回调:


  
    
    
    
  
  c
struct address_space_operations {
    int  (*read_folio)(struct file *, struct folio *);
    void (*readahead)(struct readahead_control *);
    int  (*writepages)(struct address_space *, ...);
    int  (*write_begin)(struct file *, ...);
    int  (*write_end)(struct file *, ...);
};

换文件系统不影响缓存性能——无论 ext4 还是 f2fs,Page Cache 的树查找速度完全一致。


12. 一图总结

Page Cache 总结图

图示说明:一个 1GB 文件的完整缓存架构。struct inode → address_space → XArray(3层,~4000个xa_node)。四大能力:正向查找(100ns,RCU无锁)、Tag位图标记脏页(跳过无脏子树)、反向查找(folio→mapping+index,16B/page)、LRU回收(slot=NULL,按需释放)。


Linux 用一棵按需生长、逐层 64 分叉的基数树,在百万页面中实现了纳秒级定位、零遍历的脏页追踪、以及优雅的按需回收。这就是文件第二次读取比第一次快 1000 倍的根本原因——数据已经在树里了,不再需要访问磁盘。

最新文章

随机文章