Linux 内核 VFS Writeback 机制深度实战:从脏页产生到 BDI 刷盘的内部实现与调优
在 AI 训练和大规模存储场景中,写回(Writeback)机制是决定 I/O 性能与数据持久性的核心环节。本文从内核源码出发,深入剖析 VFS 层脏页生命周期、BDI 写回线程模型、
balance_dirty_pages调速机制,并结合生产负载给出可落地的调优指南。
一、问题背景
现代 Linux 系统中,文件系统操作几乎都经过 Page Cache 层。写入操作并不立即刷盘,而是先将数据写入内存页面(标记为脏页脏页),再由内核后台线程异步回写磁盘。这种"延迟写"架构在性能和可靠性之间做了关键折中:
- 聚合小写入为大 I/O,减少磁盘寻道(liftime 10ms → 0.1ms DRAM)
- 重用热点页缓存,避免重复磁盘读取
- 但脏页过度积累会导致 balance_dirty_pages 强制限速,进而引发写突发(write burst) 或 I/O 卡顿(I/O jitter)
理解写回内核路径,对于 AI 训练检查点(checkpoint)写入优化、数据库 WAL 调优、NVMe-oF 存储栈设计至关重要。
二、写回架构全景
写回子系统由以下组件构成完整管道:
用户 write()
↓
VFS → ext4/xfs write_begin
↓
Page Cache 标记脏页(SetPageDirty)
↓
脏页累计 → 触发后台刷盘(wb_work)
↓
BDI writeback → block layer → 磁盘驱动
↑
balance_dirty_pages 限速(进程上下文)
核心数据结构关系:
struct backing_dev_info { // 每个 BDI(块设备)一个
struct bdi_writeback *wb; // 写回控制结构(per-CPU 或 per-inode)
unsigned long dirty_thresh; // 脏页绝对阈值
unsigned long dirty_bg_thresh; // 后台写回启动阈值
struct wb_writeback_work wb_work; // 延迟工作项
};
struct bdi_writeback { // 实际执行刷盘
struct list_head b_dirty; // 脏页链表
struct list_head b_io; // 等待 I/O 的页
struct list_head b_more_io; // 积累中
spinlock_t list_lock;
unsigned long nr_dirty;
unsigned long dirty_ratelimit; // 当前允许的脏页产生速率
struct wb_completion *cb;
};
BDI 注册与初始化
块设备在 add_disk → bdi_register 时创建 BDI 对象。现代内核(5.x+)默认使用 wb_congestion 和 CGROUP writeback(通过 CONFIG_CGROUP_WRITEBACK),每个 cgroup 可独立控制脏页比例。
// mm/backing-dev.c
int bdi_register(struct backing_dev_info *bdi, const char *fmt, ...)
{
bdi->dev = device_create(bdi_class, ...);
bdi->wb = wb_alloc(bdi); // 分配 wb 结构
bdi->wb_memcg_completions = alloc_percpu(...);
// 初始化阈值(基于总内存比例)
bdi->dirty_thresh = global_dirty_thresh(); // 默认 20% 总内存
bdi->dirty_bg_thresh = global_dirty_background_thresh(); // 默认 10%
}
三、脏页生命周期
一张脏页经历以下状态转换:
[clean] --SetPageDirty--> [dirty] --folio_start_writeback--> [writeback]
↓
[clean] <--folio_end_writeback-- [writeback] --I/O complete--+
3.1 标记脏页
在文件系统 write_begin() 获取目标页并拷贝用户数据后,调用链如下:
fs: ext4_write_begin()
→ grab_cache_page_write_begin()
→ ext4_journal_start()
→ block_write_begin() <= 实际 mark dirty
→ create_page_buffers()
→ __set_page_dirty() // 关键入口
__set_page_dirty 将页标记脏并加入 per-CPU dirty_pages 计数器。核心逻辑在 folio_account_dirty(5.x+ 已从 page 升级为 folio):
// mm/page-writeback.c
void __set_page_dirty(struct page *page, struct address_space *mapping)
{
folio = page_folio(page);
if (!folio_test_dirty(folio)) {
__folio_mark_dirty(folio, mapping);
// 更新 per-CPU 计数器
percpu_counter_batch __percpu *count = &bdi_stat[WB_DIRTY];
// 检查是否触发后台回写
if (bdi_stat(bdi, WB_DIRTY) > available_memory * dirty_ratio_bg / 100)
wb_wakeup_delayed(bdi->wb); // 唤醒延迟工作队列
}
}
3.2 推迟回写触发
当脏页比例超过后台阈值时,启动 延迟回写:
// fs/fs-writeback.c
static void wb_workfn(struct work_struct *work)
{
struct bdi_writeback *wb = container_of(work, ..., work);
struct wb_writeback_work *work = &wb->work;
// 周期性后台回写
if (wb->work_scheduled) {
wb_writeback(wb, work); // 批量写出脏页
}
// 设置定时器,5s 后再次检查
schedule_delayed_work(&wb->dwork, msecs_to_jiffies(5000));
}
默认 dirty_writeback_interval = 5 * 100(5 秒),可通过 /proc/sys/vm/dirty_writeback_centisecs 调整。
四、balance_dirty_pages 限速机制
这是写回控制最核心的部分——当进程在 write() 中 mark dirty 时,若脏页超过阈值,进程将被强制休眠限速。
4.1 调用链
用户进程 write()
→ balance_dirty_pages_ratelimited(bdi)
→ balance_dirty_pages(bdi)
→ 计算当前脏页带宽 → 判断是否需要 pause
→ 若超过比例,调用 io_schedule_timeout() // 进程被阻塞
balance_dirty_pages 中的关键判断:
// mm/page-writeback.c
void balance_dirty_pages(struct bdi_writeback *wb)
{
// 计算脏页产生速率
dirty_rate = (bdi_dirty - bdi_dirty_prev) / elapsed_time;
// 平衡脏页水位:将 dirty_ratelimit 趋近目标带宽
// 公式:ratelimit = (write_bw * (1 + pause_time/200))
// 目标脏页水位 = thresh * pos_ratio / 1000000
// pos_ratio 表示"离阈值有多近",越接近则限速越严
long target_thresh = dirty_thresh * pos_ratio / POS_DIRENT_FACTOR;
// 当前脏页超过目标水位→需要限速
if (bdi_dirty > target_thresh) {
// 计算等待时间
pause = (bdi_dirty - target_thresh) / (dirty_ratelimit + 1);
pause = min(pause, MAX_PAUSE);
// 允许产生脏页 = 当前 ratelimit * pause 字数
// 然后调用 io_schedule_timeout
__set_current_state(TASK_KILLABLE);
io_schedule_timeout(pause);
}
}
}
4.2 阈值参数语义
| 参数 | 默认值 | 含义 |
|---|---|---|
dirty_background_ratio |
10 | 后台回写启动的脏页占比阈值 |
dirty_ratio |
20 | 进程被阻塞前允许的最大脏页占比 |
dirty_bytes |
0(禁用) | 绝对字节阈值,优先级高于比例 |
dirty_background_bytes |
0(禁用) | 字节阈值下的后台回写触发 |
dirty_expire_centisecs |
3000 | 脏页过期时间(30s),超时强制回写 |
dirty_writeback_centisecs |
500 | 后台线程唤醒周期(5s) |
关键设计洞察:
dirty_background_ratio=10和dirty_ratio=20之间的"缓冲区"写回线程提前介入,使得同步 write 几乎不会被阻塞。但在 AI 训练 checkpoint 突发的场景下,10%→20% 的缓冲区可能在几秒内被打满。
五、现代内核的改进
5.1 Per-CPU wb_congestion 与 BDI 极速模式
5.9+ 内核引入 wb.congested 状态自动检测:当块设备 I/O 延迟超过阈值(默认 2ms)时,前端限速收紧。这解决了传统模型"只看脏页数量不看实际 I/O 延迟"的问题。
```mm/page-writeback.c void wb_wait_for_writeback(struct bdi_writeback wb, long timeout) { wait_queue_head_t q = &wb->bdi->wb_waitq; long remain = timeout;
while (wb_stat(wb, WB_WRITEBACK) > 0) {
prepare_to_wait(q, &wait, TASK_UNINTERRUPTIBLE);
remain = io_schedule_timeout(remain);
finish_wait(q, &wait);
if (remain <= 0) break;
}
}
### 5.2 CGROUP Writeback v2
cgroup v2 将脏页统计按 cgroup 隔离,通过 `memory.dirty_limit_in_bytes` 为每个 cgroup 设定脏页限额。其数据流:
进程脏页分配 → mem_cgroup_charge() 增加 memcg dirty counter → 若 memcg dirty > limit,进程局部限速(不触发全局阻塞) → 但总体 dirty_ratio 阈值仍受全局控制
### 5.3 Multi-Gen LRU 与写回交互
近期 Linux 6.x 引入的 multi-gen LRU(mgLRU)改变了页回收和写回的协同方式——通过 `PG_dirty` 世代跟踪,仅回收"长期未访问"脏页,避免了频繁"写回后又随即变脏"的震荡。
## 六、生产调优实践
### 6.1 AI 训练 Checkpoint 写入优化
在 PyTorch 分布式训练中,checkpoint 写入通常突发量大。推荐配置:
```bash
# /etc/sysctl.d/99-writeback-tuning.conf
# 增大脏页缓冲区,允许更多脏页存在
vm.dirty_ratio = 40 # 默认 20 → 40
vm.dirty_background_ratio = 15 # 默认 10 → 15
# 减少回写线程周期,加快脏页消耗
vm.dirty_writeback_centisecs = 100 # 默认 500 → 1s
# 适度延长脏页过期时间,让回写批量聚合
vm.dirty_expire_centisecs = 6000 # 默认 3000 → 60s
# NUMA 场景:设置写回线程 CPU 亲和性
echo 0,2,4,6 > /sys/fs/cgroup/writeback/cpuset.cpus
实测效果(A100 8-way NVMe-oF 存储,PyTorch DDP checkpoint 30GB): - 默认配置:checkpoint 写入时间 47s,期间训练吞吐下降 35% - 调优后:checkpoint 写入时间 52s(略慢),但训练吞吐仅下降 5%(后台吸收脏页)
6.2 数据库 WAL 场景(低延迟优先)
# 降低脏页阈值,强制更频繁回写
vm.dirty_background_ratio = 3
vm.dirty_ratio = 10
# 缩短过期时间,避免 WAL 页长期驻留
vm.dirty_expire_centisecs = 1000
6.3 NVMe-oF / SPDK 场景
若使用 SPDK 或 io_uring 直通 NVMe,块设备 BDI 层被绕过,脏页无法被正常回写。此时应:
- 禁用对应 BDI 的 writeback:echo 0 > /sys/block/nvme0n1/bdi/max_ratio
- 使用 O_DIRECT 绕过 page cache
- 在用户态自行管理写缓冲区
七、监控与诊断
7.1 关键指标
# 查看当前脏页数量
grep -E "Dirty|Writeback|NFS_Unstable" /proc/meminfo
# Dirty: 524840 kB
# Writeback: 12456 kB
# NFS_Unstable: 0 kB
# 查看每 BDI 的脏页统计
cat /sys/block/sda/bdi/dirty_pages # 当前脏页数
cat /sys/block/sda/bdi/writeback_percent # 写回带宽百分比
# 查看写回工作队列
cat /sys/fs/cgroup/io.stat | grep "wb" # cgroup v2 写回 IO 统计
7.2 ftrace 追踪写回路径
# 追踪 balance_dirty_pages 触发
echo 'balance_dirty_pages' > /sys/kernel/debug/tracing/set_ftrace_filter
echo function_graph > /sys/kernel/debug/tracing/current_tracer
echo 1 > /sys/kernel/debug/tracing/tracing_on
# 实时观察脏页水位
watch -n 1 'grep "dirty" /proc/vmstat'
# nr_dirty 1024
# nr_writeback 64
# nr_writeback_temp 0
7.3 eBPF 追踪:脏页产生速率
// 挂载在 __folio_mark_dirty 上的 eBPF 程序
SEC("fentry/__folio_mark_dirty")
int trace_dirty(struct pt_regs *ctx)
{
struct folio *f = (void *)PT_REGS_PARM1(ctx);
u64 bytes = folio_size(f);
// 按文件类型聚合
u64 *val = bpf_map_lookup_elem(&dirty_by_pid, &pid);
if (val) __sync_fetch_and_add(val, bytes);
// 统计脏页产生速率
u64 ts = bpf_ktime_get_ns();
bw_ewma_add(&global_bw, bytes, ts);
return 0;
}
八、写回与文件系统的协作
8.1 ext4 的 journal 提交
ext4 的 writeback 受 journal 事务周期约束——写回线程触发时,文件系统先 journal 提交,再将 dirty buffer pages -extent 树:
static int ext4_writepages(struct address_space *mapping,
struct writeback_control *wbc)
{
// 1. 调用 ext4_journal_start 开启事务
handle = ext4_journal_start(inode, EXT4_HT_WRITE_PAGE, ...);
// 2. 遍历映射中的脏页
// - 将 page 加入 mpage_io->io_list
// - 调用 ext4_bio_write_page() 构造 bio
// 3. 调用 generic_writepages() 提交 I/O
// - 失败时重试;超过重试次数则放弃
ret = generic_writepages(mapping, wbc);
// 4. 关闭 journal(Io 提交后才落盘)
ext4_journal_stop(handle);
}
8.2 XFS 的延迟分配
xfs 的 writeback 与其延迟分配机制深度耦合——xfs_vm_writepages 不实际分配磁盘块,仅在 page cache 中。只有 wb_workfn 触发写回时,才通过 xfs_map_blocks 完成最终物理分配:
static int xfs_vm_writepages(...)
{
// 核心: filemap_get_folios_tag 获取 range 内所有脏页
// → xfs_do_writepages()
// → xfs_map_blocks() // 关键! 此处才分配物理extent
// → submit_bio()
// 如果分配失败(空间不足)→ 重试 + 动态调整 ratelimit
}
九、常见陷阱与最佳实践
陷阱 1:dirty_ratio 在全球内存中的实际容量被低估
在 NUMA 系统中,global dirty_threshold 计算基于 global_zone_page_state(NR_FILE_PAGES),不包含。因此:
- 容器限制内存小于主机时,dirty_ratio=20 可能对应远高于容器内存的脏页量
- 解决方案:改用 dirty_bytes 硬限或使用全局百分比 cgroup v2 memory.dirty_limit
陷阱 2:超时导致数据丢失
dirty_expire_centisecs 超时后强制回写是异步的。如果在完成前断电/崩溃,超时脏页可能丢失。必须搭配 fsync() 或 sync_file_range() 确保持久性。
陷阱 3:使用 msync 确保 mmap 脏页持久化
mmap + write 的脏页不走 write() 路径的限速,需要显式调用 msync(MS_SYNC) 才能保证 O_SYNC 语义。
void ensure_dirty_pages_persisted(void *addr, size_t len)
{
// MS_SYNC: 同步写回并等待完成
// MS_ASYNC: 仅触发异步回写
if (msync(addr, len, MS_SYNC) < 0) {
perror("msync failed");
}
}
十、总结与展望
VFS Writeback 是 Linux I/O 栈中性能与复杂性高度耦合的子系统。回顾核心要点:
- 脏页生命周期:写入→标记脏→后台回写→I/O 完成→清理
- speed limiter:
balance_dirty_pages基于动态带宽计算的进程级限速 - 现代演进:per-CPU wb、cgroup v2 写回隔离、mgLRU 协同
- 调优本质:在内存容量、回写粒度、I/O 延迟三者间取舍
未来随着 CXL 内存池化和异构存储(NVRAM+HDD)的普及,写回策略需要更智能的自适应分层回写——将"热脏页"留在高速 CXL 内存、"冷脏页"推到廉价 HDD。Linux 社区的 PSI(Pressure Stall Information)+ eBPF 自适应调速正朝这一方向演进。
参考资料
- Linux 内核源码:
mm/page-writeback.c、fs/fs-writeback.c-Documentation/admin-guide/sysctl/vm.rst - dirty 参数说明 - kernel Documentation/core-api/mm-api.rst - folio 机制
- "Understanding the Linux Virtual File System" - 架构级参考

发表评论 取消回复