前言
紧接上文 [linux内存管理] 第043篇 Page Cache脏页跟踪机制,在上文中我们详细剖析了内核如何标记脏页——即哪些页面被修改过、需要写回磁盘。标记脏页是持久化的"第一步",但仅靠标记并不能保证数据安全,真正的持久化依赖于脏页回写(Writeback)机制。本章以 Linux-6.1 内核为基准,深入剖析脏页从标记到落盘的完整流程。
回写要解决的核心问题
在正式进入代码之前,我们需要理解回写机制要解决三个核心问题:
何时回写?——触发回写的时机(阈值超时、内存压力、用户主动调用)
谁来回写?——回写线程的工作模型
怎么回写?——单页与批量回写的实现路径
一、回写线程体系:从 pdflush 到 bdi_writeback
1.1 历史回顾
在早期 Linux-2.6 内核中,脏页回写由两个全局线程完成:pdflush(负责扫描脏页)和 kupdated(负责超时回写)。这种设计的问题在于:所有块设备的脏页混在一起回写,无法针对不同设备的 I/O 特性做优化,且全局锁竞争严重。
从 Linux-3.10 开始,内核引入了 bdi_writeback 框架,每个 backing_device_info(bdi)拥有独立的回写工作线程,从根本上解决了全局竞争问题。Linux-6.1 沿用并完善了这一架构。
1.2 backing_dev_info 结构体
每个块设备(或每个挂载点对应的后端设备)在内核中对应一个 backing_dev_info(简称 bdi),它管理该设备的回写状态:
// include/linux/backing-dev-defs.h
struct backing_dev_info {
struct device *dev;
struct dev_mapping *dev_map;
struct bdi_writeback *wb; // 每个 bdi 一个 wb 线程
struct list_head bdi_list; // 全局 bdi 链表
unsigned long capabilities;
unsigned int ra_pages; // readahead 页数
unsigned int io_pages; // 支持的最大 I/O 页数
// 脏页控制参数
unsigned long dirty_ratelimit;
unsigned long balanced_dirty_ratelimit;
// dirty_thresh、max_ratio 等阈值信息
struct bdi_threshold __rcu *wb_thresh;
struct bdi_threshold __rcu *wb_bg_thresh;
// 错误状态
errseq_t wb_err;
// 等待队列(脏页过多时进程在此等待)
wait_queue_head_t wb_waitq;
// 用于跟踪属于本设备的 inode 链表
struct list_head bdi_io_list;
struct list_head bdi_dirty_list;
// 统计信息
atomic_long_t writeback_pages;
atomic_long_t writeback_bytes;
...
};
关键字段说明:
wb:指向本设备的回写线程,每个 bdi 有且仅有一个bdi_writeback实例wb_waitq:当脏页数量超过阈值时,等待中的进程在此排队bdi_io_list/bdi_dirty_list:将脏页按 I/O 状态分组,提高回写批量效率dirty_ratelimit:脏页回写的速率限制,防止回写占满磁盘带宽
1.3 bdi_writeback 结构体
struct bdi_writeback {
struct backing_dev_info *bdi;
unsigned int dirty_sleep; // 上次回写时间(用于计算是否超时)
struct list_head works; // 待处理的回写任务链表
struct list_head list; // 挂入 bdi 链表的节点
struct work_struct wq_work; // 工作队列任务(核心)
wait_queue_head_t wq_waitq; // 任务等待队列
struct delayed_work dwork; // 周期性回写(dirty_expire)
// 状态标记
unsigned long state;
#define WB_start_all 0 // 启动所有脏 inode 回写
#define WB_writeback_ready (1 << 0)
#define WB_writeback_running (1 << 1)
// 统计
atomic_t idle_infos; // 空闲 inode 数
struct fprop_local_percpu completions; // I/O 完成计数器
...
};
bdi_writeback 的核心是 wq_work(立即执行的工作任务)和 dwork(延迟/周期性执行的工作任务),两者最终都调用同一个核心处理函数 wb_workfn。
1.4 回写线程的启动
当某个 inode 首次产生脏页时,如果对应的 bdi 还没有启动回写线程,内核会通过 bdi_queue_work() 唤醒回写线程:
static void bdi_queue_work(struct bdi_writeback *wb, struct wb_writeout_work *work)
{
trace_writeback_queue(wb, work->sb);
list_add_tail(&work->list, &wb->works);
mod_delayed_work(bdi_wq, &wb->dwork, 0); // 立即调度 dwork
}
static void wb_workfn(struct work_struct *work)
{
struct bdi_writeback *wb = container_of(work, struct bdi_writeback, dwork.work);
// 首先处理 pending 队列中的工作任务
for (;;) {
struct wb_writeout_work *work;
work = list_first_entry_or_null(&wb->works, struct wb_writeout_work, list);
if (!work)
break;
list_del(&work->list);
// 处理每一个回写任务
wb_do_writeout(wb, work);
}
// 如果还有脏页没有被回写,重新调度
if (wb_has_dirty_io(wb))
wb_schedule_work(wb); // 继续调度
// 如果没有更多工作,进入 idle 状态
wb_clear_pending(wb, WB_writeback_running);
}
wb_workfn 的执行逻辑非常清晰:
从
wb->works链表取出所有待处理的回写任务,逐个执行如果本设备还有脏页(
wb_has_dirty_io(wb)),则继续调度完成后清除
WB_writeback_running标记,表示回写完成
wb_do_writeout() 是真正执行批量回写的函数,它会遍历 inode 列表,对每个脏 inode 调用 writeback_sb_inodes() —— 这里就进入了我们上文中提到的 write_cache_pages 路径:
static void wb_do_writeout(struct bdi_writeback *wb, struct wb_writeout_work *work)
{
struct inode *inode;
// 根据 work 类型决定回写范围
while (!list_empty(&wb->bdi_io_list)) {
inode = list_first_entry(&wb->bdi_io_list, struct inode, i_io_list);
// 从 io_list 取出 inode,逐个回写
__writeback_single_inode(inode, wb, work);
}
// 处理 bdi_dirty_list
while (!list_empty(&wb->bdi_dirty_list)) {
inode = list_first_entry(&wb->bdi_dirty_list, struct inode, i_io_list);
__writeback_single_inode(inode, wb, work);
}
}
二、回写的三类触发时机
脏页回写的触发时机由内核统一调度,主要分为三类:
2.1 周期性回写
内核为每个 bdi 维护一个 dwork 延迟工作,在 dirty_expire_centisecs(默认 3000,即 30 秒)超时后触发回写:
// mm/page-writeback.c
static unsigned long dirty_expire_interval __read_mostly = 3000; // ms
static bool wb_check_old_data_flush(struct bdi_writeback *wb)
{
unsigned long expire_interval;
expire_interval = msecs_to_jiffies(dirty_expire_interval);
if (!time_after(jiffies, wb->dirty_sleep + expire_interval))
return false; // 还没超时
// 超时了,唤醒回写线程
wb_do_writeout(wb, NULL);
return true;
}
dirty_expire_centisecs 可以通过 /proc/sys/vm/dirty_expire_centisecs 查看和修改。
2.2 阈值触发回写(dirty_ratio / dirty_background_ratio)
这是生产环境中最常见的回写触发方式。当用户进程持续写入文件时,balance_dirty_pages() 会实时监控全局脏页数量:
// mm/page-writeback.c
void balance_dirty_pages(struct address_space *mapping,
unsigned long write_chunk)
{
unsigned long nr_dirty, nr_writeback, nr_thresh, bg_thresh;
for (;;) {
// 获取全局脏页数、回写中页数
nr_dirty = global_node_page_state(NR_FILE_DIRTY);
nr_writeback = global_node_page_state(NR_FILE_WRITEBACK);
// 获取全局脏页阈值
bg_thresh = dirty_background_bytes();
nr_thresh = dirty_bytes();
// 如果脏页数未超过后台阈值,无需处理
if (nr_dirty <= bg_thresh)
return;
// 超过后台阈值但未超过总阈值:后台回写,进程不阻塞
if (nr_dirty <= nr_thresh) {
wakeup_flusher_threads_bdi(&mapping->host->i_sb->s_bdi,
WB_REASON_BACKGROUND);
return;
}
// 超过总阈值:进程必须等待回写完成
__set_current_state(TASK_KILLABLE);
io_schedule_timeout(usecs_to_jiffies(pause));
// 让出 CPU,等待回写线程处理
}
}
关键的阈值参数说明:
dirty_background_bytes(默认空)或dirty_background_ratio(默认 10%):后台回写阈值。超过后唤醒后台回写线程,但进程继续执行。dirty_bytes(默认空)或dirty_ratio(默认 20%):总脏页阈值。超过后写入进程主动等待回写完成,防止脏页无限累积。
这两个阈值可以通过以下命令查看和修改:
# 查看
cat /proc/sys/vm/dirty_background_ratio
cat /proc/sys/vm/dirty_ratio
# 修改(临时)
echo 5 > /proc/sys/vm/dirty_background_ratio
echo 10 > /proc/sys/vm/dirty_ratio
2.3 用户主动触发(fsync / sync)
用户程序调用 fsync(fd) 或 sync() 时,内核需要立即将脏页写入磁盘,不等待超时或阈值触发。这是数据完整性保证的关键路径:
// fsync 系统调用入口
SYSCALL_DEFINE1(fsync, int, fd)
{
return do_fsync(fd, 0);
}
int do_fsync(int fd, bool datasync)
{
struct file *file = fget(fd);
struct inode *inode = file->f_path.dentry->d_inode;
int ret;
ret = vfs_fsync(file, datasync); // 通用 VFS 层
return ret;
}
// vfs_fsync 调用文件系统的具体实现
int vfs_fsync(struct file *file, bool datasync)
{
struct inode *inode = file->f_path.dentry->d_inode;
if (!inode->i_sb->s_op->sync_fs)
return 0;
// 调用 ext4_sync_file(以 ext4 为例)
return inode->i_sb->s_op->sync_fs(inode, datasync);
}
对于 ext4 文件系统,ext4_sync_file 的实现如下:
int ext4_sync_file(struct file *file, loff_t start, loff_t end, int datasync)
{
struct inode *inode = file_inode(file);
struct ext4_sb_info *sbi = EXT4_SB(inode->i_sb);
int ret;
// ordered 模式:确保数据对应元数据已写入日志,但不等待数据页落盘
// 如果要确保数据落盘,需要 O_SYNC 或 fsync
if (ext4_should_journal_data(inode))
return ext4_flush_unwritten_io(inode);
// 非 data journal 模式:先同步 inode(确保元数据一致)
ret = ext4_write_inode(inode, NULL);
if (ret)
return ret;
// 然后触发 page cache 的回写
ret = filemap_fdatawait_range(inode->i_mapping, start, end);
return ret;
}
注意:fsync 在 ordered 模式下只保证数据对应的元数据(inode、extent 树等)写入日志,数据页本身的落盘由内核的后台回写线程异步完成。如果需要数据页也落盘(data=journal 模式或 O_SYNC),则需要额外等待数据页的回写完成。
三、批量回写:write_cache_pages 深入分析
在第043篇中我们介绍了 write_cache_pages 的核心流程,本章深入分析其内部实现细节以及关键的状态管理。
3.1 脏页遍历:从 XArray 中找脏页
write_cache_pages 通过 pagevec_lookup_range_tag() 遍历给定地址空间(address_space)中带有 PAGECACHE_TAG_DIRTY 标签的页:
int write_cache_pages(struct address_space *mapping,
struct writeback_control *wbc,
writepage_t writepage, void *data)
{
struct pagevec pvec;
int ret = 0;
pgoff_t index = wbc->range_start >> PAGE_SHIFT;
pgoff_t end = wbc->range_end >> PAGE_SHIFT;
pagevec_init(&pvec);
while (!done) {
// 关键:从 i_pages xarray 中查找带有 DIRTY 标签的脏页
nr_pages = pagevec_lookup_range_tag(&pvec, mapping,
&index, end,
PAGECACHE_TAG_DIRTY);
if (nr_pages == 0)
break; // 没有更多脏页
for (i = 0; i < nr_pages; i++) {
struct page *page = pvec.pages[i];
lock_page(page);
// 有效性检查
if (page->mapping != mapping)
goto continue_unlock;
if (!PageDirty(page)) // 已被其他路径清除脏标记
goto continue_unlock;
// 如果页面正在回写中(其他线程正在写)
if (PageWriteback(page)) {
if (wbc->sync_mode == WB_SYNC_ALL)
wait_on_page_writeback(page); // 同步模式等待
else
goto continue_unlock; // 异步模式跳过
}
// 核心:清除脏标记,同时通过 rmap 处理页表
if (!clear_page_dirty_for_io(page))
goto continue_unlock;
// 调用文件系统的 writepage 回调
ret = (*writepage)(page, wbc, data);
...
}
pagevec_release(&pvec);
cond_resched();
}
return ret;
}
遍历依赖 XArray 的 PAGECACHE_TAG_DIRTY 标签——这也是为什么在脏页跟踪时,内核要通过 xa_set_mark(&mapping->i_pages, page_index, PAGECACHE_TAG_DIRTY) 设置标签的原因。没有标签,回写线程就找不到脏页。
3.2 清除脏标记:clear_page_dirty_for_io
这个函数是回写流程中最关键的步骤之一——它不仅要清除页面的 PG_dirty 标志,还要通过反向映射同步所有映射该页的页表项:
// mm/page-writeback.c
bool clear_page_dirty_for_io(struct page *page)
{
struct address_space *mapping = page_mapping(page);
bool ret = false;
// 1. 先清除页表项的脏位(仅对 mmap 方式有效,write 方式无页表映射)
ret = page_mkclean(page);
// 2. 清除页面描述符的脏标志
ret = TestClearPageDirty(page) || ret;
return ret;
}
page_mkclean() 通过反向映射(rmap)遍历所有映射该页的页表,将每个页表项设置为只读并清除脏位:
// mm/rmap.c
int page_mkclean(struct page *page)
{
struct address_space *mapping;
struct rmap_walk_control rwc = {
.arg = &args,
.rmap_one = page_mkclean_one,
.invalid_vma = page_mkclean_file_vma,
};
if (!page_mapped(page))
return 0;
mapping = page_mapping(page);
// 遍历所有映射该页的 VMA,清除页表脏位
rmap_walk(page, &rwc);
return 0;
}
static int page_mkclean_one(struct page *page, struct vm_area_struct *vma,
unsigned long address, void *arg)
{
pte_t *pte;
pte_t ptent;
pte = pte_offset_map(pmd, address);
ptent = ptep_get(pte);
if (!pte_dirty(ptent) && !pte_write(ptent))
goto out;
// 写保护:设置为只读
ptent = pte_wrprotect(ptent);
// 清除脏位
ptent = pte_mkclean(ptent);
set_pte_at(vma->vm_mm, address, pte, ptent);
out:
pte_unmap(pte);
return 0;
}
这一步骤的意义在于:在回写期间阻止进程的写入操作,避免一边回写一边有新的写入,导致数据不一致。
3.3 ext4 的 writepage 实现
当 write_cache_pages 调用文件系统的 writepage 回调时,ext4 走的是 ext4_writepage:
static int ext4_writepage(struct page *page,
struct writeback_control *wbc)
{
struct inode *inode = page->mapping->host;
struct ext4_io_submit io_submit;
int ret;
// 如果是 journal 模式,数据通过日志路径写入,不走这里
if (ext4_should_journal_data(inode))
goto out_ignore;
// 检查页面是否仍为脏(可能在 clear_page_dirty_for_io 后被其他路径修改)
ret = block_write_full_page(page, ext4_bh_submit, wbc);
return ret;
out_ignore:
unlock_page(page);
return 0;
}
block_write_full_page() 是通用块层的实现,它遍历页面所有 buffer head,构造 bio 提交到底层块设备,并在 I/O 完成后通过 end_buffer_async_write() 回调清除 PG_writeback 标志。
四、writeback_control:回写的控制参数
writeback_control(简称 wbc)是贯穿整个回写流程的核心数据结构,它控制回写的范围、模式和统计:
struct writeback_control {
long nr_to_write; // 本次还需要写多少页
long pages_skipped; // 跳过(被锁等)的页数
// 回写范围(字节)
loff_t range_start;
loff_t range_end;
// 同步模式
enum writeback_sync_modes sync_mode;
#define WB_SYNC_NONE 0 // 异步回写
#define WB_SYNC_ALL 1 // 同步回写(等待所有页完成)
// 回写原因(用于 tracing)
enum wb_reason reason;
// 是否是范围内的回写(还是全量)
unsigned int for_kupdate:1;
unsigned int for_background:1;
unsigned int for_sync:1;
unsigned int range_cyclic:1;
};
不同调用路径传入不同的 wbc 参数:
fsync:传入sync_mode = WB_SYNC_ALL,表示必须等待所有脏页落盘才返回后台回写线程:传入
sync_mode = WB_SYNC_NONE,异步执行,不等待msync(MS_ASYNC):传入WB_SYNC_NONEmsync(MS_SYNC):传入WB_SYNC_ALL
五、ext4 批量回写:ext4_writepages
实际生产环境中,文件系统更多使用的是批量回写接口 writepages(注意是复数),而不是单页回写的 writepage。ext4 的 ext4_writepages 实现中,核心调用的就是 write_cache_pages:
static int ext4_writepages(struct address_space *mapping,
struct writeback_control *wbc)
{
struct inode *inode = mapping->host;
struct ext4_sb_info *sbi = EXT4_SB(inode->i_sb);
// mballoc 分配上下文(延迟分配)
handle_t *handle = NULL;
// 批量回写的核心路径
ret = ext4_bio_write_page(&io_submit, page, len, NULL);
// 实际上底层走的还是 write_cache_pages
return generic_writepages(mapping, wbc);
}
// generic_writepages 会调用 write_cache_pages
int generic_writepages(struct address_space *mapping,
struct writeback_control *wbc)
{
return write_cache_pages(mapping, wbc, mapping->a_ops->writepage, NULL);
}
ext4 在批量回写时会使用延迟分配(delalloc)优化:多个相邻的脏页可以合并为一次大的块分配请求,减少块分配次数,提高性能。
六、writeback 与内存回收的交互
当系统内存压力增大时,内核的 shrink_inactive_list() 会尝试回收不活跃的页面缓存。如果这些页面中有脏页,必须先回写后才能释放:
// mm/vmscan.c
static unsigned long shrink_inactive_list(unsigned long nr_to_scan, ...)
{
LIST_HEAD(file_list);
struct page *page;
unsigned long nr_unqueued_dirty = 0;
while (!list_empty(&page_list)) {
page = lru_to_page(&page_list);
// 如果是脏页,必须先回写
if (PageDirty(page)) {
list_move(&page->lru, &ret_pages);
nr_unqueued_dirty++;
continue;
}
// 干净页可以直接回收
nr_scanned++;
if (PageActive(page))
...
}
// 触发回写
if (nr_unqueued_dirty)
wakeup_flusher_threads(nr_unqueued_dirty, WB_REASON_VMSCAN);
return nr_reclaimed;
}
在内存压力场景下,回写会与内存回收线程紧密配合:shrink 扫描脏页后将其移入 ret_pages,然后通过 wakeup_flusher_threads() 唤醒回写线程来处理这些脏页。
七、脏页回写完整生命周期
结合第043篇的脏页跟踪机制,我们可以画出脏页的完整生命周期:
┌──────────────────────────────────────────────────────┐
│ 脏页产生 │
│ │
│ write() → generic_perform_write() → set_page_dirty()│
│ mmap()写 → 缺页异常 → ext4_page_mkwrite() → PG_dirty │
│ │
│ 标记 PG_dirty = 1 + XArray 设置 PAGECACHE_TAG_DIRTY │
└──────────────────┬───────────────────────────────────┘
│
│ balance_dirty_pages() / 周期性调度 / fsync
▼
┌──────────────────────────────────────────────────────┐
│ 回写开始 │
│ │
│ wb_workfn → wb_do_writeout → __writeback_single_inode│
│ │
│ write_cache_pages() │
│ pagevec_lookup_range_tag(PAGECACHE_TAG_DIRTY) │
│ → 遍历脏页 │
│ │
│ for each page: │
│ lock_page(page) │
│ clear_page_dirty_for_io(page) │
│ → page_mkclean() 清除页表脏位+写保护 │
│ → TestClearPageDirty() 清除 PG_dirty │
│ → 调用 writepage 回调(ext4_writepage) │
│ → block_write_full_page() │
│ → 构造 bio 提交到块层 │
│ → PG_writeback = 1(正在回写) │
└──────────────────┬───────────────────────────────────┘
│
│ I/O 完成中断(end_buffer_async_write)
▼
┌──────────────────────────────────────────────────────┐
│ 回写完成 │
│ │
│ PG_writeback = 0 │
│ PG_dirty = 0(之前已清除) │
│ 页面变回干净(clean) │
│ │
│ 如果 fsync 等待:wake_up(page_waitqueue(page)) │
└──────────────────────────────────────────────────────┘
八、回写调优实战
理解回写机制后,可以通过调优 sysctl 参数显著改善不同场景下的性能:
可以通过以下命令实时查看脏页状态:
# 查看脏页数量(单位 KB)
cat /proc/meminfo | grep -E "Dirty|Writeback"
# 查看各设备的 bdi 信息(Linux-5.6+)
cat /sys/kernel/debug/bdi/<bdi-id>/stats
# 手动触发后台回写
sync
总结
本章系统剖析了 Linux-6.1 内核的脏页回写机制,核心要点如下:
回写线程模型:从旧的 pdflush 全局线程演进为每个 bdi 一个
bdi_writeback工作线程,消除了全局竞争三层触发时机:
周期性超时回写(
dirty_expire_centisecs)阈值触发(
dirty_ratio/dirty_background_ratio)用户主动触发(
fsync/sync/msync)
回写流程:
wb_workfn→write_cache_pages→clear_page_dirty_for_io→writepage→ 块层 bio关键同步点:回写前通过
page_mkclean()清除页表脏位并写保护,回写期间阻塞新写入;回写完成后清除PG_writeback,页面恢复干净与脏页跟踪的闭环:脏页跟踪标记了"哪些页需要回写",回写机制负责"何时回写"和"怎么回写",两者共同构成 Linux 内存管理中数据持久化的完整链路
结合第043篇和第044篇,读者应能完整理解 Linux Page Cache 中脏页从产生、跟踪到最终落盘的全流程。下一步可以深入的方向包括:
ext4 日志(jbd2)机制与数据回写的交互
内存回收(kswapd / direct reclaim)中脏页的回收策略
eBPF/bpftrace 追踪脏页回写的实战方法
由于作者水平有限,文章不足之处在所难免,敬请广大读者朋友批评指正。