内存管理-77-kernel-internals系列-Memory专题-Caching & Reclaim-1-Page Cache


一、页缓存 (Page Cache)翻译

注: 翻译自 https://kernel-internals.org/mm/page-cache/

在内存中缓存文件数据。


1. 什么是页缓存?

页缓存将最近访问过的文件数据保存在 RAM 中。当您读取文件时,数据会留在内存中——后续的读取将直接来自 RAM,而不是磁盘。

首次读取:应用程序 ──► 内核 ──► 存储设备(速度慢:机械硬盘 ~10ms,NVMe 固态硬盘 ~100μs)
                           │
                           ▼
                       页缓存(保存副本)

再次读取:应用程序 ──► 内核 ──► 页缓存(速度快,~100ns,不触及存储设备)


2. 为什么需要页缓存?

2.1 速度差距:

----------------------------------------------------------------------
存储介质		   延迟 (Latency)	带宽 (Bandwidth)
----------------------------------------------------------------------
RAM				~100ns			~100 GB/s
NVMe SSD		~100µs			~7 GB/s
SATA SSD		~100µs			~600 MB/s
HDD(机械硬盘)	~10ms			~200 MB/s
----------------------------------------------------------------------

(以上为数量级估计;详情可参考“每个程序员都应该知道的延迟数字”。) 

RAM 的速度比存储设备快 1000 到 100,000 倍。缓存利用了时间局部性(temporal locality)——最近被访问过的数据在不久 retail 极有可能再次被访问。

2.2 核心优势

• 提升读取性能:热数据直接从 RAM 中提供。
• 写入缓冲:写入操作可以批量处理,无需同步写入磁盘。
• 数据共享:多个进程可以共享缓存中的同一个页面。
• 提高内存效率:未使用的 RAM 会自动转化为缓存。


3. 工作原理

3.1 读取文件

read(fd, buf, 4096)
  │
  ▼
在缓存中查找页面? ─── 是 ──► 复制到用户缓冲区 (buf)
  │
  └─ 否 ─► 分配页面 ─► 从磁盘读入页面 ─► 添加到页缓存 ─► 复制到用户缓冲区


3.2 写入文件

write(fd, buf, 4096)
  │
  ▼
在缓存中查找/分配页面
  │
  ▼
将数据从用户缓冲区复制到页面中
  │
  ▼
将该页面标记为 脏页 (Dirty)
  │
  ▼
返回应用程序(写入“完成”)
  │
  ▼(后续,异步执行)
回写线程 (Writeback thread) 将数据刷新到磁盘

写入操作会立即返回——数据此时已进入缓存并被标记为脏页。实际的磁盘 I/O 由后台的回写机制处理。


3.3 address_space 结构体

每个文件(inode)都有一个 address_space 结构体来管理其缓存页:

struct address_space {
    struct inode *host;           /* Owning inode */
    struct xarray i_pages;        /* Cached pages */
    unsigned long nrpages;        /* Number of cached pages */
    const struct address_space_operations *a_ops;  /* Operations */
    /* ... */
};

页面在 XArray 中按文件偏移量进行索引,从而实现快速查找。


4. 脏页与回写

4.1 脏页生命周期:

干净页 (Clean page) ──► write() ──► 脏页 (Dirty page) ──► 回写 (writeback) ──► 干净页 (Clean page)
                                      │
                                      ▼
                                在脏页列表中追踪


4.2 回写触发条件

-----------------------------------------------------------------------------------------
触发器 (Trigger)                   触发时机 (When)
-----------------------------------------------------------------------------------------
定期触发 (Periodic)                每隔 dirty_writeback_centisecs 时间(默认 5 秒)
内存压力 (Memory pressure)         当页面回收(Reclaim)需要释放内存空间时
显式刷新 (sync/fsync)              应用程序发出显式的刷新请求时
脏页阈值 (Dirty threshold)         全系统范围内的脏页数量过多时
脏页生存期 (Dirty age)              页面保持脏状态的时间超过了 dirty_expire_centisecs 时
-----------------------------------------------------------------------------------------


4.3 脏页限制参数

这些参数的默认值定义在内核源码的 mm/page-writeback.c 中;相关的可调优项在虚拟内存系统控制文档("vm sysctl docs")中进行了详细记载:

# Percentage of memory that can be dirty. 20 (default) - start blocking writers at 20%
cat /proc/sys/vm/dirty_ratio

# Background writeback threshold. 10 (default) - start background writeback at 10%
cat /proc/sys/vm/dirty_background_ratio

# Time-based settings (centiseconds). 3000 (30s) - pages older than this get written
cat /proc/sys/vm/dirty_expire_centisecs

# 500 (5s) - writeback thread wakes this often, 6.1内核实测是 300
cat /proc/sys/vm/dirty_writeback_centisecs


4.4 回写线程

每个块设备都拥有专属的后台回写工作线程,可通过 ps -AT | grep writeback 进行查看。下面是 X86 的:

# Per-device writeback threads. kworker/u8:0+flush-8:0   (for device 8:0)
ps aux | grep writeback


5. 读取策略 

5.1 预读 (Readahead)

内核会预测文件的顺序访问行为并提前进行读取:

应用程序读取第 0 页
       │
       ▼
内核检测到顺序访问模式
       │
       ▼
提前将第 1, 2, 3, 4... 页加载到缓存中
       │
       ▼
当应用程序读取第 1 页时,该页已经被缓存了

相关文件:

# 默认预读大小(单位:KB), 128 (默认值)
cat /sys/block/sda/queue/read_ahead_kb

# 根据业务负载进行调整
echo 256 > /sys/block/sda/queue/read_ahead_kb


5.2 mmap() vs read()

-----------------------------------------------------------------------------
方法       机制                             最适用场景
-----------------------------------------------------------------------------
read()    将数据从缓存复制到用户缓冲区         顺序访问、简单的 I/O
mmap()    将缓存页直接映射到进程的地址空间      随机访问、大文件
-----------------------------------------------------------------------------

通过 mmap(),页缓存的页面可以直接映射到进程的地址空间中:

/* ptr directly references page cache pages - true zero-copy */
void *ptr = mmap(NULL, size, PROT_READ, MAP_SHARED, fd, 0);

/* With MAP_PRIVATE, writes trigger COW (copy-on-write). Reads are zero-copy, but writes get a private copy  */
void *ptr = mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_PRIVATE, fd, 0);


6. 内存压力 (Memory Pressure)

页缓存作为“空闲”内存 (Page Cache as "Free" Memory)

Linux 会将“空闲”的物理内存(RAM)用于页缓存。当应用程序需要内存时:

(1) 干净的缓存页(Clean cache pages)会被立即释放(丢弃)。
(2) 脏的缓存页(Dirty cache pages)会被先写回到磁盘中,然后再释放。
(3) 腾出的内存将分配给应用程序。

# 查看内存细节划分
cat /proc/meminfo | grep -E "MemFree|Cached|Buffers|Available"
# MemFree: 1234567 kB (真正未被使用的物理内存)
# Cached: 8765432 kB (页缓存大小)
# MemAvailable: 9876543 kB (可用内存:包含空闲内存 + 可回收的缓存)


7. 清理缓存 (Drop Caches - 仅用于测试)

可以通过向 /proc/sys/vm/drop_caches 写入不同数值来手动清理缓存(通常用于性能测试)。

# Drop clean caches (for benchmarking)
sync  # Write dirty pages first
echo 3 > /proc/sys/vm/drop_caches
# 1 = page cache
# 2 = dentries/inodes
# 3 = both

这两个命令的作用是不一样的。


8. 监控(Monitoring)

全系统缓存统计信息

# Disk I/O counters (not direct cache hit/miss - includes readahead, direct I/O)
# pgpgin   - Pages read from disk (KB)
# pgpgout  - Pages written to disk (KB)
cat /proc/vmstat | grep -E "pgpgin|pgpgout"


# Page cache size and state
# Cached:      Page cache size (file-backed pages in memory)
# Buffers:     Block device metadata cache
# Dirty:       Pages modified, waiting to be written
# Writeback:   Pages currently being written to disk
cat /proc/meminfo | grep -E "Cached|Buffers|Dirty|Writeback"

注意:Linux 不提供直接的“缓存命中率”计数器。`pgpgin` 数值高而 Cached(缓存)增长缓慢,表明存在缓存压力。


8.1 每文件的缓存状态

# Check if file is cached (using vmtouch or fincore)
# Files: 1
# Pages: 1000 (cached: 800)
vmtouch -v /path/to/file

# Or with fincore (coreutils)
fincore /path/to/file


8.2 Tracing

# Trace page cache events
echo 1 > /sys/kernel/debug/tracing/events/filemap/mm_filemap_add_to_page_cache/enable
echo 1 > /sys/kernel/debug/tracing/events/writeback/writeback_dirty_page/enable
cat /sys/kernel/debug/tracing/trace_pipe


9. 演进历史 (Evolution)

起源 (1990s): 页缓存自 Linux 早期版本就已存在。最初,它与用于块设备的“缓冲区缓存(buffer cache)”是相互独立的。

统一页缓存 (v2.4, 2001): 缓冲区缓存被合并到了页缓存中。自此,所有的文件 I/O 都统一通过同一个缓存进行处理。

基数树 (v2.6): 页面被组织在基数树(radix tree)中,以便通过文件偏移量进行快速查找。

XArray (v4.20, 2018): 提交记录:a28334862993 ("page cache: Finish XArray conversion") | LKML
作者:Matthew Wilcox
基数树被替换为 XArray —— 它的 API 更加简洁,同时保持了相同的性能表现。

Folios (v5.16, 2022)
提交记录:7b230db3b8d3 ("mm: Introduce struct folio") | LKML
作者:Matthew Wilcox
Folio 将一个或多个物理上连续的页面表示为一个单一单元。这种抽象减少了页缓存中大页(huge pages)的管理开销,并消除了过去关于“某个函数到底是作用于头页(head page)还是任意普通页面”的混乱定义。


10. 直接 I/O (Bypassing Cache / 直接 I/O)

某些应用程序会完全绕过页缓存:

/* 读取和写入将直接绕过页缓存. 要求用户缓冲区和 I/O 大小必须对齐 */
int fd = open("file", O_RDWR | O_DIRECT); 

适用场景:
• 拥有自身独立缓存系统的数据库。
• 避免双重缓存(Double-caching)。
• 需要可预测的延迟表现。

权衡与代价:
• 无法享受预读(Readahead)带来的优势。
• 没有写入缓冲(Write buffering)。
• 应用程序必须自己负责和实现缓存管理。


11. 常见问题 (Common Issues)

11.1 缓存抖动 (Cache Thrashing)

工作集(Working set)的大小超过了系统当前可用的 RAM。
• 症状:高 pgpgin / pgpgout,I/O 速度变得极慢。
• 解决方案:增加物理内存(RAM)。减小应用程序的工作集。对特定的业务负载采用 O_DIRECT。

11.2 脏页积压 (Dirty Page Buildup)

过多的脏页导致写入操作发生停顿(Stalls)。
• 症状:进程在 balance_dirty_pages 中被阻塞。
• 解决方案:调低 dirty_ratio 阈值。使用速度更快的高性能存储设备。限制或降低写入速率。

11.3 预读不匹配 (Readahead Mismatch)

顺序预读机制反而损害了随机访问型负载的性能。
• 解决方案:使用 posix_fadvise(POSIX_FADV_RANDOM) 显式告知内核。减少 read_ahead_kb 的大小。针对 mmap 使用 madvise(MADV_RANDOM)。


12. 参考(References)

...

 

posted on 2026-10-08 13:49  Hello-World3  阅读(2)  评论(0)    收藏  举报

导航