news 2026/10/22 0:41:10

Linux 6.6内核内存管理深度解析(五):page cache(页缓存)— 读缓存在内存、写延迟回写

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux 6.6内核内存管理深度解析(五):page cache(页缓存)— 读缓存在内存、写延迟回写

〇、全景:磁盘和用户之间的缓存层

前面几篇讲了物理内存怎么管理(#1 memblock/buddy)、虚拟地址怎么映射到物理页(#3 页表缺页)。但缺页分配出来的页,数据从哪来?匿名页(堆/栈)是凭空清零,文件页则来自磁盘——而内核不会每次都去读磁盘,它在内存里维护了一层page cache(页缓存):

  • 读:先查 page cache,命中就直接拷给用户(copy_folio_to_iter);没命中才读盘填进去;
  • 写:先写进 page cache 标脏,不立刻落盘(延迟写 write-back),由后台 flush 线程异步刷回磁盘。

命中 → 拷贝给用户
miss → 读盘填进去

预读 readahead
提前读后续页

写进缓存标脏
不立刻落盘

脏页超阈值
balance_dirty_pages 限速

异步刷回磁盘
清除脏标记

page cache
每个文件一个 address_space
用 xarray 按页偏移索引 folio

读 read()
filemap_read

写 write()
generic_perform_write

回写 writeback
wb_workfn flush 线程

一句话主线:page cache 是"磁盘 ↔ 用户"之间的一层内存缓存——每个文件对应一个address_space,用 xarray 按页偏移索引 folio;读 = 先查缓存(命中直接拷、miss 读盘filemap_read_folio填进去),写 = 写进缓存标脏(write_end)、由后台 flush 线程wb_workfn异步回写,脏页太多时balance_dirty_pages限速写进程;预读readahead提前把后续页读进来。它和上一篇 #3 缺页是同一枚硬币的两面:文件页缺页do_fault本质就是"从 page cache 里找不到这一页,去磁盘读进来"。


一、核心数据结构:address_space+ xarray + folio

1.1 一个文件一个address_space

每个 inode(文件)对应一个address_space,它就是这个文件的 page cache 的"账簿":

// include/linux/fs.h (v6.6, line 470)structaddress_space{structinode*host;// 归属哪个文件(inode)structxarrayi_pages;// 按页偏移索引 folio 的树structrb_root_cachedi_mmap;// 映射这个文件的所有 vmaunsignedlongnrpages;// 缓存了多少页pgoff_twriteback_index;// 回写进度(写到哪一页了)conststructaddress_space_operations*a_ops;// 读/写/回写的回调// …};
  • i_pages(xarray):核心索引结构,key 是"页偏移"(文件第几个页),value 是struct folio *。旧内核用 radix tree,v6.6 已换成更通用的xarray。
  • a_ops(address_space_operations):具体文件系统提供的回调——read_folio(读一页)、readahead(预读一批)、write_begin/write_end(写)、writepages(回写落盘)。page cache 是通用框架,具体怎么读盘/写盘交给文件系统。

1.2 folio:页缓存的单位(不再是单struct page)

v6.6 的 page cache 基本单位是struct folio(include/linux/mm_types.h:293),不再是单个struct page。一个 folio 可以包含多个物理页(order 0 单页,或 order 9 = 2MB 大页),把"一组连续页"当整体管理,减少了逐页加锁/记数的开销,也让 THP 大页能直接作为缓存单位。

1.3 xarray 的 tag:标记"脏"和"回写中"

xarray 除了存 folio,还能给每个条目打 tag。page cache 用两个 tag 标记状态:

// include/linux/fs.h (v6.6, line 498)#definePAGECACHE_TAG_DIRTYXA_MARK_0// 这页被改过,要回写#definePAGECACHE_TAG_WRITEBACKXA_MARK_1// 正在回写中

回写时,内核用xa_marked扫出所有打了 DIRTY tag 的 folio 拿去落盘,省得遍历整个文件的所有页。


二、读路径:查缓存 → 预读 → 读盘

read(fd, buf, n)走generic_file_read_iter(filemap.c:2790),它对 buffered 读(非O_DIRECT)最后落到filemap_read(filemap.c:2617):

// mm/filemap.c (v6.6, line 2790,节选)ssize_tgeneric_file_read_iter(structkiocb*iocb,structiov_iter*iter){// O_DIRECT 直接读盘,绕过 page cache(:2798 direct_IO)if(iocb->ki_flags&IOCB_DIRECT){…}// 普通 buffered 读 → 走 page cachereturnfilemap_read(iocb,iter,retval);// :2831}

filemap_read(filemap.c:2617)是一个循环:每次用filemap_get_pages(filemap.c:2535)取一批 folio,再copy_folio_to_iter拷给用户。核心在filemap_get_pages,它按"页在不在缓存里"分三种情况:

// mm/filemap.c (v6.6, line 2535,节选)filemap_get_read_batch(mapping,index,last_index-1,fbatch);// ① 查 xarrayif(!folio_batch_count(fbatch)){// 缓存 misspage_cache_sync_readahead(mapping,ra,filp,index,…);// ② 同步预读filemap_get_read_batch(mapping,index,last_index-1,fbatch);}if(!folio_batch_count(fbatch)){// 还是没有err=filemap_create_folio(filp,mapping,…);// ③ 分配 + 读盘// …}
  • ① 命中:filemap_get_read_batch(filemap.c:2329)从 xarray 批量捞出 folio,直接拷给用户,零磁盘 I/O;
  • ② 预读:miss 时先page_cache_sync_readahead把当前及后续一批页读进来(见 §五);
  • ③ 读盘:还 miss,filemap_create_folio(filemap.c:2478)分配一个新 folio、filemap_add_folio挂进 xarray、filemap_read_folio(filemap.c:2365)调用a_ops->read_folio真正从磁盘读;对"预读已部分填好但还没读完"的 folio,则走filemap_update_page(filemap.c:2422)补读并等待 I/O 完成。

注意generic_file_read_iter里的O_DIRECT分支(:2798):用O_DIRECT打开的文件绕过 page cache,direct_IO直接读盘。这是"要不要缓存"的选择——默认要缓存(page cache),数据库等自己管缓存的场景才用 O_DIRECT 绕过。


三、写路径:写进缓存 + 标脏(延迟写)

write(fd, buf, n)走generic_file_write_iter(filemap.c:4081),buffered 写落到generic_perform_write(filemap.c:3932):

// mm/filemap.c (v6.6, line 3932,节选)do{status=a_ops->write_begin(file,mapping,pos,bytes,…);// ① 准备页// …copied=copy_page_from_iter_atomic(page,offset,bytes,i);// ② 拷用户数据// …status=a_ops->write_end(file,mapping,pos,bytes,copied,…);// ③ 标脏// …balance_dirty_pages_ratelimited(mapping);// ④ 脏页多了就限速}while(iov_iter_count(i));
  • ①write_begin:文件系统拿到(或分配)page cache 里的目标页,必要时读盘先填满要改的部分;
  • ② 拷贝:copy_page_from_iter_atomic把用户缓冲区数据拷进 page cache 页;
  • ③write_end:标记这页为dirty(打上PAGECACHE_TAG_DIRTY),但绝不写盘;
  • ④ 限速:balance_dirty_pages_ratelimited(page-writeback.c:2062)检查脏页是否超阈值,超了就慢下来(见 §四)。

这就是"延迟写(write-back)":write()返回时数据只进了内存缓存,还没到磁盘。好处是快(攒一批再落盘、合并连续写),代价是掉电/崩溃会丢还没回写的数据(所以有fsync/O_SYNC强制落盘)。


四、回写 writeback:balance_dirty_pages限速 + flush 线程

脏页不能无限攒,内核用两条腿把它刷回磁盘:一条是"写进程侧"的限速,一条是"后台 flush 线程"的刷盘。

4.1 三个阈值:free-run → 后台回写 → 节流

脏页占"可脏内存"的比例由两个可配置阈值(默认dirty_background_ratio = 10、vm_dirty_ratio = 20,page-writeback.c:74/:91)和派生的 freerun 上限一起分档:

脏页比例内核动作
< bg_thresh(10%)写进程全速,无动作
> bg_thresh(10%)启动后台回写wb_start_background_writeback
> freerun ceiling((thresh+bg_thresh)/2= 15%)写进程开始被节流(wb_position_ratio算比例,pause)
> thresh(20%)严重节流,写进程基本停下来等回写

balance_dirty_pages(page-writeback.c:1669)是限速的核心,它在一个循环里反复判断:

// mm/page-writeback.c (v6.6, line 1669,节选)for(;;){// ① 脏页超过后台阈值 → 启动后台回写if(!laptop_mode&&nr_reclaimable>gdtc->bg_thresh&&!writeback_in_progress(wb))wb_start_background_writeback(wb);// :1754// ② 还没到 freerun 上限 → 写进程全速,跳出(if 在 :1768)if(dirty<=dirty_freerun_ceiling(thresh,bg_thresh)&&…)break;// :1783// ③ 超过 thresh → 算 pos_ratio,按比例 pause 写进程wb_position_ratio(gdtc);// :1813// … pause 一段时间后回到循环重新判断 …}

dirty_freerun_ceiling(page-writeback.c:813)就是(thresh + bg_thresh) / 2。核心思想是"让写盘速度追上写内存速度":脏页涨到 bg_thresh 就派后台线程回写;回写还追不上、脏页继续涨到 15%/20%,就直接把写进程摁住(节流),等脏页降下来再放行——保证脏页不会把可回收内存吞光。

4.2 flush 线程:wb_workfn真正落盘

后台刷盘由 writeback 工作队列的 worker 执行——每个 backing device 一个bdi_writeback上下文,worker 显示名就是ps里看到的flush-<设备名>线程,入口wb_workfn(fs/fs-writeback.c:2245):

// fs/fs-writeback.c (v6.6, line 2245,节选)voidwb_workfn(structwork_struct*work){structbdi_writeback*wb=container_of(…,structbdi_writeback,dwork);do{pages_written=wb_do_writeback(wb);// → wb_writeback(:2022)}while(!list_empty(&wb->work_list));// 还有活就继续// …}

wb_writeback(fs-writeback.c:2022)扫出打PAGECACHE_TAG_DIRTY的 folio,调用a_ops->writepages(具体文件系统,如 ext4)把它们写回磁盘,完成后清除 DIRTY tag 和脏位。这整个过程是异步的——写进程早write()完事了,flush 线程在后台慢慢刷。


五、预读 readahead:提前把后续页读进来

顺序读文件时,如果每次都等"缺这一页才去读盘",磁盘 I/O 延迟会卡住读进程。预读就是"读第 N 页时,顺手把 N+1、N+2… 也读进来",用空间换延迟。两条触发路径:

  • 同步预读page_cache_sync_ra(readahead.c:671):读 miss 时(§二 ②),把请求页及后续一批页读进来;
  • 异步预读page_cache_async_ra(readahead.c:699):读到一个打了PG_readahead标记的页时,说明"预读的页快被消费完了",后台再预读下一批。

预读窗口记在struct file_ra_state(fs.h:968)里——start(窗口起点)、size(本次预读多少)、async_size(异步预读的提前量)、ra_pages(最大预读页数):

// include/linux/fs.h (v6.6, line 968)structfile_ra_state{pgoff_tstart;// 预读窗口起点unsignedintsize;// 本次预读页数unsignedintasync_size;// 异步预读提前量unsignedintra_pages;// 最大预读页数// …};

两条*_ra最终都进ondemand_readahead→page_cache_ra_unbounded(readahead.c:205)→read_pages(readahead.c:146),read_pages调a_ops->readahead(文件系统批量读)或逐页a_ops->read_folio提交 I/O。预读的页先加进 page cache 但标记PG_readahead/!uptodate,读进程走到那一页时若还没读完,就filemap_update_page等它(§二 ③)。


六、与 mmap 缺页的关系:filemap_fault

上一篇 #3 讲文件页缺页do_fault→__do_fault→vm_ops->fault。对普通文件,这个fault回调就是filemap_fault(filemap.c:3255)——它做的事情和read()的读路径几乎一样:在 page cache 里找这一页,找不到就读盘填进去,然后映射进页表。所以mmap 的文件映射,底层数据也是存在 page cache 里的:

  • read()/write()通过filemap_read/generic_perform_write操作 page cache;
  • mmap后的内存访问通过缺页filemap_fault操作同一个page cache;
  • 两者看到的是一致的缓存,这正是mmap和read/write能共享同一份文件缓存的原因。

七、为什么这样设计(Why 层)

7.1 为什么读/写都要过 page cache,而不是直接读盘

因为磁盘慢、内存快,且文件访问有强时间局部性和重复性(同一文件反复读、反复写)。page cache 把"最近访问过的文件页"留在内存,命中就免掉磁盘 I/O,性能提升数量级。这也是为什么刚读过的文件再次读会快得多、为什么内存里总有大量"可回收的"文件页(见 #8 页回收)。

7.2 为什么写用"延迟写(write-back)“而不是"同步写(write-through)”

同步写每write()一次就落盘一次,磁盘 I/O 立刻成为瓶颈。延迟写让写进程只写内存、立刻返回,把"落盘"这个慢动作攒成一批、交给后台线程异步做——合并连续写、批量下盘,吞吐量大幅提升。代价是崩溃可能丢数据,所以内核提供fsync/O_SYNC给需要持久化保证的调用者显式强制落盘。"默认快(延迟写)+ 需要时慢(fsync)"是通用折中。

7.3 为什么要balance_dirty_pages限速写进程

如果写进程全速往 page cache 灌、回写线程又刷不过来,脏页会无限上涨,最终把可回收内存(NR_FILE_DIRTY)全部吞掉、触发 OOM。限速的本质是"让写内存的速度和写盘的速度匹配":脏页一超阈值就摁住写进程,逼迫整体吞吐收敛到磁盘带宽——这是"生产者(写进程)vs 消费者(回写线程)"的背压(backpressure)机制。

7.4 为什么要预读 readahead

顺序读是文件 I/O 的常态(日志、数据文件、模型权重)。如果只按需读,每次读盘都要等磁盘寻道/延迟,CPU 空转。预读用"读一页、顺手读一批"把未来的缺页 I/O 提前、合并成顺序大 I/O(磁盘顺序读远快于随机读),把延迟摊销掉。预读是"用多读一点内存的代价,换磁盘顺序 I/O"的投机优化。

7.5 为什么用 xarray 而不是简单数组

文件的页偏移是稀疏的——一个 1GB 的文件,用户可能只访问头尾两页。简单数组要为整个文件每个页都建条目,稀疏访问时浪费巨大;xarray(radix tree 的通用化)按"用到的偏移"建树,只占实际访问到的部分,和 #3 多级页表"稀疏地址空间只占实际用到的部分"是同一个思想。


八、与已有博客的交叉引用

已有博客本篇覆盖的关系
内存 #3 页表缺页文件页缺页do_fault→__do_fault→vm_ops->fault=filemap_fault(:3255),本质是"从 page cache 读"。本篇 §六 讲清了这条线;page cache 是文件缺页的"数据源"
内存 #2 buddypage cache 的 folio 从哪来?filemap_alloc_folio底层就是 buddy 分配物理页(__alloc_pages)。page cache 是 buddy 的又一个大消费者
内存 #4 大页folio 可以是 order=9 的大页,THP 大页能直接作为 page cache 的缓存单位(CONFIG_READ_ONLY_THP_FOR_FS,fs.h:476)
IOMMU 系列page cache 管的是"CPU/文件"侧缓存;设备 DMA 读写文件(如 GPU 直读 SSD)绕不绕 page cache 是另一套(O_DIRECT / GUP,见后续 [[blog-memory-expansion]] GUP 篇)

附:本篇关键源码索引

符号位置
struct address_space(host/i_pages/a_ops/nrpages/writeback_index)include/linux/fs.h:470
PAGECACHE_TAG_DIRTY/PAGECACHE_TAG_WRITEBACKinclude/linux/fs.h:498/:499
struct file_ra_state(start/size/async_size/ra_pages)include/linux/fs.h:968
struct folio(页缓存单位)include/linux/mm_types.h:293
generic_file_read_iter(读入口,O_DIRECT 分流)mm/filemap.c:2790
filemap_read(读主循环)mm/filemap.c:2617
filemap_get_pages(查缓存/预读/读盘三态)mm/filemap.c:2535
filemap_get_read_batch(xarray 批量查)mm/filemap.c:2329
filemap_create_folio(分配 + 读盘)mm/filemap.c:2478
filemap_update_page(补读 + 等 I/O)mm/filemap.c:2422
filemap_read_folio(→a_ops->read_folio)mm/filemap.c:2365
filemap_fault(mmap 文件缺页)mm/filemap.c:3255
generic_perform_write(写主循环)mm/filemap.c:3932
generic_file_write_iter(写入口)mm/filemap.c:4081
balance_dirty_pages(限速核心)mm/page-writeback.c:1669
balance_dirty_pages_ratelimitedmm/page-writeback.c:2062
dirty_background_ratio/vm_dirty_ratio(默认 10 / 20)mm/page-writeback.c:74/:91
dirty_freerun_ceiling((thresh+bg_thresh)/2)mm/page-writeback.c:813
wb_workfn(flush 线程)fs/fs-writeback.c:2245
wb_writeback(回写落盘)fs/fs-writeback.c:2022
page_cache_sync_ra/page_cache_async_ra(同步/异步预读)mm/readahead.c:671/:699
page_cache_ra_unbounded/read_pages(预读执行)mm/readahead.c:205/:146
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 10:56:52

Prim算法详解:从手算到代码实现最小生成树

1. 从一张网线布线图说起&#xff1a;Prim算法到底在解决什么问题很多刚接触图论的朋友第一次听到“最小生成树”这个词&#xff0c;脑子里冒出来的问号比代码行数还多。我当初也一样&#xff0c;直到有一次帮朋友规划一个小型办公区的网线走线——六个工位&#xff0c;两两之间…

作者头像 李华
网站建设 2026/10/20 19:40:15

Django与Flask整合开发公务员考试系统实践

1. 项目概述与核心价值这个公务员考试练习系统项目采用了前后端分离的架构设计&#xff0c;后端同时整合了Django和Flask两大Python框架&#xff0c;前端使用Vue.js实现动态交互界面&#xff0c;开发环境选择了PyCharm这款专业的Python IDE。这种技术组合在当前教育类Web应用中…

作者头像 李华
网站建设 2026/10/19 9:43:33

Matlab实现氢能综合能源系统优化调度模型

1. 项目背景与核心价值氢能作为清洁能源的重要载体&#xff0c;正在重塑综合能源系统的运行方式。这项研究通过Matlab实现了计及氢能的综合能源系统优化调度模型&#xff0c;为多能互补系统提供了量化分析工具。我在参与某工业园区微电网项目时&#xff0c;深刻体会到传统能源调…

作者头像 李华
网站建设 2026/10/19 15:14:46

PoE Build 规划器:离线算伤害,快速规划角色

PoE Build 规划器&#xff1a;离线算伤害&#xff0c;快速规划角色 【免费下载链接】PathOfBuilding Offline build planner for Path of Exile. 项目地址: https://gitcode.com/GitHub_Trending/pa/PathOfBuilding 还在为天赋加点和装备搭配反复纠结&#xff0c;算不清…

作者头像 李华
网站建设 2026/10/19 4:18:01

FIFO UVM验证环境全解析:从拆包到覆盖率收敛的实战笔记

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华