Linux内核学习轨迹第五部:页缓存Page Cache与回写机制(第九小节)
9. 页缓存Page Cache与回写机制
9.1 页缓存的核心设计目标
- 减少磁盘IO,提升IO性能:用内存缓存磁盘文件的内容,重复访问文件时,直接从内存读取,不需要访问磁盘,IO延迟从毫秒级降到纳秒级;
- 统一缓存管理:为文件的读写、内存映射(mmap)、可执行文件加载提供统一的缓存,避免重复缓存,最大化内存利用率;
- 优化磁盘IO模式:把大量的小随机写,合并为大的顺序写,减少磁盘的寻道次数,提升磁盘的写入性能;
- 预读优化:基于文件的访问模式,提前读取后续的文件内容到页缓存,减少主缺页异常的次数,提升顺序读的性能;
- 内存复用:系统空闲内存越多,页缓存占用的内存越多,当应用程序需要内存时,页缓存可以被快速回收,分配给应用程序,实现内存的动态复用。
9.2 页缓存的核心数据结构
9.2.1 核心锚点:struct address_space
struct address_space { // 宿主inode,对应磁盘上的文件 struct inode *host; // 页缓存的核心存储:XArray,存储该文件的所有缓存页 struct xarray i_pages; // 映射了该文件的所有VMA的区间树,用于反向映射 struct maple_tree i_mmap; // 保护i_mmap的读写信号量 rwlock_t i_mmap_rwsem; // 该文件的总缓存页数 unsigned long nrpages; // 脏页回写相关的操作函数集 const struct address_space_operations *a_ops; // 脏页、回写相关的标志位 gfp_t gfp_mask; unsigned int flags; // 错误记录 errseq_t wb_err; };- i_pages:XArray(扩展数组),Linux 4.20+替换了之前的radix树(基数树),是页缓存的核心存储结构。XArray是一个高效的、可扩展的稀疏数组,key是文件内的页偏移量(page->index),value是对应的struct page结构体指针。通过文件偏移量,可以在O(logn)时间复杂度内快速找到对应的缓存页;
- a_ops:地址空间操作函数集,定义了该文件系统的页缓存操作函数,包括读页、写页、脏页回写、释放页等,不同的文件系统(ext4、xfs、btrfs)有不同的实现;
- host:指向文件的inode结构体,inode是磁盘文件在内存中的表示,存储了文件的元数据(权限、大小、创建时间、块指针等),address_space和inode是一一对应的关系。
9.2.2 缓存页的状态标志
标志位 | 核心含义 | 页缓存中的作用 |
PG_locked | 页被锁定 | 正在进行IO操作(读/写磁盘)时设置,防止并发访问导致的数据竞争,IO完成后清除 |
PG_uptodate | 页的数据是最新的 | 从磁盘成功读取数据到页后设置,标记页的数据和磁盘一致,可以直接访问 |
PG_dirty | 页是脏页 | 页的内容被修改,和磁盘不一致,需要回写到磁盘 |
PG_writeback | 页正在回写 | 页正在被回写到磁盘,回写完成后清除该标志,同时清除PG_dirty |
PG_referenced | 页最近被访问过 | 用于LRU链表的活跃/不活跃判断,标记页的冷热程度,决定回收优先级 |
PG_active | 页在活跃LRU链表中 | 标记页是活跃的,最近被频繁访问,回收优先级低 |
PG_private | 页有私有数据 | 用于文件系统的缓冲区,比如ext4的buffer_head |
9.3 页缓存的读流程与预读机制
9.3.1 缓冲IO的读全流程
- 读操作的核心优化:重复读取同一个文件时,数据已经在页缓存中,不需要访问磁盘,直接从内存复制,性能提升上千倍;
- 页缓存的生命周期:只要系统有空闲内存,页缓存就会一直保留,直到内存不足时,内核才会回收不活跃的页缓存;
- 预读机制是顺序读性能的核心,提前把后续的页加载到缓存中,避免每次读取都触发磁盘IO,减少主缺页异常的次数。
9.3.2 文件预读机制
1.顺序访问检测:内核会跟踪进程的文件访问记录,如果发现进程连续访问文件的后续页,判断为顺序读,触发预读;
2.预读窗口动态调整:预读窗口的大小会根据访问模式动态调整,初始预读窗口是4页,顺序访问持续的话,预读窗口会逐步增大,最大到128页(512KB);
3.同步预读与异步预读:
- 同步预读:当进程访问的页不在缓存中时,同步读取当前页,同时预读后续的页,阻塞等待IO完成;
- 异步预读:当进程访问的页已经在缓存中,但是已经到达预读窗口的边缘时,后台异步触发预读,读取后续的页,不会阻塞进程的读操作;
4.随机访问处理:如果内核检测到进程是随机访问文件,会关闭预读,避免不必要的磁盘IO,浪费内存和带宽。
常用advice参数:
- POSIX_FADV_NORMAL:默认预读行为,适合普通的顺序访问;
- POSIX_FADV_SEQUENTIAL:告诉内核进程会顺序访问文件,开启更大的预读窗口,预读大小翻倍;
- POSIX_FADV_RANDOM:告诉内核进程会随机访问文件,关闭预读;
- POSIX_FADV_WILLNEED:告诉内核进程会很快访问这段文件内容,提前把内容加载到页缓存中,异步预读;
- POSIX_FADV_DONTNEED:告诉内核进程不会再访问这段内容,释放对应的页缓存,回收内存。
9.4 页缓存的写流程与脏页回写机制
9.4.1 缓冲IO的写全流程
- 缓冲IO的write()调用,只是把数据复制到页缓存就返回,不会等待磁盘IO完成,延迟极低,性能极高;
- 数据写入页缓存后,在回写到磁盘之前,如果系统崩溃、断电,数据会丢失,这是缓冲IO的核心风险;
- 脏页回写是异步的,由内核的回写线程统一管理,合并多个小的写操作,批量写入磁盘,减少磁盘寻道次数,提升写入性能。
9.4.2 脏页回写机制
- 定时回写:回写线程会周期性唤醒,检查系统中的脏页,把超过过期时间的脏页回写到磁盘,默认过期时间是30秒;
- 内存阈值触发回写:当系统中的脏页数量超过了设定的阈值,内核会唤醒回写线程,开始回写脏页,避免脏页占用过多的内存;
- 主动同步触发回写:用户态调用fsync()/fdatasync()/sync()系统调用,主动把脏页回写到磁盘,等待IO完成后返回,保证数据持久化。
9.4.3 脏页回写的核心调优参数
参数名 | 默认值 | 核心含义 | 调优场景 |
dirty_background_ratio | 10 | 脏页占总内存的百分比达到该值时,唤醒回写线程异步回写脏页 | 写入量大的场景,调大该值,减少回写次数,提升写入性能;稳定性要求高的场景,调小该值,减少数据丢失风险 |
dirty_ratio | 20 | 脏页占总内存的百分比达到该值时,所有的写操作会被阻塞,同步等待脏页回写 | 写入量大的场景,调大该值,避免写操作被阻塞;稳定性要求高的场景,调小该值,限制脏页的最大数量 |
dirty_expire_centisecs | 3000 | 脏页的过期时间,单位百分之一秒,默认30秒,脏页超过这个时间会被回写 | 数据安全性要求高的场景,调小该值,比如300(3秒),减少断电时的数据丢失;写入性能要求高的场景,调大该值,合并更多的写操作 |
dirty_writeback_centisecs | 500 | 回写线程的唤醒周期,单位百分之一秒,默认500ms | 写入频繁的场景,调小该值,让回写线程更频繁的唤醒,避免脏页堆积;写入少的场景,调大该值,减少CPU开销 |
dirtytime_expire_seconds | 43200 | 脏inode的过期时间,默认12小时,超过这个时间,inode的元数据会被回写 | 元数据修改频繁的场景,调小该值,保证元数据的持久化 |
9.5 工程实践与避坑指南
1.free命令中内存字段的认知纠正
free命令的输出解析:
total used free shared buff/cache available Mem: 32742344 8123456 2098765 123456 22520123 23876543 Swap: 16777212 0 16777212total:总物理内存;
used:已经被进程、内核占用的内存,不包括buff/cache;
free:完全空闲的物理内存;
buff/cache:页缓存、缓冲区、slab占用的内存,这部分内存可以被快速回收,当应用程序需要内存时,内核会立即回收这部分内存,分配给应用程序;
available:系统真正可用的内存,等于free + 可回收的buff/cache,是判断系统内存是否充足的核心指标。
认知纠正:Linux会尽可能利用空闲内存做页缓存,提升IO性能,所以buff/cache占用高是正常的,不是内存泄漏,只有当available列很低时,才说明系统内存不足。
2.页缓存的性能优化最佳实践
- 顺序读优化:大文件顺序读取时,用posix_fadvise(fd, 0, 0, POSIX_FADV_SEQUENTIAL)告诉内核顺序访问,开启更大的预读窗口,提升读取性能;
- 随机读优化:小文件随机读取时,用posix_fadvise(fd, offset, len, POSIX_FADV_WILLNEED)提前把需要的内容加载到页缓存中,避免读取时阻塞;对于完全随机的访问,用POSIX_FADV_RANDOM关闭预读,避免不必要的磁盘IO;
- 写入性能优化:写入量大的场景,适当调大dirty_background_ratio和dirty_expire_centisecs,让内核合并更多的写操作,减少磁盘IO次数,提升写入性能;但要注意,调大这些值会增加断电时的数据丢失风险;
- 数据安全优化:数据库等对数据持久化要求高的场景,每次事务提交必须调用fsync()/fdatasync(),把脏页和元数据强制回写到磁盘,保证数据不丢失;同时调小dirty_expire_centisecs,减少脏页在内存中的停留时间;
- 缓存释放:需要释放页缓存,回收内存时,可以用以下命令:
# 释放页缓存 echo 1 > /proc/sys/vm/drop_caches # 释放目录项和inode缓存 echo 2 > /proc/sys/vm/drop_caches # 释放页缓存、目录项、inode缓存 echo 3 > /proc/sys/vm/drop_caches注意:drop_caches只能释放干净的、没有被锁定的页缓存,脏页需要先回写到磁盘才能被释放,所以执行drop_caches之前,先执行sync命令,把脏页回写到磁盘。
3.直接IO的适用场景与避坑
适用场景:
- 数据库等自己实现了缓存机制的应用,不需要内核的页缓存,避免双重缓存,浪费内存;
- 大文件的顺序读写,不需要预读和缓存;
- 对数据持久化要求极高的场景,直接写入磁盘,避免页缓存中的数据丢失。
避坑指南:
- 直接IO的缓冲区必须按磁盘扇区大小(通常512字节/4KB)对齐,否则会调用失败;
- 直接IO的读写会阻塞等待磁盘IO完成,延迟比缓冲IO高很多,不适合小的随机读写;
- 直接IO会绕过页缓存,无法利用内核的预读、合并写优化,随机读写性能极差;
- 直接IO和缓冲IO混合使用同一个文件,会导致数据不一致,必须避免。
4.脏页回写导致的系统卡顿问题排查与解决
排查流程:
- 用vmstat 1查看bi/bo(磁盘IO)、wa(iowait),确认卡顿的时候是否有大量的磁盘写入;
- 用cat /proc/meminfo | grep Dirty查看脏页的数量,确认卡顿的时候脏页数量是否突然下降,说明正在集中回写脏页;
- 检查dirty_ratio和dirty_background_ratio的配置,如果两个值太接近,会导致异步回写还没完成,就达到了dirty_ratio,阻塞所有写操作,导致系统卡顿。
解决方案:
- 调大dirty_background_ratio和dirty_ratio的差值,比如dirty_background_ratio=10,dirty_ratio=30,保证异步回写有足够的时间,不会触发同步阻塞;
- 调小dirty_writeback_centisecs,让回写线程更频繁的唤醒,每次回写少量的脏页,避免集中回写;
- 调小dirty_expire_centisecs,让脏页更快的被回写,避免脏页堆积;
- 对于机械硬盘,开启IO调度器的合并优化,用mq-deadline调度器;对于SSD,用none/kyber调度器,提升IO性能。
5.页缓存的监控工具
- 全局监控:vmstat 1、sar -r 1、free -h,查看系统的页缓存、脏页数量、内存使用情况;
- 进程级监控:pcstat工具,查看某个文件在页缓存中的占用比例;mincore系统调用,查看进程的虚拟地址对应的页是否在缓存中;
- 详细监控:cat /proc/meminfo,查看Cached、Dirty、Writeback、PageTables等详细指标;
- 回写监控:cat /proc/vmstat | grep pgpgin | pgpgout | pswpin | pswpout,查看磁盘IO的读写次数;iostat -x 1,查看磁盘的IOPS、吞吐量、iowait、队列长度。
