〇、全景:磁盘和用户之间的缓存层
前面几篇讲了物理内存怎么管理(#1 memblock/buddy)、虚拟地址怎么映射到物理页(#3 页表缺页)。但缺页分配出来的页,数据从哪来?匿名页(堆/栈)是凭空清零,文件页则来自磁盘——而内核不会每次都去读磁盘,它在内存里维护了一层page cache(页缓存):
- 读:先查 page cache,命中就直接拷给用户(
copy_folio_to_iter);没命中才读盘填进去; - 写:先写进 page cache 标脏,不立刻落盘(延迟写 write-back),由后台 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 buddy | page 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_WRITEBACK | include/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_ratelimited | mm/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 |