writeback:脏页什么时候写回磁盘

20 阅读26分钟

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。它做几件事:

  1. 打印当前 /proc/sys/vm/dirty_*。
  2. 读取 /proc/meminfo 的 Dirty / Writeback / Cached。
  3. 读取 /proc/vmstat 的 nr_dirty / nr_writeback / nr_dirtied / nr_written。
  4. 向普通文件 write() 256 MiB,但先不 fsync()。
  5. 观察 dirty 统计增长。
  6. 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: