writeback:脏页什么时候写回磁盘
看本篇之前请先看前置文章,如果对本篇所讲的 radix tree 以及 XArray 数据结构熟悉的可以跳过前置文章直接看本篇。
上一篇讲文件页回收时留下了一个关键问题:
clean file page 可以直接丢。
dirty file page 不能直接丢。
那谁负责把 dirty page 写回?
write() 为什么常常很快返回?
回收遇到 dirty page 时,是自己写盘,还是等别人写?
这一篇专门拆 writeback。
先把结论放前面。从整体行为上看,一次普通的 buffered write 大致经历下面这个过程:
应用发起写入
│
▼
数据先进入内存中的 page cache
│
├─ 对应的缓存页被标记为 dirty
├─ 系统记录“这个文件有数据待写回”
└─ 通常不等待磁盘 I/O,write() 就可以返回
│
▼
脏数据在之后的某个时刻被写回后备存储
│
├─ 后台回写机制定期或按需处理
├─ 脏数据过多时,系统推动回写并限制新的写入
├─ 应用显式要求同步或持久化
└─ 内存回收遇到脏页时,触发回写或等待回写完成
│
▼
数据进入后备存储,对应的缓存页恢复为 clean
最重要的区分是:
writeback:
把 dirty file page 写成 clean file page。
reclaim:
把 clean 且可释放的 folio 从 page cache 移除,释放物理页。
fsync:
用户要求某个文件的数据/元数据达到持久性边界。
三者有关联,但不是一件事。
一、write() 为什么不等于落盘
普通 buffered write() 的目标不是每次都同步写磁盘。它更常见的行为是:
用户缓冲区
│
▼
copy_from_user
│
▼
page cache folio
│
├─ 标记 dirty
├─ 更新 inode / writeback 统计
└─ write() 可以返回
后续某个时刻
│
▼
writeback 把 dirty folio 写到后备存储
这样做的收益很直接:
- 多次小写入可以合并成更大的 I/O。
- 同一页反复写,只需要最终写回一次。
- 应用不必每次
write()都等待设备延迟。 - 文件系统可以按自己的布局、日志、延迟分配策略组织写回。
代价也很明确:
write() 成功返回
!=
数据已经持久化到磁盘
如果应用需要持久性边界,需要用 fsync()、fdatasync()、sync_file_range()、O_SYNC、O_DSYNC 等机制表达。不同文件系统还会涉及日志、元数据、barrier、设备缓存等更深层语义;本文只讲 Linux 内存管理和 writeback 主线。
二、脏页是怎么被标记出来的
普通文件写入常见路径在 mm/filemap.c 的 generic_perform_write():
write(fd, 用户 buf, len)
│
▼
进入 buffered write 主循环
│
├─ 写入者先做一次脏页节流检查
│ 如果当前任务已经制造了不少 dirty page,
│ 可能在这里被 balance_dirty_pages_ratelimited() 限速。
│
├─ 按文件 offset 找到这次要写的 page-cache 位置
│ 文件 offset -> address_space->i_pages[index]
│
├─ 找到或创建 page-cache folio
│ 由文件系统的 write_begin() 准备:
│ 可能分配 folio、读入旧内容、准备块映射或日志状态。
│
├─ 把用户 buf 里的字节复制到这个 folio
│ 复制完成后,内存里的 page cache 已经是新数据;
│ 磁盘/后端文件通常还不是新数据。
│
└─ 文件系统收尾
write_end() 更新本次写入长度、文件大小等状态,
并把这个 folio 标成 dirty。
标脏的通用路径可以抽象成:
page-cache folio 里已经有新数据
│
▼
设置 folio 自己的 dirty 状态
│
├─ 如果它本来已经 dirty:
│ 不需要重复记账,后续 writeback 会统一处理。
│
└─ 如果它是第一次从 clean 变 dirty:
│
▼
找到 folio 所属的文件缓存对象
│
└─ folio -> mapping(address_space) -> inode
│
▼
在 mapping->i_pages 的 xarray 上打 dirty 标记
│
└─ index=N 的槽位标成 PAGECACHE_TAG_DIRTY,
以后 writeback 可以按 tag 快速找到这批脏页。
│
▼
更新 dirty page 账本
│
├─ 节点 / 全局统计:NR_FILE_DIRTY、NR_DIRTIED 等
├─ backing-device writeback 统计:WB_RECLAIMABLE、WB_DIRTIED 等
└─ 当前任务统计:current->nr_dirtied 增长
│
▼
标记 inode 有脏页
│
└─ inode 带上 I_DIRTY_PAGES,
后台 writeback / fsync 才知道这个文件有数据页要写回。
源码里的函数名对应到上面这些动作,大致是:folio_mark_dirty() 入口找到 mapping,调用文件系统的 dirty_folio() 钩子;默认通用实现常走 filemap_dirty_folio(),它负责设置 folio dirty 状态、给 mapping->i_pages 打 PAGECACHE_TAG_DIRTY,再通过 __mark_inode_dirty(inode, I_DIRTY_PAGES) 把 inode 放进脏页管理路径。
这里有三层状态一起改变。
第一,folio 自己有 dirty 状态:
folio dirty = 1
第二,mapping->i_pages 的 xarray 上有 dirty tag:
address_space->i_pages[index] 被标记 PAGECACHE_TAG_DIRTY
这让 writeback 能高效找到脏页,而不是从头扫描所有 page cache。
第三,inode 和 backing device writeback 账本也要更新:
inode: I_DIRTY_PAGES
node/vmstat: NR_FILE_DIRTY, NR_DIRTIED
wb: WB_RECLAIMABLE, WB_DIRTIED
current->nr_dirtied 增长
current->nr_dirtied 很重要。它让写入者不能无限制造 dirty page 而不被检查。后面 balance_dirty_pages_ratelimited() 就靠它周期性进入脏页平衡逻辑。
三、MAP_SHARED 写文件映射也会标脏 page cache
文件 mmap 要分私有和共享。
两者在只读时都可以复用文件的 page cache,核心区别在于“写入后修改归谁”:
MAP_PRIVATE是私有映射。写入时会为当前进程产生私有副本,修改对其他映射者不可见,也不会写回原文件。它适合加载可执行文件、动态库,或把文件当作可修改但不保存的初始模板。MAP_SHARED是共享映射。写入直接修改文件的 page-cache 页,其他映射者可以看到,脏数据之后也可以写回文件。它适合多进程共享数据,或直接通过映射修改文件。
但“其他进程已经看到修改”不等于“数据已经持久化”。MAP_SHARED 如果需要明确的持久化边界,还要使用 msync()、fsync() 等同步手段。
具体到缺页路径,MAP_PRIVATE 文件映射写入通常触发 COW:
MAP_PRIVATE file mapping
│
▼
write fault
│
▼
do_cow_fault
│
└─ 复制成匿名页
结果:
改的是进程私有匿名页,不是文件 page cache。
而 MAP_SHARED 文件映射写入才是本文关心的脏文件页:
进程写入 MAP_SHARED 映射区域
│
▼
对应文件页尚未建立可写映射?
│
├─ 是:找到或读入对应的 page-cache 页
│ 让文件系统做好写入准备
│ 再建立共享的可写映射
└─ 否:直接使用已有映射
│
▼
CPU 的 store 直接修改文件的 page-cache 页
│
├─ 其他共享映射者可以看到修改
├─ 该页被标记为 dirty
└─ 系统记录这个文件有数据待写回
│
▼
之后由 writeback 写入后备存储
所以:
write(fd, buf)
和
对 MAP_SHARED mmap 区间 store
都可以制造 dirty file page。
区别在于用户访问路径不同:前者通过系统调用复制数据,后者通过缺页和页表把 page cache folio 映射给用户,再由 CPU store 修改它。
四、脏页阈值:什么时候后台写,什么时候写入者被限速
Linux 不允许脏页无限增长。核心阈值来自 /proc/sys/vm/dirty_*:
dirty_background_ratio
dirty_background_bytes
dirty_ratio
dirty_bytes
dirty_writeback_centisecs
dirty_expire_centisecs
前两组控制“有多少 dirty page 才开始采取行动”,对应关系是:
background threshold:
dirty_background_ratio 按 dirtyable memory 的比例设置
dirty_background_bytes 按绝对字节数设置
超过后唤醒后台 writeback。
dirty threshold:
dirty_ratio 按 dirtyable memory 的比例设置
dirty_bytes 按绝对字节数设置
更高的限制。
写入者可能被 balance_dirty_pages() 限速,
避免脏页继续失控增长。
每组中的 ratio 和 bytes 是同一个阈值的两种表达方式,二选一,不会叠加生效。设置其中一种时,对应的另一种会被清零。
v6.12.65 的 domain_dirty_limits() 会根据这些 sysctl 算出:
bg_thresh
thresh
这些阈值不是简单用总内存乘比例。内核会先算 dirtyable memory,还要考虑 memcg、backing device、实时任务等因素。第一层模型可以这样记:
dirty <= background:
多数写入自由进行。
dirty > background:
唤醒后台 writeback。
dirty 接近或超过 dirty threshold:
写入者在 balance_dirty_pages() 里按设备写回速度睡眠限速。
五、balance_dirty_pages() 不是回收,它是节流器
generic_perform_write() 在每轮写入前调用:
balance_dirty_pages_ratelimited(mapping)
这个函数不是每次都完整计算全局状态。它先看当前任务已经脏了多少页:
current->nr_dirtied >= current->nr_dirtied_pause ?
达到检查周期后,系统才会做一次完整的脏页压力评估。抽象来看,它主要做几件事:
评估当前的脏页压力
│
├─ 统计系统及各内存控制组已累积的脏数据
└─ 结合可用内存算出当前的脏页阈值
│
▼
脏数据是否超过后台回写阈值?
│
└─ 是:如果后端尚未回写,则启动后台 writeback
│
▼
当前压力是否仍在安全范围?
│
├─ 是:允许写入者继续运行,并调整下次检查时间
└─ 否:根据后端的实际写回速度限制写入者,
必要时让写入者暂停一会儿
这就是为什么大量写文件时,你可能看到 write() 本身变慢,即使还没有调用 fsync():
应用制造 dirty page 太快
│
▼
dirty pages 接近阈值
│
▼
脏页平衡机制让写入者睡一会儿
│
▼
速度被压到接近后端 writeback 能力
它的目标不是释放内存,而是避免 dirty page 堆积到不可控。
六、后台 writeback 线程怎么组织写回
现代内核里 writeback 按 backing device 和 writeback context 组织,核心对象常见是 bdi_writeback。你在系统里看到的 worker 描述可能像 flush-8:0、flush-nvme0n1,但具体显示受内核、workqueue 和环境影响。
源码主线在 fs/fs-writeback.c:
wb_start_background_writeback(wb)
│
└─ wb_wakeup(wb)
│
▼
wb_workfn
│
▼
wb_do_writeback
│
├─ 处理显式 work_list
├─ wb_check_start_all
├─ wb_check_old_data_flush
└─ wb_check_background_flush
│
▼
wb_writeback
│
├─ queue_io
│ └─ 把过期/需要写的 dirty inode 移到 b_io
│
├─ writeback_sb_inodes / __writeback_inodes_wb
│ └─ 按 superblock / inode 扫描
│
└─ filesystem writepages/writepage
└─ 提交到底层 I/O
把具体函数和队列拿掉,上面这条源码主线表达的是下面这个过程:
达到回写条件,或收到明确的回写请求
│
▼
唤醒负责对应后端存储的回写工作者
│
▼
确定本轮要处理的脏文件
│
├─ 显式要求写回的文件
├─ 脏了较长时间的文件
└─ 为降低整体脏页压力而选中的文件
│
▼
扫描这些文件中需要回写的 page-cache 页
│
▼
由文件系统组织并提交底层 I/O
│
▼
I/O 完成后,对应页从 dirty 恢复为 clean
几个关键词要分清:
inode:
哪个文件有脏页。
address_space:
这个文件的 page cache 和 dirty tags。
bdi_writeback:
某个 backing device / cgroup writeback 视角下的写回队列和统计。
writeback_control:
本轮写多少、范围是什么、同步还是异步、为什么写回。
writeback 不是直接扫全系统所有页。它更像:
哪些 inode 脏了?
│
▼
这些 inode 属于哪个 backing device / wb?
│
▼
对每个 inode 的 address_space,找 PAGECACHE_TAG_DIRTY 的 folio
│
▼
调文件系统 writepages/writepage
这也是为什么脏页标记不能只存在 folio flag 上,还要有 xarray tag、inode dirty 状态和 wb 账本。
七、周期性写回和过期脏页
两个常见 sysctl:
dirty_writeback_centisecs
后台回写工作者“多久醒来检查一次旧脏数据”。
常见默认值是 500,即 5 秒;
本文实验环境里是 1500,即 15 秒。
设为 0 会关闭这种周期性回写。
dirty_expire_centisecs
脏数据“多旧以后才具备周期回写资格”。
常见默认值是 3000,即 30 秒。
这两个参数都以百分之一秒为单位,但分工不同:
dirty_writeback_centisecs
决定“多久检查一次”。
dirty_expire_centisecs
决定“多旧才算这次检查的候选者”。
因此,dirty_expire_centisecs = 3000 不表示每个脏页都挂了一个 30 秒定时器,也不表示数据在第 30 秒时必然已经落盘。它表示:脏数据足够旧之后,在后台工作者下一次醒来时,才具备被选中回写的资格。
在 fs/fs-writeback.c 里,wb_check_old_data_flush() 按 dirty_writeback_interval 定期启动这类检查,queue_io() 再根据 dirty_expire_interval 挑出足够旧的 dirty inode。这里的组织线索是脏 inode 及其变脏时间,不是给每个 folio 单独设置一个过期定时器。inode 被选中后,文件系统才会进一步扫描它的脏 page-cache 页并提交 I/O。
完整的周期回写模型可以记成:
文件中的数据被修改
│
▼
页变成 dirty,inode 进入脏队列并记录时间
│
▼
后台工作者按 dirty_writeback_centisecs 周期醒来
│
▼
检查哪些脏 inode 已经足够旧
│
├─ 未达到 dirty_expire_centisecs
│ └─ 暂时留在脏队列,等下一轮检查
│
└─ 已达到 dirty_expire_centisecs
└─ 进入本轮回写,扫描脏页并提交 I/O
以常见的 5 秒检查周期和 30 秒过期时间为例,如果系统的脏页一直没有超过后台回写阈值:
t = 0
数据变脏。
t = 5s、10s、15s ...
后台工作者周期醒来;
数据还不够旧,可以暂不回写。
脏了约 30s 之后的某次检查
数据具备回写资格,被选中后才开始提交 I/O。
再往后
还要等待回写队列、文件系统和存储设备完成处理。
周期回写只是其中一条路径。其他条件可以让脏数据更早进入回写:
脏数据超过 background threshold
──► 触发或唤醒后台 writeback,不必等待数据过期
脏数据进一步接近 dirty threshold
──► 除了推动回写,还会限制写入者的速度
fsync / fdatasync / sync
──► 按各自语义主动要求回写,不必等待周期扫描
内存回收遇到脏页
──► 在允许的条件下推动回写,或等待已有回写
即使过期数据已被选中,它也可能因为工作队列拥堵、设备速度、文件系统批处理策略等因素而延后完成。回写数据页也不自动等价于已完成相关元数据、日志和设备缓存所需的全部持久化步骤。
所以,dirty_expire_centisecs 是“周期回写资格线”,不是“数据持久化截止时间”。应用需要明确的持久性边界时,仍然应该使用 fsync()、fdatasync() 等接口。
八、fsync() 和普通 writeback 的边界
fsync(fd) 的目标不是“让系统随便写点脏页”,而是对某个文件建立持久性边界。
普通 buffered write() 只需要把数据交给 page cache;fsync() 则要把“这个文件此前的修改是否已完成持久化”变成应用可以判断的结果。抽象主线是:
应用先通过 write() 或 MAP_SHARED 修改文件
│
▼
数据页变成 dirty,相关 inode 元数据也可能变脏
│
▼
应用对这个文件调用 fsync(fd)
│
▼
文件系统完成该文件所需的持久化流程
│
├─ 提交尚未开始回写的脏数据页
├─ 等待该文件相关的数据 I/O 完成
├─ 按文件系统规则处理必要的元数据和日志
├─ 必要时保证 I/O 顺序并刷新设备写缓存
└─ 汇总并检查延迟出现的 writeback error
│
▼
成功则返回 0;失败则返回 -1 并设置 errno
上面几项的具体顺序和实现由文件系统决定。例如,ext4、XFS、Btrfs 和 NFS 的 fsync() 路径并不相同;VFS 只是把请求交给对应文件系统,由它实现自己的持久化协议。
源码入口在 fs/sync.c:
fsync(fd)
│
▼
do_fsync
│
▼
vfs_fsync
│
▼
vfs_fsync_range(file, 0, LLONG_MAX, datasync=0)
│
▼
file->f_op->fsync(...)
│
└─ 文件系统实现
├─ filemap_write_and_wait_range
├─ 提交/等待数据页写回
├─ 处理 writeback error
└─ 必要时提交元数据或日志
mm/filemap.c 里的 filemap_write_and_wait_range() 会:
mapping_needs_writeback?
│
├─ yes:
│ ├─ __filemap_fdatawrite_range(..., WB_SYNC_ALL)
│ └─ __filemap_fdatawait_range(...)
│
└─ no:
直接检查错误
对照上面两段源码,通用 page-cache 部分的行为其实很直接:
这个文件还有 dirty 页?
│
├─ 有:把它们提交给 writeback
└─ 没有:不需要重复提交数据页
│
▼
这个文件还有正在 writeback 的页?
│
└─ 有:等待它们完成
│
▼
检查这段期间是否出现过 I/O 错误
│
▼
返回数据页回写结果
这只是 filemap_write_and_wait_range() 负责的数据页部分。它返回文件系统的 fsync() 实现后,文件系统还可能需要提交元数据、等待 journal transaction,或向存储设备发送 flush / barrier。
因此,普通后台 writeback 和 fsync() 的区别不是“一个会写磁盘,另一个才会写磁盘”,而是触发者、范围和完成语义不同:
后台 writeback
触发者:内核的脏页阈值、周期等机制
对象: 内核选中的一批脏 inode / 脏页
等待: 通常在后台异步进行,原写入者不等它完成
目标: 控制脏页规模,把脏页变成之后可回收的 clean page
结果: 不向某个应用承诺文件已达到持久化边界
fsync:
触发者:应用显式调用
对象: fd 指向的这个文件
等待: 调用者阻塞,直到相关持久化步骤完成或失败
目标: 建立应用可观察的持久性边界
结果: 用返回值和 errno 把成功或错误告诉应用
这里有三个容易忽略的边界。
第一,后台 writeback 可能已经抢先把数据页写完。这时 fsync() 不需要重写这些 clean page,但仍可能需要检查早先的 writeback error,并完成元数据或日志所需的持久化步骤。因此,“当前没有 dirty page”不能在应用层直接推导出“不需要 fsync()”。
第二,write() 成功后延迟出现的 I/O 错误,例如 EIO、ENOSPC 或 EDQUOT,可能在后续 fsync() 时才报告。所以需要持久性的程序不仅要调用 fsync(),还必须检查它的返回值。
第三,对普通文件调用 fsync(fd) 不一定会把包含它的目录项一起持久化。创建新文件、重命名或替换文件后,如果需要保证崩溃后文件名和目录关系也存在,还需要打开并 fsync() 相关父目录。
九、reclaim 遇到 dirty page 时到底做什么
上一篇讲过,内存回收遇到 dirty file folio 时,不能像处理 clean page 那样直接丢掉,也不会在所有场景下都亲自发起写盘。
简化路径:
内存回收扫描文件缓存页
│
▼
该页是否含有尚未写回的修改?
│
├─ 没有:在满足其他回收条件时,从 page cache 移除并释放物理页
│
└─ 有:不能直接丢弃
│
▼
该页是否已经在回写?
│
├─ 是:根据当前回收场景等待,或暂时跳过它
│
└─ 否:当前场景是否适合发起 I/O?
│
├─ 适合:推动该页开始回写
└─ 不适合:暂时保留,依赖后台 writeback,
同时继续寻找其他可回收页
│
▼
写回成功且页不再被修改
│
▼
页恢复为 clean,之后的回收才能再尝试释放它
这体现了 writeback 和 reclaim 的协作边界:
reclaim 负责筛选并释放满足条件的物理页。
writeback 负责把脏文件页变干净。
dirty / writeback page 不能当场直接释放。
它先变成 clean,并满足其他回收条件后,
reclaim 才能把它从 page cache 移除并释放物理页。
所以,reclaim 的目标和实际结果就是释放物理内存。它遇到 clean、cold 且已满足其他条件的文件页时,可以直接将其从 page cache 中移除并释放。
问题在于,如果系统里 dirty page 很多,reclaim 可能发现 LRU 尾部的候选页大都是 dirty 或正在 writeback 的页。它无法立即释放的是“这些页”,此时可以:
- 对已经在 writeback 的页,根据当前场景等待或暂时跳过。
- 对尚未回写的脏页,唤醒后台 writeback,或在合适的回收场景中发起 pageout。
- 继续扫描其他 clean cold page,并直接回收其中满足条件的页。
- 等原来的脏页写回完成、恢复为 clean 后,再在后续扫描中尝试回收它们。
这也是为什么脏页太多时,系统可能同时表现出:
write() 变慢
kswapd 活跃
flush/writeback worker 活跃
Dirty / Writeback 统计变化
direct reclaim 延迟升高
十、实验:write() 先制造脏页,fsync() 再等待写回
这个实验不用 root。它做几件事:
- 打印当前
/proc/sys/vm/dirty_*。 - 读取
/proc/meminfo的Dirty/Writeback/Cached。 - 读取
/proc/vmstat的nr_dirty/nr_writeback/nr_dirtied/nr_written。 - 向普通文件
write()256 MiB,但先不fsync()。 - 观察 dirty 统计增长。
fsync(),观察nr_written增长、Dirty回落。
完整代码如下。把它保存为当前目录下的 writeback-dirty-demo.c 后运行后面的命令:
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <sys/types.h>
#include <time.h>
#include <unistd.h>
struct vmstat_snapshot {
unsigned long nr_dirty;
unsigned long nr_writeback;
unsigned long nr_dirtied;
unsigned long nr_written;
};
struct meminfo_snapshot {
unsigned long cached_kb;
unsigned long dirty_kb;
unsigned long writeback_kb;
};
static void die(const char *msg)
{
perror(msg);
exit(1);
}
static size_t parse_mib(int argc, char **argv)
{
size_t mib = 256;
if (argc >= 2) {
char *end = NULL;
unsigned long v = strtoul(argv[1], &end, 10);
if (!v || (end && *end)) {
fprintf(stderr, "usage: %s [MiB]\n", argv[0]);
exit(2);
}
mib = (size_t)v;
}
return mib * 1024UL * 1024UL;
}
static unsigned long read_ulong_file(const char *path)
{
FILE *f = fopen(path, "r");
unsigned long v = 0;
if (!f)
return 0;
if (fscanf(f, "%lu", &v) != 1)
v = 0;
fclose(f);
return v;
}
static void read_vmstat(struct vmstat_snapshot *s)
{
FILE *f = fopen("/proc/vmstat", "r");
char key[128];
unsigned long value;
memset(s, 0, sizeof(*s));
if (!f)
die("open /proc/vmstat");
while (fscanf(f, "%127s %lu", key, &value) == 2) {
if (strcmp(key, "nr_dirty") == 0)
s->nr_dirty = value;
else if (strcmp(key, "nr_writeback") == 0)
s->nr_writeback = value;
else if (strcmp(key, "nr_dirtied") == 0)
s->nr_dirtied = value;
else if (strcmp(key, "nr_written") == 0)
s->nr_written = value;
}
fclose(f);
}
static void read_meminfo(struct meminfo_snapshot *s)
{
FILE *f = fopen("/proc/meminfo", "r");
char key[128];
unsigned long value;
char unit[32];
memset(s, 0, sizeof(*s));
if (!f)
die("open /proc/meminfo");
while (fscanf(f, "%127s %lu %31s", key, &value, unit) >= 2) {
if (strcmp(key, "Cached:") == 0)
s->cached_kb = value;
else if (strcmp(key, "Dirty:") == 0)
s->dirty_kb = value;
else if (strcmp(key, "Writeback:") == 0)
s->writeback_kb = value;
}
fclose(f);
}
static void print_snap(const char *label,
const struct vmstat_snapshot *base_vm,
const struct vmstat_snapshot *vm,
const struct meminfo_snapshot *mi)
{
printf("%-16s Dirty=%lu kB Writeback=%lu kB Cached=%lu kB "
"nr_dirty=%lu nr_writeback=%lu "
"nr_dirtied_delta=%lu nr_written_delta=%lu\n",
label,
mi->dirty_kb,
mi->writeback_kb,
mi->cached_kb,
vm->nr_dirty,
vm->nr_writeback,
vm->nr_dirtied - base_vm->nr_dirtied,
vm->nr_written - base_vm->nr_written);
}
static double now_seconds(void)
{
struct timespec ts;
if (clock_gettime(CLOCK_MONOTONIC, &ts) < 0)
die("clock_gettime");
return ts.tv_sec + ts.tv_nsec / 1000000000.0;
}
static void full_write(int fd, const void *buf, size_t len)
{
const char *p = buf;
while (len) {
ssize_t n = write(fd, p, len);
if (n < 0)
die("write");
p += n;
len -= (size_t)n;
}
}
int main(int argc, char **argv)
{
size_t len = parse_mib(argc, argv);
const size_t chunk = 1024 * 1024;
char path[] = "/tmp/writeback-demo-XXXXXX";
unsigned char *buf;
int fd;
struct vmstat_snapshot vm0, vm1, vm2, vm3;
struct meminfo_snapshot mi0, mi1, mi2, mi3;
double t1, t2;
printf("writeback dirty-page demo\n");
printf("size=%zu MiB\n", len / 1024 / 1024);
printf("dirty_background_ratio=%lu dirty_ratio=%lu "
"dirty_background_bytes=%lu dirty_bytes=%lu\n",
read_ulong_file("/proc/sys/vm/dirty_background_ratio"),
read_ulong_file("/proc/sys/vm/dirty_ratio"),
read_ulong_file("/proc/sys/vm/dirty_background_bytes"),
read_ulong_file("/proc/sys/vm/dirty_bytes"));
printf("dirty_writeback_centisecs=%lu dirty_expire_centisecs=%lu\n",
read_ulong_file("/proc/sys/vm/dirty_writeback_centisecs"),
read_ulong_file("/proc/sys/vm/dirty_expire_centisecs"));
fd = mkstemp(path);
if (fd < 0)
die("mkstemp");
printf("file=%s\n", path);
buf = malloc(chunk);
if (!buf)
die("malloc");
for (size_t i = 0; i < chunk; i++)
buf[i] = (unsigned char)(i * 17u + 3u);
read_vmstat(&vm0);
read_meminfo(&mi0);
print_snap("before", &vm0, &vm0, &mi0);
t1 = now_seconds();
for (size_t written = 0; written < len; ) {
size_t n = len - written < chunk ? len - written : chunk;
full_write(fd, buf, n);
written += n;
}
t2 = now_seconds();
printf("write() returned after %.3f s, without fsync yet\n", t2 - t1);
read_vmstat(&vm1);
read_meminfo(&mi1);
print_snap("after write", &vm0, &vm1, &mi1);
sleep(1);
read_vmstat(&vm2);
read_meminfo(&mi2);
print_snap("after 1s", &vm0, &vm2, &mi2);
t1 = now_seconds();
if (fsync(fd) < 0)
die("fsync");
t2 = now_seconds();
printf("fsync() returned after %.3f s\n", t2 - t1);
read_vmstat(&vm3);
read_meminfo(&mi3);
print_snap("after fsync", &vm0, &vm3, &mi3);
free(buf);
if (close(fd) < 0)
die("close");
if (unlink(path) < 0)
die("unlink");
return 0;
}
我在 linux/amd64 容器里跑过,容器内 uname -m 是 x86_64:
docker run --rm --platform linux/amd64 \
-v "$PWD:/work" -w /work \
alpine:3.20 sh -lc '
set -e
apk add --no-cache build-base >/dev/null
uname -m
gcc -O2 -Wall -Wextra -std=c11 \
writeback-dirty-demo.c \
-o /tmp/writeback-dirty-demo
/tmp/writeback-dirty-demo 256
'
真实输出:
x86_64
writeback dirty-page demo
size=256 MiB
dirty_background_ratio=10 dirty_ratio=20 dirty_background_bytes=0 dirty_bytes=0
dirty_writeback_centisecs=1500 dirty_expire_centisecs=3000
file=/tmp/writeback-demo-nAfifp
before Dirty=222176 kB Writeback=0 kB Cached=622048 kB nr_dirty=55544 nr_writeback=0 nr_dirtied_delta=0 nr_written_delta=0
write() returned after 0.036 s, without fsync yet
after write Dirty=484148 kB Writeback=0 kB Cached=884020 kB nr_dirty=121037 nr_writeback=0 nr_dirtied_delta=65493 nr_written_delta=0
after 1s Dirty=484320 kB Writeback=0 kB Cached=884192 kB nr_dirty=121080 nr_writeback=0 nr_dirtied_delta=65536 nr_written_delta=0
fsync() returned after 0.115 s
after fsync Dirty=222176 kB Writeback=0 kB Cached=884180 kB nr_dirty=55544 nr_writeback=0 nr_dirtied_delta=65536 nr_written_delta=65536
这组输出要看 delta,不要只看绝对值。容器里的 /proc/meminfo 和 /proc/vmstat 是内核视角,运行前宿主 VM 里已经有 Dirty=222176 kB。关键变化是:
写入 256 MiB = 65536 个 4KB 页
after write:
nr_dirtied_delta ≈ 65493
nr_written_delta = 0
Dirty 从 222176 kB 增到 484148 kB
after 1s:
nr_dirtied_delta = 65536
nr_written_delta = 0
说明这一秒内后台还没把这批页写掉
after fsync:
nr_written_delta = 65536
nr_dirty 回到基线
Dirty 回到基线
这正好对应源码路径:
write()
│
├─ 数据进入 page cache
├─ folio_mark_dirty
├─ nr_dirtied 增长
└─ write() 返回,nr_written 暂不增长
fsync()
│
├─ vfs_fsync
├─ file filesystem fsync
├─ filemap_write_and_wait_range
├─ writeback dirty folios
└─ nr_written 增长,Dirty 回落
为什么 after 1s 还没写?这台环境里:
dirty_writeback_centisecs = 1500
也就是周期性 writeback 间隔是 15 秒,而实验只等了 1 秒;同时 256 MiB 脏页低于该环境的 background threshold,所以没有立刻触发明显后台写回。不同机器上,如果后台 writeback 更积极、磁盘策略不同、dirty 阈值更低,你可能在 after 1s 就看到 nr_written_delta 增长。
十一、把实验和内核路径对上
实验里的每一步对应到内核结构:
mkstemp / write 256 MiB
│
▼
page cache folio
│
├─ address_space->i_pages[index]
├─ folio dirty
├─ PAGECACHE_TAG_DIRTY
├─ inode I_DIRTY_PAGES
└─ wb 统计增长
/proc/vmstat
│
├─ nr_dirtied 增长
├─ nr_dirty 增长
└─ nr_written 还不增长
fsync
│
▼
file->f_op->fsync
│
├─ 写回 dirty page cache
├─ 等待 I/O 完成
└─ 更新 writeback 统计
/proc/vmstat
│
├─ nr_written 增长
└─ nr_dirty 回落
这个实验没有强行触发 reclaim。它只证明 writeback 的基本事实:write() 先制造 dirty page,fsync() 才等待它们写回。上一篇的 reclaim 逻辑建立在这个事实之上:dirty file page 在写回前不能按 clean file page 丢弃。
十二、容器里观察 writeback 要小心
容器实验有几个边界。
第一,/proc/meminfo 和 /proc/vmstat 通常是宿主内核或 Docker VM 的全局视角,不是单个容器的完全私有统计。要看本实验效果,优先看运行前后的 delta。
第二,Docker Desktop、overlayfs、虚拟磁盘、宿主文件系统会影响 writeback 时机。这个实验使用容器内 /tmp 临时文件,避免把测试文件写到 bind mount 目录,但底层仍是 Docker VM 的文件系统。
第三,Writeback 瞬时值可能一直是 0。这不表示没有写回,而是采样时没有抓到“正在 writeback”的页;nr_written_delta 更适合看累计写回。
第四,写入量如果太小,背景噪声会淹没结果;写入量如果太大,可能触发 balance_dirty_pages()、后台 writeback 或 cgroup 限制,输出会更复杂。256 MiB 在这台环境里刚好能清楚看到 nr_dirtied 和 nr_written 的对应关系。
十三、常见误解
误解一:write() 返回就说明数据已经在磁盘上。
不是。普通 buffered write 通常只是把数据复制进 page cache 并标脏。需要持久性边界时要用 fsync()、fdatasync() 或同步写语义。
误解二:writeback 会释放内存。
writeback 把 dirty page 写成 clean page。释放内存要靠 reclaim 后续把 clean folio 从 page cache 移除。
误解三:reclaim 遇到 dirty page 就直接写盘。
不总是。很多情况下 reclaim 会标记、跳过、等待或依赖 flusher。kswapd 在特定条件下可以 pageout,但 direct reclaim 不应随便陷入文件系统写回。
误解四:Dirty 高就一定马上 OOM。
不是。Dirty 高说明有较多尚未写回的文件页。它可能导致写入者被限速、writeback 活跃、reclaim 成本升高,但 OOM 还取决于可回收页、匿名页、swap、memcg、分配类型等。
误解五:sync、fsync、writeback、reclaim 是同一个动作。
不是。sync/fsync 是用户显式要求写回和等待的接口;writeback 是内核把脏页写回;reclaim 是释放内存。三者会互相配合,但目标不同。
十四、收束
这一篇需要记住的主线:
buffered write 先写 page cache。
dirty 标记存在于 folio、xarray tag、inode、wb 统计多层结构里。
dirty_background_* 触发后台 writeback。
dirty_* 上限附近,balance_dirty_pages 会让写入者睡眠限速。
flusher/writeback worker 按 bdi_writeback、inode、address_space
找 dirty folio,并调用文件系统 writepages/writepage。
fsync 是用户要求的持久性边界。
reclaim 需要 clean page;
dirty page 要先通过 writeback 变 clean,才可能被释放。
最后用一张图收束:
write / MAP_SHARED store
│
▼
page cache folio dirty
│
├─ PAGECACHE_TAG_DIRTY
├─ inode I_DIRTY_PAGES
└─ wb dirty accounting
│
▼
dirty thresholds
│
├─ below background -> 暂时缓存
├─ above background -> wake writeback
└─ above throttle zone -> balance_dirty_pages 限速写入者
│
▼
writeback
│
├─ background flusher
├─ fsync / sync
└─ reclaim pageout in selected cases
│
▼
dirty folio becomes clean
│
▼
reclaim can remove clean folio from page cache
│
▼
physical page returns to buddy
下一篇番外六会离开普通页 LRU,进入 shrinker:dentry、inode、文件系统缓存、可回收 slab 对象不是普通匿名页/文件页,内核为什么还可以在内存压力下缩它们。
参考源码
本文源码判断基于 Linux stable v6.12.65:
mm/filemap.c:generic_perform_write()mm/memory.c:do_shared_fault()/fault_dirty_shared_page()mm/page-writeback.c:domain_dirty_limits()/ dirty sysctlmm/page-writeback.c:balance_dirty_pages()/balance_dirty_pages_ratelimited()mm/page-writeback.c:folio_account_dirtied()/__folio_mark_dirty()/filemap_dirty_folio()/folio_mark_dirty()fs/fs-writeback.c:wb_start_background_writeback()/queue_io()/wb_writeback()/wb_workfn()mm/filemap.c:filemap_fdatawrite_wbc()/filemap_write_and_wait_range()fs/sync.c:vfs_fsync_range()/fsync()mm/vmscan.c:shrink_folio_list()/pageout()