写入几秒很快,持续写入却降速;write 成功之后,fsync 仍可能报错。Page Cache 既保存内容,也协调并发填充、脏页预算和完成边界。
| 要解释的现象 | 阅读位置 |
| 多个 fd 是否各有一份缓存? | 第 1 节:文件偏移、XArray 与 folio |
| 命中缓存为何仍要等? | 第 2~3 节:填充与写回 |
| 缓存为何撑不住持续高速写入? | 第 4~5 节:节流与完成语义 |
| mmap、直接 I/O 改变哪条路径? | 第 6~7 节:接口与实验 |
1. 缓存以文件内容组织,不按每个 fd 复制
常规文件的 inode 通常通过 i_mapping 关联 address_space;其中 i_pages 用 XArray 索引缓存内容。名字不同但指向同一 inode 的硬链接,以及独立 open 得到的多个 fd,通常共享这份内容缓存。address_space 不是进程的虚拟地址空间 mm_struct。
手算:文件偏移 10000 落在哪个索引
假设 PAGE_SIZE=4096 字节,10000 = 2 × 4096 + 1808:缓存索引为 2,页内偏移为 1808。
索引单位是基础页大小,不是文件系统块、设备逻辑扇区,也不是 folio 个数。基础页大小仍依架构与内核配置而定。
为什么引入 folio
folio 表示一组按整体管理、物理连续的基础页,大小为基础页的二次幂倍数,可以只有一页。明确“这里操作的是整组页”能减少把 compound page 尾页误当独立对象的歧义,也有助于批量管理缓存。
大 folio 不意味着所有文件都使用 2 MiB 页,也不意味着文件系统无需关心大小。分配能力、对齐、文件系统支持、读写有效范围、回收和拆分都会影响实际选择。一个跨多个基础页的 folio 仍按文件页索引覆盖相应范围,不能把索引改算成“第几个 folio”。
XArray 支持多索引条目、标记和并发查找,但查到的条目不一定是普通指针,也可能是 shadow/swap 等值条目。它的内部同步不自动解决所存对象的寿命或文件范围锁定;页缓存代码应使用相应 filemap/folio 接口,不能直接 xa_store() 一个指针就认为已完成插入、引用和记账。见 XArray 文档。
2. 读路径:存在于缓存不等于数据已可用
一次缓冲读取需要定位请求范围、取得有效引用、确认对应内容就绪,才能复制到用户缓冲区。若 folio 正由另一请求填充,读取者可能等待;缓存命中因此不保证零等待。文件系统可能按块追踪部分有效状态,整 folio 的 uptodate 与某一范围已有效也要区分。
多个请求读取同一未缓存范围时,需要围绕同一份填充状态协调:
- 建立候选。 分配并尝试插入锁定的 folio,处理并发插入竞争。
- 推进填充。 发起后端读取,其他请求可等待正在填充的对象。
- 发布结果。 I/O 完成后更新有效状态、解锁并唤醒等待者。
若各请求在缓存外独立读完,最后再无条件插入,会难以避免重复填充。实际路径还要处理截断与 I/O 错误。
后端不必产生 bio:本地块文件系统可能通过块层,NFS 使用网络请求,tmpfs 的后备方式又不同。file_accessed(file) 与访问时间相关,不能写成接收 folio 并更新 LRU 的缓存访问标记函数。
预读是在赌下一次访问
顺序访问适合提前读后面的范围,让后端 I/O 与应用计算重叠。随机跳转或工作集远大于内存时,过量预读可能浪费带宽并挤走有用缓存。
“同步预读”通常指当前缓存缺失触发的预读路径;“异步预读”常由消费到预读窗口中的触发标记来推进窗口。这里的命名不表示同步路径必须等待整个预读窗口完成,也不等于固定预读 128 KiB。窗口受访问历史、文件边界、设备设置和实现策略影响。见 页缓存与预读 API。
3. 写路径:先保证局部内容正确,再管理脏数据
缓冲写入取得相应缓存范围,把用户数据复制进去,更新大小及脏状态。部分覆盖原有文件块时,可能需要先读取未覆盖的旧内容;若旧内容已在缓存,或范围是新分配、空洞且应为零,就不必机械地先读磁盘。不同文件系统可使用 buffer-head 或 iomap 等路径,不能把一条固定函数链当作所有实现。
dirty 表示有尚需写回的修改;writeback 表示某次写回正在进行。开始写回后,应用可能再次修改同一 folio,所以两种状态能够同时出现。本轮 I/O 完成不自动清除之后产生的新脏数据。写回代码需要跟踪范围、状态与完成时序,详细契约见 iomap 设计。
延迟分配允许文件系统在后续写回时再决定物理块,有利于聚合布局,但也使空间不足等错误可能晚于最初的 write。内核使用写回错误记录机制向相关文件描述返回错误,应用不能忽略后续 fsync 的失败。即使 close 返回成功,也不能据此认定已持久化。
4. 写者为什么会被脏页限流
先算缓存积压速度,假设输入、输出速率持续不变:
| 流量 | 速率 |
| 应用产生脏数据 | 1 GiB/s = 1024 MiB/s |
| 后端排出 | 200 MiB/s |
| 暂存差额 | 824 MiB/s |
每多持续一秒,就需要再容纳约 824 MiB 差额。缓存能吸收突发,却不能无限维持这个速度;脏页预算受到压力后,写者需要节流。短测的快速 write 可能只是消耗了这份预算。
| 控制项 | 含义 | 易错理解 |
dirty_background_ratio / dirty_background_bytes | 后台写回启动相关阈值 | 不是数据持久性的截止时间 |
dirty_ratio / dirty_bytes | 写者节流相关阈值 | ratio 的分母不是简单的物理总内存 |
dirty_expire_centisecs | 脏数据达到一定年龄后可成为周期写回对象 | 不是每个字节到龄即已落盘 |
dirty_writeback_centisecs | 周期写回唤醒间隔 | 不是唯一写回触发来源 |
ratio 与 bytes 是成对的替代配置方式,写入一方会使对应另一方清零;实际控制还涉及可写脏内存估算、各写回域份额及 cgroup 支持。当前值应读取 /proc/sys/vm/,不要把某组默认值写成所有机器的事实。后台工作也不是永久“一块盘对应一个独占 flusher 线程”。见 VM sysctl 文档。
5. 三种完成语义
| 观察点 | 能说明什么 | 不能说明什么 |
| 缓冲 write 返回成功 | 返回的字节数已被本次写入接受 | 所有请求字节都写完;掉电后仍在 |
| 后端写请求完成 | 相应 I/O 达到该协议的完成条件 | 自动满足设备易失缓存、目录更新和应用事务要求 |
| fsync 成功 | 文件数据及所需元数据按同步契约完成 | 父目录项已同步;多个文件形成原子事务 |
fdatasync() 仍要同步恢复数据所必需的元数据,例如影响读取的文件大小;它不是完全不写元数据。创建或重命名后要保证名字持久存在,通常还需要同步相关父目录,具体故障协议应按文件系统能力设计。同步失败必须上报或进入明确的恢复流程。见 fsync(2)。
特意在一次写回进行期间再次写入,再完成旧请求。下面分别保留缓存、设备易失状态和稳定内容,用断电结果检查每个完成点究竟保住了哪个版本。
正在呈现知识画面
一次写入,四个不同的完成位置读者看到的缓存版本、一次 I/O 的快照、设备易失缓存和稳定介质可以不同。fsync 的文件完成契约不替代目录同步。
{"id":"cache-writeback-durability","title":"一次写入,四个不同的完成位置","summary":"读者看到的缓存版本、一次 I/O 的快照、设备易失缓存和稳定介质可以不同。fsync 的文件完成契约不替代目录同步。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"actions\"><button id=\"write\" data-i18n=\"write\"></button><button id=\"start\" data-i18n=\"start\"></button></div><div class=\"actions\"><button id=\"complete\" data-i18n=\"complete\"></button><button id=\"flush\" data-i18n=\"flush\"></button></div><div class=\"track-row\"><span data-i18n=\"cache\"></span><div id=\"cache\" class=\"track\"></div></div><div class=\"track-row\"><span data-i18n=\"flight\"></span><div id=\"flight\" class=\"track\"></div></div><div class=\"track-row\"><span data-i18n=\"device\"></span><div id=\"device\" class=\"track\"></div></div><div class=\"track-row\"><span data-i18n=\"media\"></span><div id=\"media\" class=\"track\"></div></div><div class=\"metrics\"><div><span data-i18n=\"dirty\"></span><strong id=\"dirty\"></strong></div><div><span data-i18n=\"saved\"></span><strong id=\"saved\"></strong></div></div><div class=\"actions\"><button id=\"sync\" data-i18n=\"sync\"></button><button id=\"crash\" data-i18n=\"crash\"></button></div><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"note\"></p></div><div class=\"actions\"><button id=\"reset\" data-i18n=\"reset\"></button></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}let cache=0,flight=null,device=0,media=0,serial=0;function draw(){for(const [k,v]of Object.entries({cache,flight,device,media}))cells(k,[{text:v===null?'—':'v'+v,cls:k==='media'?'gold':v===null?'empty':'on'}]);set('dirty',cache===device?'—':'v'+cache);set('saved','v'+media);$('#start').disabled=flight!==null||cache===device;$('#complete').disabled=flight===null;set('explanation',t('note'));}$('#write').onclick=()=>{cache=++serial;draw();};$('#start').onclick=()=>{if(flight===null&&cache!==device)flight=cache;draw();};$('#complete').onclick=()=>{if(flight!==null){device=flight;flight=null;}draw();};$('#flush').onclick=()=>{media=device;draw();};$('#sync').onclick=()=>{device=media=cache;flight=null;draw();};$('#crash').onclick=()=>{cache=device=media;flight=null;draw();};$('#reset').onclick=()=>{cache=device=media=serial=0;flight=null;draw();};draw();","audio":false,"strings":{"cache":"① 读者可见的页缓存","complete":"完成这轮 I/O","crash":"模拟掉电并恢复","device":"③ 设备易失缓存","dirty":"尚待写出的新版本","flight":"② 本轮写回快照","flush":"设备 flush","heading":"一次写入,四个不同的完成位置","intro":"直接写入、启动写回、完成 I/O 或同步。写回进行中再写一次,比较正在传输的版本和新的脏版本。","media":"④ 稳定介质","note":"写回开始后,新写入不会自动加入已经发出的 I/O。对照四层的版本,再判断哪一份能经受这里的掉电。","reset":"重置","saved":"已持久化的版本","scope":"单一文件范围,无错误或并发写回。掉电情景确定性丢弃所有易失状态,保留稳定介质;现实中未同步数据也可能已后台落盘。设备遵守 flush,fsync 在这里原子完成该文件链条,不模拟实际调度与元数据。","scopeLabel":"模型范围与约定","start":"启动一轮写回","sync":"文件 fsync","write":"写入下一版本"}}
6. mmap 与直接 I/O 分别改变了什么
普通文件的 MAP_SHARED 映射通常接到同一页缓存,写入可形成脏页;MAP_PRIVATE 的写时复制形成私有修改,不把这些修改写回原文件。映射在关闭原 fd 后仍可存续;文件被并发截短后访问超出新末尾的页可能收到 SIGBUS。msync(MS_SYNC) 可同步共享映射范围,但不代替跨文件事务或目录同步。见 mmap(2)。
直接 I/O:绕开缓存路径,不扩大持久承诺
O_DIRECT 通常让文件数据传输避开缓冲 I/O 的缓存路径,但不等于 O_SYNC,也不自动承诺零复制、异步执行或断电持久性。缓冲区地址、长度和文件偏移的对齐要求依文件系统与设备而异;支持时可通过 statx() 的 STATX_DIOALIGN 查询,不能统一写死为 512 或 4096 字节。不满足约束时可能报错,部分路径也可能回退。
混用路径时,仍要协调缓存和缓冲区寿命
直接 I/O 与缓冲写入、共享映射混用时,内核仍可能需要写回或失效缓存来维持一致性,性能和并发推理都更复杂。异步请求完成前,用户缓冲区必须保持有效且满足访问约束;涉及 fork 与私有映射的缓冲区还要遵守额外限制。接口限制见 open(2) 的 O_DIRECT 说明。
7. 小实验:共享映射和私有副本
下面只创建一个临时文件,验证可见内容,不测吞吐量或断电恢复:
import mmap
import os
import tempfile
with tempfile.TemporaryFile() as file:
file.write(b"A" * mmap.PAGESIZE)
file.flush() with mmap.mmap(file.fileno(), 0, access=mmap.ACCESS_WRITE) as shared:
shared[0:1] = b"S"
shared.flush() os.fsync(file.fileno())
assert os.pread(file.fileno(), 1, 0) == b"S"
with mmap.mmap(file.fileno(), 0, access=mmap.ACCESS_COPY) as private:
private[0:1] = b"P"
assert private[0:1] == b"P"
assert os.pread(file.fileno(), 1, 0) == b"S"
assert os.pread(file.fileno(), 1, 0) == b"S"
print("共享映射写入可见;私有修改未写回文件:通过")
file.flush()、映射 flush 和 fsync() 分别跨越不同缓冲/同步边界;一个 Python 方法名相同,并不意味着所做工作相同。
只读排查可查看 /proc/meminfo 的 Cached、Dirty、Writeback 和 /proc/vmstat 的相关计数。Cached 不应直接当作可随时无成本释放的普通磁盘文件缓存,tmpfs/shmem 等也影响统计。mincore() 报告的驻留状态是快照,不保证下一次读取延迟。drop_caches 是系统范围操作,会扰动其他进程的缓存状态,不应放进日常观测步骤当作无副作用的“刷新”。
进一步沿 mm/filemap.c 阅读缓存读取,沿 mm/readahead.c 阅读预读,沿 mm/page-writeback.c 与 fs/fs-writeback.c 阅读节流和写回。文件布局与日志机制接 文件系统实现。