一、为什么写回机制是 I/O 栈的"最后一公里"?
在我们之前深入剖析了 Linux 内存管理、页面缓存与回收 以及 块设备层 blk-mq 架构 之后,有一个关键桥梁始终未被展开:脏页写回(Writeback)机制。
当应用程序调用 write() 或 mmap() 修改文件时,内核并不会立即将数据写入磁盘,而是标记页面为"脏"(dirty),然后由后台线程在合适的时机异步回写。这个看似简单的"延迟写入"机制,实际上涉及:
- 脏页产生与传播:page cache 脏标记、mmap redirty 逻辑、文件系统日志回写
- BDI 管理架构:Backing Device Info 结构、per-BDI 注册与生命周期
- 回写线程演进:从 pdflush 到 BDI-flusher 到 wb_workqueue,三代架构变迁
- 回写策略控制:dirty_ratio、dirty_background_ratio、dirty_expire_centisecs、dirty_writeback_centisecs 四大参数联动
- 回写工作流:wb_workfn → writeback_single_inode → do_writepages 全链路
- 性能抖动诊断:direct reclaim 风暴、writeback 拥塞、BDI 饥饿、IOосаждение
本文将从内核源码级别,结合实战调优、eBPF 观测、生产案例分析,彻底拆解 Linux 写回机制。
二、脏页生命周期:从 write() 到 Writeback 的完整路径
2.1 脏页产生——文件写入的时间线
当一个普通 write() 系统调用发生时,数据从用户空间到被标记为脏页的路径如下:
用户态 write(buf, 4096)
→ sys_write()
→ vfs_write()
→ new_sync_write()
→ call_write_iter() // 文件系统的 write_iter
→ ext4_file_write_iter() // 以 ext4 为例
→ generic_perform_write()
→ a_ops->write_begin() // 准备页缓存页面
→ copy_from_user() // 拷贝数据
→ a_ops->write_end() // 准备页缓存页面
→ __set_page_dirty() // 标记 PG_dirty ⭐
__set_page_dirty() 是脏页产生的核心入口。它做了什么?
// mm/page-writeback.c: 核心脏页标记
static void __set_page_dirty(struct page *page, struct address_space *mapping)
{
// 1. 设置 PG_dirty 标志位
SetPageDirty(page);
// 2. 将 inode 添加到 per-BDI 的 b_dirty 链表
if (!TestSetPageDirty(page)) {
// 脏计数递增(percpu_counter)
__inc_node_page_state(page, NR_FILE_DIRTY);
__inc_zone_page_state(page, NR_ZONE_WRITE_PENDING);
// 将 inode 加入 b_dirty 列表
// 每个 b_dirty inode 仅被加入一次
mark_inode_dirty(mapping->host);
}
}
关键数据结构:每个 inode 通过 bdi_writeback 结构挂载到所属 BDI 的三个链表之一:
bdi_writeback
├── b_dirty // 有脏页尚未开始回写的 inode 链表
├── b_io // 正在回写中的 inode 链表 (writeback_inodes_wb)
└── b_more_io // 当前周期回写时又产生新脏页的 inode(用于下一轮处理)
2.2 mmap 脏页的特殊路径
对于 mmap 映射产生的脏页,路径完全不同——它通过缺页异常触发:
CPU 访问 mmap 只读页 → #PF 缺页异常
→ do_page_fault()
→ handle_mm_fault()
→ handle_pte_fault()
→ do_wp_page() // 写时复制
→ pte_mkdirty() // 设置页表 Dirty 位
→ SetPageDirty(page) // 标记 PG_dirty(延迟到刷回时)
注意 mmapped 脏页的特殊性:
- 页表 PTE 的 Dirty 位由 CPU 自动设置
- 内核的 PG_dirty 标志通过 folio_mark_dirty_for_io() 在 page writeback 时由反向映射(reverse mapping)确认后设置
- msync() 系统调用可以强制刷回 mmap 脏页
2.3 四大核心参数:决定脏页何时开始回写
Linux 通过 /proc/sys/vm/ 下的四个参数控制回写行为:
| 参数 | 默认值(典型) | 含义 |
|---|---|---|
| dirty_background_ratio | 10 | 脏页占总内存达到此比例时,后台回写启动(异步) |
| dirty_background_bytes | 0 | 与 ratio 互斥,字节级控制 |
| dirty_ratio | 20 | 脏页占总内存达到此比例时,进程被阻塞同步刷回(同步) |
| dirty_bytes | 0 | 与 ratio 互斥,字节级控制 |
| dirty_expire_centisecs | 3000(30秒) | 脏页过期时间,超过此时长的脏页标记为回写候选 |
| dirty_writeback_centisecs | 500(5秒) | wb_writeback 线程唤醒周期 |
参数优先级:bytes 系列优先于 ratio 系列。如果设置了 dirty_bytes 非零,则 dirty_ratio 被忽略。
典型的生产环境推荐值(16GB 内存数据库服务器):
# 高写入负载场景:延缓同步阻塞,减少突发延迟
dirty_background_ratio = 5 # 800MB 即开始后台回写
dirty_ratio = 10 # 1.6GB 触发同步阻塞
dirty_expire_centisecs = 3000
dirty_writeback_centisecs = 1000 # 更频繁的巡检
三、BDI 架构:backing_device_info 深度解析
3.1 BDI 的诞生与设计目标
在 Linux 2.6 之前,pdflush 线程数量为固定 2~8 个,所有设备共享这些线程。这导致高速 NVMe 和低速 USB 共用同一回写队列,互相拖慢——这被称为"BDI 饥饿"问题。
Linux 2.6 引入了 per-BDI flusher 概念(commit),每个块设备分配独立的 backing_dev_info 结构:
// include/linux/backing-dev.h
struct backing_dev_info {
u64 ra_pages; // 预读窗口大小
unsigned long io_pages; // 正在等待的 I/O 页数
struct bdi_writeback wb; // ⭐ 核心:per-cpu 回写状态
struct list_head b_dirty; // 脏 inode 列表
struct list_head b_io; // 正在回写的 inode 列表
struct list_head b_more_io; // 待下一轮处理的 inode 列表
spinlock_t list_lock; // 三个链表的锁
unsigned long last_old_flush; // 上次过期检查时间 jiffies
struct wb_writeback_work work; // 回写工作项
struct device *dev; // 关联设备
char bdi_id[BDI_CAPACITY_NAME]; // BDI 标识符
unsigned long min_ratio; // 最小回写比例
unsigned long max_ratio; // 最大回写比例
unsigned long max_prop_frac; // 最大比例分数
struct bdi_writeback_congested *wb_congested; // 拥塞状态
} ____cacheline_aligned_in_smp;
3.2 BDI 注册流程
BDI 的注册在块设备挂载或设备驱动 probe 时发生:
// 驱动调用典型路径:
blk_alloc_queue()
→ bdi_alloc() // 分配 backing_dev_info
→ bdi_register() // 注册 BDI 到全局 bdi_list
→ device_add(&bdi->dev) // 注册为设备模型中的设备
→ sysfs 创建 /sys/class/bdi/<device>/ 属性文件
每个块设备在 sysfs 中暴露的属性:
$ ls /sys/class/bdi/8:0/
max_ratio # 回写带宽上限比例
min_ratio # 回写带宽下限比例
dirty_limit # 脏页限制
read_ahead_kb # 预读窗口(KB)
stats/ # 统计目录
├── BdiReclaimable # 可回收回写页数
├── BdiDirty # 当前脏页数
├── BdiWriteback # 正在回写页数
├── BdiWritten # 累计已回写页数
└── avg_write_bandwidth # 平均写带宽
四、三代回写线程架构演进
4.1 第一代:pdflush(Linux 2.4/2.6 早期)
pdflush(page dirty flush)是最早的回写线程方案。内核创建 2~8 个 pdflush 线程,扫描整个系统所有设备的脏页:
// mm/pdflush.c(已移除的代码)
static int pdflush()
{
// 每个 pdflush 线程循环执行
for (;;) {
// 遍历全局 inode_unused 链表
// 找到脏 inode 后调用 writeback_inode()
// 通过 balance_dirty_pages_ratelimited() 控制速率
}
}
致命问题:
- 所有设备共享 pdflush 线程池
- 无法做 NUMA 感知的 CPU 绑定
- 高速设备和低速设备相互干扰
- 线程数量动态调整算法不合理
4.2 第二代:BDI-flusher per-device(Linux 2.6.32+)
2.6 内核引入 per-BDI 机制,每个有脏页的 BDI 分配一个 bdi_writeback 结构:
// 每个 BDI 的回写线程:flush-<major>:<minor>
// 例如:flush-8:0(sda),flush-8:16(sdb)
struct task_struct *bdi->wb.task; // per-BDI 回写线程
优势:每个磁盘独立线程,消除了设备间干扰。但新问题:
- 每多一个块设备就多一个线程,数百个 LVM 逻辑卷时线程量爆炸
- 线程创建/销毁开销大
- 空闲设备的 pdflush 线程空转消耗 CPU
4.3 第三代:wb_workqueue workqueue 化(Linux 5.0+)
Linux 5.0+ 彻底重写了回写线程管理,引入 wb_workqueue 机制:
// fs/fs-writeback.c: 核心数据结构
static struct workqueue_struct *bdi_wq; // 核心回写 workqueue
struct bdi_writeback {
struct backing_dev_info *bdi; // 所属 BDI
unsigned long last_switch_time; // 上次切换时间
struct wb_writeback_work work; // 回写工作项
struct list_head b_dirty; // 脏 inode 链表
struct list_head b_io; // I/O 中 inode 链表
struct list_head b_more_io; // 待处理 inode 链表
spinlock_t work_lock; // 工作项锁
unsigned long dirtied_stamp; // 脏页产生时间戳
unsigned long written_stamp; // 回写完成时间戳
unsigned long write_bandwidth; // 当前写带宽估算
unsigned long avg_write_bandwidth; // EWMA 平均写带宽
struct delayed_work dwork; // ⭐ 延迟调度(替代固定线程)
struct percpu_counter stat[NR_WB_STAT_ITEMS]; // 统计计数器
};
核心变化:回写线程由 workqueue 动态分配,不再每个设备固定一个线程。空闲时 workqueue 线程回收,繁忙时动态扩展。
五、wb_writeback 全链路详解
5.1 回写触发的四种时机
┌─────────────────────────────────────────────────────────────┐
│ 回写触发源 │
├────────────┬────────────────────────────────────────────────┤
│ 1. 后台阈值│ balance_dirty_pages() 检测 │
│ (background)│ → 脏页超过 dirty_background_ratio │
│ │ → 唤醒 wb_workfn 异步回写 │
├────────────┼────────────────────────────────────────────────┤
│ 2. 前台阻塞│ balance_dirty_pages_ratelimited() │
│ (foreground)│ → 进程脏页超过 dirty_ratio │
│ │ → 进程被阻塞,直接调用 writeback_single_inode │
├────────────┼────────────────────────────────────────────────┤
│ 3. 过期回写│ wb_workfn() 周期巡检 │
│ (periodic) │ → dirty_expire_centisecs 到期 │
│ │ → 无论脏页量多少都扫描过期页 │
├────────────┼────────────────────────────────────────────────┤
│ 4. 显式同步│ sync() / fsync() / fdatawrite() / write_inode()│
│ (explicit) │ → 绕过所有阈值,强制刷回 │
└────────────┴────────────────────────────────────────────────┘
5.2 wb_workfn:回写的核心函数
wb_workfn 是每个 BDI 回写的入口,由 workqueue 调度执行:
// fs/fs-writeback.c
static long wb_workfn(struct work_struct *work)
{
struct bdi_writeback *wb = container_of(work, ...);
long pages_written;
set_user_nice(current, 0); // nice=0, 适当优先级
while (!kthread_should_stop()) {
// 第一阶段:处理 b_more_io 链表(上一轮遗留)
pages_written = writeback_inodes_wb(wb, &work, WB_REASONS_MORE_IO);
// 第二阶段:检查是否有过期脏页
if (list_empty(&wb->b_dirty) || time_before(jiffies, wb->last_old_flush + dirty_expire_centisecs * HZ / 100)) {
// 无脏页或还没到过期时间,进入睡眠
goto sleep;
}
// 第三阶段:回写过期脏页
pages_written = wb_writeback(wb, &work);
// 如果还有未完成的脏页,继续下一轮
if (pages_written > 0)
continue;
sleep:
// 任务排入延时工作队列
queue_delayed_work(bdi_wq, &wb->dwork,
wb->last_old_flush + dirty_writeback_centisecs * HZ / 100 - jiffies);
}
}
5.3 wb_writeback:选择要回写的 inode
wb_writeback(wb, work)
│
├── 如果 work->for_kupdate:只回写指定时间前的脏页(fdatawrite 触发)
├── 如果 work->for_background:只回写过期脏页
│
┌── for_each_inode_in_list_entry_safe(&wb->b_dirty)
│ │
│ ├── inode 有 I/O 错误?跳过
│ ├── inode 过期检查:inode->dirtied_when < jiffies - expire_interval
│ │ └── 是 → 加入回写列表 writeback_list
│ │
│ └── writeback_single_inode(inode, work)
│ │
│ ├── mutex_lock(&inode->i_lock)
│ ├── 检查 I_DIRTY 标志
│ │
│ ├── 如果有 I_DIRTY_PAGES(有脏页):
│ │ └──do_writepages(mapping, wbc)
│ │ └──a_ops->writepages() ← 文件系统的页回写方法
│ │ └──ext4_writepages()
│ │ ← 扫描 extent tree,构建 bio
│ │ ← 调用 mpage_submit_page() → submit_bio()
│ │
│ ├── 如果有 I_DIRTY_SYNC(inode 自身脏):
│ │ └──write_inode(inode, wbc)
│ │ └──ext4_write_inode()
│ │ ← 写回 inode 元数据
│ │
│ └── 成功?从 b_dirty 移除,加入 b_io 临时列表
│
└── 完成所有 inode 后:
└── wb_check_start_all(&wb->bdi->wb_list) 检查是否需继续
5.4 ext4_writepages 深度剖析
ext4 文件系统的 writepages 方法是一个复杂的引擎,它需要高效地扫描所有脏页并合并成一个大 bio:
// fs/ext4/inode.c
static int ext4_writepages(struct address_space *mapping,
struct writeback_control *wbc)
{
struct ext4_inode_info *ei = EXT4_I(inode);
ext4_io_end_t *io_end;
// 1. 通过 mpage_scan_extent() 扫描 extent tree
// 找到包含脏页的逻辑块 → 物理块映射
ext4_ext_map_blocks(NULL, inode, &map, 0);
// 2. 按物理块地址排序脏页,最大化连续回写
// → 生成 struct mpage_da_data
// 3. 调用 ext4_io_submit() 批量提交 bio
// ← 这里我们完成的循环!
// 从用户 write() → page cache → balance_dirty_pages →
// wb_workfn → wb_writeback → writeback_single_inode →
// ext4_writepages → submit_bio() → blk-mq → NVMe → 磁盘
// 4. 等待 I/O 完成
ext4_end_bio() → unlock_page() → end_page_writeback()
return ret;
}
六、拥塞控制与 QoS
6.1 BDI Congestion:回写反压机制
当某个 BDI 的回写速度跟不上脏页产生速度时,需要通过拥塞机制反压到写入进程:
// include/linux/backing-dev-defs.h
struct bdi_writeback_congested {
unsigned long state; // WB_congested_* 状态位
#define WB_async_congested 0 // 异步拥塞(后台回写跟不上)
#define WB_sync_congested 1 // 同步拥塞(阻塞写入)
struct timer_list congestion_wait; // 拥塞解除等待
};
// 拥塞检测方法
static bool mapping_congested(struct address_space *mapping, int sync)
{
// 检查 BDI 的拥塞状态
if (test_bit(WB_async_congested, &congested->state))
return true;
// 检查是否是同步拥塞
if (sync && test_bit(WB_sync_congested, &congested->state))
return true;
return false;
}
// 写入时检查拥塞
void throttle_vm_writeout(gfp_t gfp_mask)
{
// 1. 等待拥塞解除
// 2. 调用 congestion_wait(BLK_RW_ASYNC, HZ/10)
// 3. 超时后直接触发同步回写
}
6.2 cgroup v2 writeback 控制
Linux 5.2+ 引入 cgroup v2 的 writeback 控制,允许按 cgroup 限制回写带宽:
// 每个回写的 bio 现在携带 cgroup 信息
struct bio {
struct bio_cgroup_css *bi_css; // 指向 cgroup 控制结构
};
// cgroup v2 通过 io.max 控制回写速率
$ echo "8:0 wiops=10000" > /sys/fs/cgroup/mygroup/io.max
# 限制 /dev/sda 的写 IOPS 为 10000
七、生产环境调优实战
7.1 数据库场景(MySQL/PostgreSQL)
数据库通常使用 O_DIRECT 或自有缓存管理,但仍需关注 filesystem writeback:
# /etc/sysctl.d/99-database.conf
# WAL/journal 写入路径不能因拥塞而阻塞
vm.dirty_background_ratio = 3
vm.dirty_ratio = 5
# 更频繁的回写巡检,减少瞬时的 I/O 峰值
vm.dirty_writeback_centisecs = 1000
# WAL 过期时间不要太快(ext4 journal 写回频繁)
vm.dirty_expire_centisecs = 5000
# MySQL 双写缓冲和 redo log 除外
# innodb_flush_method = O_DIRECT 绕过 page cache
7.2 日志收集场景(ELK/Loki)
日志收集的特征是大量顺序写、对延迟容忍度高:
/etc/sysctl.d/99-logging.conf
# 允许更多脏页,提高写合并效率
vm.dirty_background_ratio = 15
vm.dirty_ratio = 30
# 建议配合使用(明确字节值)
# vm.dirty_background_bytes = 4294967296 # 4GB
# vm.dirty_bytes = 8589934592 # 8GB
# 过期时间可以较长(日志最终持久化即可)
vm.dirty_expire_centisecs = 6000
# 较长的巡检间隔,减少 CPU 消耗
vm.dirty_writeback_centisecs = 1000
7.3 高性能计算/AI 训练场景
GPU 训练的 checkpoint 通常是大文件异步写回:
/etc/sysctl.d/99-hpc.conf
# 大内存机器(512GB+),ratio 值应该按比例放大
# 或者直接使用 bytes
vm.dirty_background_bytes = 16106127360 # 16GB
vm.dirty_bytes = 32212254720 # 32GB
# Checkpoint 通常是 SSD 阵列,不需要太频繁
vm.dirty_expire_centisecs = 3000
vm.dirty_writeback_centisecs = 2000
# ⭐ 关键:对 NVMe 设备启用 write_cache
$ cat /sys/block/nvme0n1/queue/write_cache
# 确保是 write back 模式(有掉电保护时)
7.4 K8s 容器场景
容器环境下需要使用 cgroup v2 io.max 控制回写带宽,避免 noisy neighbor:
# Pod 资源限制片段
resources:
limits:
# 内核 cgroup v2 io.max 格式
# <device> wbps=<limit> 或 riops=<limit>
# 宿主机侧设置(CRI-O/containerd 的 baseline)
# OCI hook 注入 cgroup io.max:
$ echo "8:0 wbps=524288000" > /sys/fs/cgroup/kubepods.slice/.../io.max
# 500MB/s 写带宽限制
八、eBPF/bpftrace 观测写回
8.1 跟踪脏页产生速率
#!/usr/bin/env bpftrace
#!/usr/bin/bpftrace
// 跟踪 set_page_dirty 调用栈
kprobe:__set_page_dirty_nobuffers,
kprobe:folio_mark_dirty_for_io
{
@cnt[comm] = count();
}
// 统计每秒脏页产生
s:1
{
print(@cnt);
clear(@cnt);
}
// 使用示例:找出产生脏页最多的进程
// $ sudo bpftrace trace_dirty.bt
//
// Attaching 2 probes...
// @cnt[rsyslogd]: 4521
// [ 1s ]
// @cnt[journalctl]: 82341
// [ 1s ]
// @cnt[postgres]: 234891 ← 发现 PostgreSQL 是脏页主力
8.2 跟踪 wb_writeback 执行状态
#!/usr/bin/bpftrace
// 跟踪回写单次处理时间
kprobe:wb_writeback
{
@start[tid] = nsecs;
}
kretprobe:wb_writeback
/@start[tid]/
{
$wb = (struct bdi_writeback *)arg0;
$bdi = $wb->bdi;
$duration = (nsecs - @start[tid]) / 1000;
// 打印回写耗时
time("%H:%M:%S");
printf("wb_writeback pid=%d pages_written=%d time_us=%d\n",
pid, $wb->work.nr_pages, $duration);
delete(@start[tid]);
}
// 跟踪每个 inode 的回写延迟
kprobe:writeback_single_inode
{
$inode = (struct inode *)arg0;
$dirtied = $inode->i_dirtied_when;
@inode_start[tid] = $dirtied;
}
kretprobe:writeback_single_inode
/@inode_start[tid]/
{
$latency = (jiffies - @inode_start[tid]) * 1000 / HZ;
// latency = 从 dirtied_when 到回写开始的延迟(ms)
@latency_ms = hist($latency);
delete(@inode_start[tid]);
}
8.3 BCC 工具集
BCC 提供了多个现成的写回观测工具:
# 1. 查看每个 BDI 的写带宽
$ bcc-wbabs
BDI Write(KB/s) Write(%) Congested
8:0 (sda) 125432 87.3 0
259:0 (nvme0n1) 45231 100.0 1 ← 拥塞中
# 2. 跟踪 writeback 页提交
$ btrfstat -p 1
# 3. 脏页年龄分布直方图
$ bpftrace -e '
kprobe:wb_start_writeback {
$wb = (struct bdi_writeback *)arg0;
@age = hist((jiffies - $wb->dirtied_stamp) * 1000 / HZ);
}'
九、故障排查清单
9.1 问题一:write 调用偶发卡顿
症状:应用出现 100ms~数秒级别的写入延迟尖峰。
根因:触发同步回写(foreground writeback),进程在 balance_dirty_pages_ratelimited() 中被阻塞。
排查流程:
# 1. 检查是否触发了同步回写
$ dmesg | grep -i dirty
"Write-out is starting"
"Free swap: 0kB"
# 2. 查看当前脏页量
$ grep -E "Dirty|Writeback|NFS_Unstable" /proc/meminfo
Dirty: 5242880 kB # 5GB 脏页!
Writeback: 2048000 kB
# 3. 检查 dirty_ratio 配置
$ sysctl vm.dirty_ratio vm.dirty_background_ratio
vm.dirty_ratio = 20 # 内存 64GB 时限制 12.8GB
vm.dirty_background_ratio = 10
# 4. 查看哪个设备在拥塞
$ cat /sys/class/bdi/*/congested
# 5. 解决方案
echo 10 > /proc/sys/vm/dirty_ratio
echo 5 > /proc/sys/vm/dirty_background_ratio
9.2 问题二:sync/fsync 异常慢
症状:fsync() 调用耗时从 10ms 突然升高到 5s+。
排查流程:
# 1. 确认需要回写的脏页数量
$ grep "Dirty" /proc/meminfo
Dirty: 16777210 kB # 16GB 脏页需要一次性刷回
# 2. 检查 fsync 的内核路径耗时
$ bpftrace -e '
kprobe:ext4_sync_file {
@start[tid] = nsecs;
}
kretprobe:ext4_sync_file /@start[tid]/ {
printf("fsync pid=%d time_us=%d\n", pid, (nsecs-@start[tid])/1000);
delete(@start[tid]);
}'
# 3. 检查是否有并发 sync 竞争同一个 inode
$ strace -p $PID -e trace=fsync,write -T 2>&1
9.3 问题三:BDI 饥饿(多设备场景)
症状:NVMe 设备上写入速度远低于预期,而 SATA HDD 满负载。
# 检查每个设备的回写状态
for bdi in /sys/class/bdi/*/; do
echo "=== $(basename $bdi) ==="
echo -n "Dirty: "; cat ${bdi}stats/BdiDirty
echo -n "Writeback: "; cat ${bdi}stats/BdiWriteback
echo -n "Bandwidth: "; cat ${bdi}stats/avg_write_bandwidth
done
# 解决方案:调整各设备的 max_ratio
# 让 NVMe 获得更高回写带宽配比
echo "50" > /sys/class/bdi/259:0/max_ratio # NVMe
echo "30" > /sys/class/bdi/8:0/max_ratio # SATA
十、NUMA 感知与多队列优化
10.1 跨 NUMA 节点的回写代价
在多路服务器上,如果 BDI 的 wb_writeback workqueue 线程运行在错误的 NUMA 节点上,会导致内存访问远程延迟:
// 查看 workqueue 运行在哪个 CPU
$ grep -H . /sys/bdi/*/stats/ | head
// 在 AMD EPYC 4路服务器上优化:
// 将 workqueue 绑定到与 BDI 设备 PCIe 連接相同的 NUMA 节点
$ cat /sys/block/nvme0n1/device/numa_node
0
// workqueue NUMA 绑定(Linux 5.8+)
$ chrt -r 1 taskset -c 0-7 wb_workqueue_override_affinity=0
10.2 memcg 与写回压力传播
cgroup v2 的 memory controller 与 writeback 的交互:
// 当 cgroup 超过 memory.high 限制时
// 1. cgroup 内的进程在写入时触发 direct reclaim
// 2. reclaim 路径发现脏页 → 触发 writeback
// 3. writeback 启动 → 脏页回写
// 这种机制保证 cgroup 的写入不会无限制产生脏页
// 而是被 memory.high 平滑节流
十一、内核参数速查表
| 参数 | 默认 | 最佳场景建议 | 影响范围 |
|---|---|---|---|
| vm.dirty_background_ratio | 10 | DB: 3, 日志: 15, HPC: 5 | 全局 |
| vm.dirty_ratio | 20 | DB: 5, 日志: 30, HPC: 10 | 全局 |
| vm.dirty_expire_centisecs | 3000 | DB: 5000, 日志: 6000, 实时: 1500 | 全局 |
| vm.dirty_writeback_centisecs | 500 | 通用: 1000, 实时: 200 | 全局 |
| vm.dirtytime_expire_seconds | 43200 | 默认即可 | 脏时间累计 |
| fs.xfs.speculative_prealloc_lifetime | 300 | XFS 文件预分配 | XFS only |
十二、总结与展望
Linux 写回机制从最初的 pdflush 到现代化的 wb_workqueue,经历了三代架构演进。理解写回机制的核心,需要掌握:
- 脏页标记与 BDI 管理:每个脏页通过 inode 挂载到 BDI 的 b_dirty 列表
- 三级触发:后台阈值(异步)、前台阻塞(同步)、过期巡检
- wb_workfn 核心循环:delayed_work 驱动 → 选择 inode → writepages → submit_bio
- 拥塞反压:通过 BDI congestion state 限制写入速率
- cgroup v2 io 控制:per-cgroup 的 writeback 带宽限制
写回机制仍在持续演进中:
- writeback throttling v2 (wbt):基于 rq_qos 的精确 I/O 延迟控制
- per-IOs-cgroup accounting:更细粒度的回写成本归属
- FUSE/passthrough writeback 优化:用户态文件系统的零拷贝回写
- iomap writepages 标准化:ext4/xfs/btrfs/iomap 统一的 writepages 框架
掌握写回机制,是你成为 Linux 存储性能调优专家路上的最后一块拼图。

发表评论 取消回复