mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
5616 字
15 分钟
页缓存与 I/O 路径
2025-01-09

一、引言#

上一章中,追踪了一条虚拟地址如何通过页表翻译为物理地址——那是 CPU 访问内存的故事。但内存中的数据并非凭空而来:当你执行 cat /etc/passwd 时,文件内容从磁盘加载到内存;当你 echo "hello" > file.txt 时,数据从内存写回磁盘。这条从用户缓冲区到块设备的完整路径,就是 Linux I/O 子系统的核心。

而这条路径上最关键的”加速器”,就是页缓存(Page Cache)。Linux 的设计哲学是:磁盘太慢,能不访问就不访问。页缓存正是这一哲学的工程实现——它用物理内存缓存磁盘上的文件内容,让绝大多数读操作直接在内存中完成,写操作则延迟到合适的时机再刷盘。

本章将从 Page Cache 的核心数据结构出发,深入解析读路径与写路径的完整流程,剖析脏页写回机制、fsync 语义、Direct I/O、异步 I/O(从旧式 AIO 到 io_uring)以及预读机制,最终让你对”数据从 write() 到磁盘”的每一步都了如指掌。

flowchart TB subgraph 用户空间 APP[用户进程] end subgraph VFS层 VFS_R[vfs_read] VFS_W[vfs_write] end subgraph 页缓存 PC[Page Cache<br/>address_space] RA[预读机制<br/>readahead] DIRTY[脏页标记] end subgraph 写回机制 WB[flush/kworker<br/>写回线程] DIRTY_RATIO[dirty_ratio<br/>dirty_background_ratio] end subgraph 块设备层 BIO[bio 请求] BLK[块设备] end APP -->|read| VFS_R APP -->|write| VFS_W VFS_R -->|缓存命中| PC VFS_R -->|缓存未命中| RA RA -->|从磁盘加载| BIO VFS_W --> DIRTY DIRTY --> WB WB --> BIO BIO --> BLK DIRTY_RATIO -.->|触发写回| WB style PC fill:#99ccff,stroke:#333 style DIRTY fill:#ffcc99,stroke:#333 style WB fill:#ff9999,stroke:#333 style BLK fill:#cc99ff,stroke:#333

二、Page Cache:用内存换速度#

1.1 为什么需要 Page Cache?#

磁盘 I/O 是计算机系统中最慢的操作之一。一次随机磁盘访问的延迟大约在毫秒级(HDD ~10ms,SSD ~0.1ms),而内存访问的延迟在纳秒级(~100ns)——两者相差 5 到 6 个数量级。如果每次读文件都要访问磁盘,系统性能将灾难性地下降。

Linux 的解决方案是 Page Cache:将文件内容缓存在物理内存中。当进程读取文件时,内核先在 Page Cache 中查找——如果命中(cache hit),直接从内存返回数据,无需磁盘 I/O;如果未命中(cache miss),则从磁盘读取数据,同时存入 Page Cache,为后续访问做准备。

Note

Page Cache 使用的是匿名内存之外的物理内存。当系统内存紧张时,Page Cache 页可以被直接丢弃(干净页)或写回磁盘后丢弃(脏页),而不需要像匿名页那样换出到 swap。这也是为什么 Linux 总是倾向于把空闲内存用作 Page Cache——“空闲的内存就是浪费的内存”。

1.2 address_space:Page Cache 的组织核心#

Page Cache 的核心数据结构是 struct address_space,每个 inode 都关联一个 address_space,它管理着该文件在内存中的所有缓存页:

include/linux/fs.h
struct address_space {
struct inode *host; /* 关联的 inode */
struct xarray i_pages; /* 存储 page 指针的 xarray(基数树) */
unsigned long nrpages; /* 缓存页总数 */
unsigned long nrexceptional; /* DAX/THP 等特殊条目数 */
pgoff_t writeback_index;/* 写回起始位置 */
const struct address_space_operations *a_ops; /* 操作函数表 */
unsigned long flags; /* 错误标志、DIRTY 标记等 */
spinlock_t private_lock; /* private_list 保护锁 */
struct list_head private_list; /* buffer_head 链表 */
void *private_data; /* 私有数据 */
} __attribute__((aligned(sizeof(long)))) __randomize_layout;

关键字段解读:

  • i_pages(xarray):这是 Page Cache 的”索引引擎”。它是一棵基于基数树(radix tree)的高效查找结构,以页偏移(page index,即文件内的第几页)为键,以 struct page 指针为值。当内核需要查找文件第 N 页是否在缓存中时,只需在 xarray 中查找 index=N 的条目即可。
  • a_ops(address_space_operations):操作函数表,定义了如何在该 address_space 上执行 I/O 操作——读页、写页、写回脏页等。这是 VFS 与具体文件系统之间的桥梁。
  • host:指向关联的 inode,使得从 address_space 可以反向找到文件元数据。

1.3 address_space_operations:I/O 操作的多态接口#

address_space_operations 是 Page Cache 与底层文件系统之间的”协议”,它定义了一组操作函数:

include/linux/fs.h
struct address_space_operations {
int (*writepage)(struct page *page, struct writeback_control *wbc);
int (*readpage)(struct file *, struct page *);
int (*writepages)(struct address_space *, struct writeback_control *);
int (*set_page_dirty)(struct page *page);
int (*readpages)(struct file *filp, struct address_space *mapping,
struct list_head *pages, unsigned nr_pages);
int (*write_begin)(struct file *, struct address_space *mapping,
loff_t pos, unsigned len,
struct page **pagep, void **fsdata);
int (*write_end)(struct file *, struct address_space *mapping,
loff_t pos, unsigned len, unsigned copied,
struct page *page, void *fsdata);
/* ... 更多操作 ... */
};

几个关键操作的含义:

操作作用典型调用场景
readpage从磁盘读取一页到 Page Cache缓存未命中时
readpages批量预读多页预读机制触发
writepage将一页写回磁盘脏页写回
writepages批量写回多页周期性/主动写回
set_page_dirty标记页为脏写操作修改页后
write_begin写操作开始,准备页写路径第一步
write_end写操作完成写路径最后一步

不同文件系统实现不同版本的 a_ops。例如 ext4 的 ext4_address_space_operations 与 XFS 的 xfs_address_space_operations 各自实现了这些操作,但上层 VFS 代码无需关心底层细节——这就是多态的力量。

1.4 Buffer Head:从历史到现代#

在 Linux 2.4 及更早的版本中,Buffer Head(struct buffer_head 是块 I/O 的核心抽象。每个 buffer_head 描述磁盘上的一个块(通常 1KB 或 4KB)与内存中缓冲区的映射关系。

// include/linux/buffer_head.h(简化)
struct buffer_head {
unsigned long b_state; /* 缓冲区状态 */
struct buffer_head *b_this_page; /* 同一页中的缓冲区链表 */
struct page *b_page; /* 所属的 page */
sector_t b_blocknr; /* 磁盘块号 */
size_t b_size; /* 块大小 */
char *b_data; /* 数据指针 */
struct block_device *b_bdev; /* 块设备 */
};

在早期设计中,每个磁盘块对应一个 buffer_head,I/O 操作以 buffer_head 为单位。这种设计存在严重的可扩展性问题:一个大文件可能需要成千上万个 buffer_head,导致内存开销巨大、锁竞争激烈。

从 Linux 2.5 开始,内核引入了 bio 结构作为新的 I/O 描述符,以**段(segment)**为单位组织 I/O,一个 bio 可以描述多个不连续的磁盘区间。现代文件系统(ext4、XFS、Btrfs 等)的 I/O 路径已经完全基于 bio,buffer_head 仅在以下场景中保留:

  • 文件系统元数据(inode、目录项、块位图等)的读写仍使用 buffer_head
  • 某些旧文件系统(如 ext2)的数据路径仍依赖 buffer_head
  • buffer_migrate_folio 等辅助函数中
Tip

理解 buffer_head 的历史有助于阅读旧版内核代码,但在现代内核开发中,你应该关注 bio 和 struct folio。Linux 6.x 正在将 Page Cache 从 struct page 迁移到 struct folio(一个 folio 可以包含多个连续 page),以减少内存管理开销。

三、读路径:从 read() 到数据返回#

2.1 完整读路径流程#

当用户程序调用 read() 时,数据经历以下旅程:

sequenceDiagram participant U as 用户进程 participant V as VFS (vfs_read) participant P as Page Cache participant R as Readahead participant B as 块设备 U->>V: read(fd, buf, count) V->>V: 调用 file->f_op->read_iter<br/>或 generic_file_read_iter() V->>P: 在 address_space->i_pages 中查找页 alt 缓存命中 (cache hit) P-->>V: 返回 page 数据 V-->>U: 拷贝到用户缓冲区 else 缓存未命中 (cache miss) P-->>V: 页不存在 V->>R: 触发预读 (readahead) R->>B: 提交 bio 读取请求 B-->>R: 数据返回 R->>P: 将页加入 Page Cache P-->>V: 返回 page 数据 V-->>U: 拷贝到用户缓冲区 end

2.2 源码级追踪#

读路径的核心函数调用链如下:

// 1. 系统调用入口
SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
ksys_read()
vfs_read()
→ file->f_op->read_iter() // 通常指向 generic_file_read_iter
// 2. 通用读路径
generic_file_read_iter()
generic_file_buffered_read()
page_cache_sync_readahead() // 同步预读:确保目标页在缓存中
// 3. 在 Page Cache 中查找页
page_cache_sync_readahead()
page_cache_ra_order()
read_pages()
→ mapping->a_ops->readpages() // 文件系统实现,提交 bio
// 4. 等待 I/O 完成
// 如果页是锁定的(正在读入),进程会等待:
lock_page_killable(page)
__wait_on_bit_lock(page_waitqueue(page), ...)
// 5. 拷贝数据到用户空间
copy_page_to_iter(page, offset, size, iter)
copy_page_to_iter_pipe() 或 copy_to_iter()

关键步骤解析:

  1. Page Cache 查找generic_file_buffered_read() 首先在 mapping->i_pages 中查找目标页。查找使用 xarray 的 RCU 安全接口,无需加锁即可完成快速查找。
  2. 缓存命中:如果页存在且是最新的(PG_uptodate 标志置位),直接调用 copy_page_to_iter() 将数据拷贝到用户缓冲区,无需任何磁盘 I/O。
  3. 缓存未命中:如果页不存在,触发同步预读page_cache_sync_readahead),分配新页并提交 I/O 请求从磁盘读取。进程在 lock_page() 上等待 I/O 完成。
  4. 数据拷贝:I/O 完成后,页被标记为 PG_uptodate,进程被唤醒,数据从页缓存拷贝到用户空间。
Warning

注意这里有一次数据拷贝:从内核的 Page Cache 页拷贝到用户空间的缓冲区。这是传统 buffered I/O 的固有开销。splice()sendfile() 系统调用可以避免这次拷贝——它们直接在内核空间内将页缓存数据转移到 socket 缓冲区,这就是零拷贝(zero-copy) I/O。

2.3 预读机制:让磁盘跑在前面#

当内核检测到顺序读模式时,它会提前读取后续页面到 Page Cache 中,这就是预读(Readahead)。预读的核心思想是:既然磁盘已经转起来了,不如多读一些数据,反正顺序读大概率会用到。

预读算法的核心逻辑在 mm/readahead.c 中实现:

// mm/readahead.c(简化逻辑)
void page_cache_ra_order(struct readahead_control *ractl,
struct file_ra_state *ra, unsigned int order)
{
// 计算预读窗口大小
// 初始预读:读取 4 页(默认值)
// 后续预读:根据历史模式动态调整,最大可到 128 页(512KB)
// 关键参数:
// ra->start - 预读起始位置
// ra->size - 预读页数
// ra->async_size - 异步预读部分大小
// ra->prev_pos - 上次读位置(用于检测顺序/随机)
}

预读的两种模式:

  1. 同步预读(sync readahead):当缓存未命中时触发,进程必须等待 I/O 完成。初始预读窗口较小(4 页)。
  2. 异步预读(async readahead):当进程读到预读窗口的边缘时触发,内核提前发起下一轮预读,进程无需等待。预读窗口逐步增大。

预读算法通过 file_ra_state 结构跟踪每个文件的读模式:

include/linux/fs.h
struct file_ra_state {
pgoff_t start; /* 预读窗口起始 */
unsigned int size; /* 预读窗口大小 */
unsigned int async_size; /* 异步预读触发点 */
unsigned int ra_pages; /* 最大预读页数 */
unsigned int mmap_miss; /* mmap 缺失计数 */
loff_t prev_pos; /* 上一次读位置 */
};

当检测到随机读模式(prev_pos 与当前读位置不连续)时,预读窗口会缩小甚至禁用,避免浪费内存和带宽。

四、写路径:从 write() 到磁盘#

3.1 完整写路径流程#

写路径比读路径更复杂,因为 Linux 采用了**延迟写(delayed write)**策略——write() 系统调用只将数据写入 Page Cache 并标记为脏,不立即写回磁盘。真正的磁盘写入由后台写回线程异步完成。

sequenceDiagram participant U as 用户进程 participant V as VFS (vfs_write) participant P as Page Cache participant D as 脏页标记 participant W as 写回线程<br/>(flush/kworker) participant B as 块设备 U->>V: write(fd, buf, count) V->>V: 调用 file->f_op->write_iter<br/>或 generic_file_write_iter() V->>P: write_begin() 准备页 P-->>V: 返回目标页 V->>V: 从用户空间拷贝数据到页<br/>copy_from_iter() V->>D: write_end() + set_page_dirty()<br/>标记页为脏 V-->>U: write() 返回(数据仅在内存中) Note over W: 后台写回线程被触发 W->>P: 扫描脏页列表 P-->>W: 收集脏页 W->>B: 提交 bio 写请求 B-->>W: 写入完成 W->>D: 清除脏标记 PG_dirty

3.2 源码级追踪#

写路径的核心函数调用链:

// 1. 系统调用入口
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, size_t, count)
ksys_write()
vfs_write()
→ file->f_op->write_iter() // 通常指向 generic_file_write_iter
// 2. 通用写路径
generic_file_write_iter()
__generic_file_write_iter()
generic_perform_write()
// 3. 逐页写入
generic_perform_write()
→ a_ops->write_begin() // 准备目标页(可能需要从磁盘读取)
copy_from_iter() // 从用户空间拷贝数据到页缓存
→ a_ops->write_end() // 完成写入,标记页为脏
// 4. 标记脏页
// write_end() 内部调用:
__set_page_dirty_buffers() 或 __set_page_dirty()
→ 设置 PG_dirty 标志
→ 将页加入 inode 的脏页链表 (mapping->i_pages with dirty tag)
→ 唤醒后台写回线程

关键步骤解析:

  1. write_begin:文件系统的 write_begin 操作负责准备目标页。如果页不在 Page Cache 中,需要先分配并可能从磁盘读取原始数据(因为写入可能只修改页的一部分,需要先读出完整页再部分修改——这就是读-改-写模式)。
  2. 数据拷贝copy_from_iter() 将用户空间的数据拷贝到 Page Cache 中的对应位置。注意,这里只有一次拷贝——从用户空间到内核页缓存。
  3. 标记脏页set_page_dirty() 将页标记为脏(设置 PG_dirty 标志),并将其加入 inode 的脏页集合。此时数据仅存在于内存中,write() 系统调用即可返回。
  4. 后台写回:内核的写回线程会在合适的时机将脏页写回磁盘。
Note

延迟写的设计带来了巨大的性能优势:多次对同一页的小写入只产生一次磁盘 I/O;对同一文件的多个进程的写入可以被合并;应用程序无需等待慢速的磁盘操作。但代价是数据在 write() 返回后可能尚未落盘——如果此时系统断电,数据将丢失。这就是 fsync() 存在的意义。

五、脏页写回机制#

4.1 何时触发写回?#

Linux 内核通过三个条件触发脏页写回:

触发条件相关参数默认值含义
脏页比例超限vm.dirty_ratio20(%物理内存)脏页占总内存的比例上限,超限后写操作被阻塞
后台写回阈值vm.dirty_background_ratio10(%物理内存)脏页达到此比例时,后台写回线程开始工作
定时写回dirty_writeback_interval500(厘秒 = 5秒)每隔此时间,写回线程醒来扫描脏页
# 查看当前写回参数
cat /proc/sys/vm/dirty_ratio
cat /proc/sys/vm/dirty_background_ratio
cat /proc/sys/vm/dirty_writeback_interval
# 也可以用字节而非百分比设置
cat /proc/sys/vm/dirty_bytes
cat /proc/sys/vm/dirty_background_bytes

三个条件的协作方式:

  1. 正常情况:脏页比例低于 dirty_background_ratio,写回线程休眠,write() 立即返回。
  2. 后台写回:脏页比例超过 dirty_background_ratioflush/kworker 线程被唤醒,在后台异步写回脏页,write() 仍立即返回。
  3. 阻塞写回:脏页比例超过 dirty_ratio,新的 write() 调用将被阻塞,调用者被迫等待脏页写回——这是内核的”紧急刹车”,防止内存被脏页耗尽。

4.2 flush/kworker 写回线程#

Linux 内核使用专门的内核线程负责脏页写回:

  • flush-x:y 线程:每个块设备一个,负责该设备上的脏页写回。在较新的内核中,这些线程由 kworker 工作队列实现。
  • kworker 线程:通用内核工作队列线程,也承担定时写回任务。

写回线程的核心逻辑在 mm/page-writeback.cfs/fs-writeback.c 中:

// fs/fs-writeback.c(简化)
static long wb_writeback(struct bdi_writeback *wb,
struct wb_writeback_work *work)
{
while (1) {
// 1. 从 inode 脏页列表中收集脏页
// 2. 调用 mapping->a_ops->writepages() 提交写 I/O
// 3. 等待 I/O 完成
// 4. 清除脏标记
if (work->nr_pages <= 0)
break;
if (wb->dirty_exceeded)
break;
// ...
}
}

写回线程的调度策略:

  • 周期性唤醒:每隔 dirty_writeback_interval 醒来,扫描所有脏 inode
  • 事件驱动唤醒:当 set_page_dirty() 发现脏页比例超过 dirty_background_ratio 时唤醒
  • 阻塞唤醒:当脏页比例超过 dirty_ratio 时,balance_dirty_pages() 强制当前进程参与写回

4.3 脏页的生命周期#

一个页从被标记为脏到最终写回磁盘,经历以下状态转换:

干净页 (clean)
│ write() 修改
脏页 (dirty) ──── 加入 inode 脏页链表
│ 写回线程选中
写回中 (writeback) ──── 设置 PG_writeback 标志
│ I/O 完成
干净页 (clean) ──── 清除 PG_dirty 和 PG_writeback

对应的页标志位:

  • PG_dirty:页内容已被修改,需要写回磁盘
  • PG_writeback:页正在被写回磁盘(I/O 进行中)
  • PG_locked:页被锁定(I/O 进行中或正在被修改)
Warning

一个页可以同时处于 PG_dirtyPG_writeback 状态。写回开始时,先设置 PG_writeback,I/O 完成后再清除 PG_dirty。在此期间,新的写入可以继续修改该页——这称为脏页的重新脏化(re-dirtying),意味着一次写回可能不够,需要再次写回。

六、fsync 家族:确保数据落盘#

5.1 三种同步语义#

由于延迟写的存在,write() 返回后数据可能仍在内存中。对于需要确保数据持久化的应用(如数据库事务日志),必须使用显式同步操作:

系统调用同步范围元数据典型用途
fsync(fd)单个文件的数据 + 元数据同步数据库事务日志
fdatasync(fd)单个文件的数据不同步(除非必要)大文件写入,不关心 mtime
sync()全局所有脏数据同步系统关机前
syncfs(fd)单个文件系统同步容器/挂载点同步

5.2 fsync 的实现#

fsync 的核心工作是确保指定文件的所有脏页都写回磁盘,并且磁盘确认写入完成(即绕过磁盘的写缓存):

fs/sync.c
SYSCALL_DEFINE1(fsync, unsigned int, fd)
do_fsync(fd, 1)
vfs_fsync(fd, 1)
vfs_fsync_range(file, 0, LLONG_MAX, 1)
// fs/sync.c
int vfs_fsync_range(struct file *file, loff_t start, loff_t end, int datasync)
{
// 1. 将 inode 的所有脏页写回
filemap_write_and_wait_range(mapping, start, end);
// 2. 调用文件系统的 sync 操作(确保元数据也落盘)
// 如果 datasync=1 (fdatasync),文件系统可以跳过不必要的元数据写回
ret = file->f_op->fsync(file, start, end, datasync);
return ret;
}

fsyncfdatasync 的关键区别:

  • fsync 会同步文件的元数据(inode 中的 mtimectimesize 等),确保文件系统的完整性
  • fdatasync 只同步文件数据,跳过元数据的写回——除非文件大小发生了变化(此时必须更新元数据,否则数据不可达)
Tip

对于大文件追加写入的场景,fdatasyncfsync 少一次元数据 I/O,性能优势明显。但如果文件大小未变(原地更新),两者开销几乎相同,因为不需要额外的元数据写回。数据库系统(如 PostgreSQL 的 WAL、MySQL 的 redo log)通常使用 fsync 来保证事务的持久性。

5.3 fsync 的性能陷阱#

fsync 有一个著名的性能陷阱:对同一文件的大量小 fsync 调用会导致严重的写放大。每次 fsync 都会将文件的所有脏页写回磁盘,即使只修改了一个字节。此外,fsync 还会强制提交文件系统的日志(journal/commit),导致磁盘频繁执行 flush 操作。

更糟糕的是,fsync 可能影响全局性能:ext4 等文件系统在 fsync 时会提交整个事务,这意味着其他进程的脏数据也可能被一起刷盘——这就是”fsync 风暴”的根源。

七、Direct I/O:绕过 Page Cache#

6.1 为什么需要 Direct I/O?#

Page Cache 虽好,但并非万能。某些场景下,应用希望完全控制 I/O 行为:

  • 数据库系统:自己维护缓冲池(如 MySQL InnoDB Buffer Pool),不希望内核重复缓存
  • 大文件顺序读写:数据只会被访问一次,缓存没有意义,反而浪费内存
  • 低延迟要求:需要精确控制数据何时落盘,不接受延迟写的不确定性

Direct I/O(通过 O_DIRECT 标志打开文件)允许应用绕过 Page Cache,直接在用户缓冲区和磁盘之间传输数据。

6.2 Direct I/O 的限制#

使用 Direct I/O 有严格的对齐要求:

// 打开文件时指定 O_DIRECT
int fd = open("data.bin", O_RDONLY | O_DIRECT);
// 以下三个值必须对齐到逻辑块大小(通常 512 字节或 4KB):
// 1. 用户缓冲区地址(必须页对齐)
// 2. 文件偏移量(必须是块大小的整数倍)
// 3. 读取长度(必须是块大小的整数倍)
// 使用 posix_memalign 分配对齐的缓冲区
void *buf;
posix_memalign(&buf, 4096, 4096);
pread(fd, buf, 4096, 0); // 偏移 0,长度 4096,均对齐

不满足对齐要求的 Direct I/O 会返回 EINVAL 错误。这些限制源于 Direct I/O 直接将用户缓冲区映射到 DMA 传输——硬件要求缓冲区地址和长度必须对齐。

6.3 Direct I/O 的实现#

Direct I/O 的核心实现在 fs/direct-io.c(旧路径)和 iomap 框架(新路径)中:

// fs/direct-io.c(简化流程)
// 当文件以 O_DIRECT 打开时,read/write 路径会走 direct I/O 分支
generic_file_read_iter()
if (O_DIRECT): filemap_write_and_wait_range() // 先写回该范围的脏页
->direct_IO()
generic_file_write_iter()
if (O_DIRECT): invalidate_inode_pages2_range() // 使该范围的缓存失效
->direct_IO()
// direct_IO 最终调用:
// mapping->a_ops->direct_IO()
// ext4 实现: ext4_direct_IO() → iomap_dio_rw()
Note

Direct I/O 并非完全绕过内核——数据仍然经过内核的 DMA 映射和 I/O 提交路径,只是不经过 Page Cache。此外,Direct I/O 是同步的:read()/write() 会等待磁盘 I/O 完成才返回,不像 buffered I/O 那样写操作可以立即返回。

八、异步 I/O:从 AIO 到 io_uring#

7.1 Linux AIO 的困境#

Linux 早期提供的异步 I/O 接口是 Linux AIOio_setup/io_submit/io_getevents),但它存在严重的设计缺陷:

  1. 仅支持 O_DIRECT:对于 buffered I/O,io_submit 会退化为同步操作——提交时即阻塞等待完成,完全失去了”异步”的意义
  2. API 复杂:需要先 io_setup 创建上下文,再 io_submit 提交 I/O,最后 io_getevents 获取完成事件,每次系统调用都有开销
  3. 拷贝开销:I/O 提交和完成事件都需要在用户空间和内核之间拷贝数据
  4. 不可扩展:每个 I/O 请求至少一次系统调用,高并发场景下系统调用开销成为瓶颈

7.2 io_uring:革命性的异步 I/O#

io_uring 是 Linux 5.1 引入的全新异步 I/O 接口,由 Jens Axboe 设计。它彻底解决了 Linux AIO 的所有问题,其核心设计思想是:通过共享内存环缓冲区,实现用户空间与内核的零拷贝通信

io_uring 的核心架构#

┌─────────────────────────────────────────────────┐
│ 用户空间 │
│ │
│ ┌──────────┐ ┌──────────────┐ │
│ │ SQ │ │ CQ │ │
│ │ 提交队列 │ │ 完成队列 │ │
│ │ (环形缓冲) │ │ (环形缓冲) │ │
│ └────┬─────┘ └──────┬───────┘ │
│ │ │ │
│ 应用写入 SQE 应用读取 CQE │
│ (提交请求) (获取结果) │
└───────┼───────────────────────┼───────────────────┘
│ │
════════╪═══════════════════════╪═══════════════════
│ 共享内存 │
════════╪═══════════════════════╪═══════════════════
┌───────┼───────────────────────┼───────────────────┐
│ ▼ │ │
│ 内核读取 SQE │ │
│ (处理请求) │ │
│ │ ▼ │
│ │ 内核写入 CQE │
│ │ (完成通知) │
│ │ │ │
│ 内核空间 │
└─────────────────────────────────────────────────┘

提交队列(Submission Queue, SQ):应用将 I/O 请求(SQE, Submission Queue Entry)写入 SQ,内核从中消费请求。

完成队列(Completion Queue, CQ):内核将 I/O 完成事件(CQE, Completion Queue Entry)写入 CQ,应用从中消费结果。

io_uring 的关键优势#

特性Linux AIOio_uring
Buffered I/O 支持退化为同步真正异步
系统调用次数每个请求至少 1 次批量提交,可零系统调用
数据拷贝需要拷贝共享内存,零拷贝
可扩展性受限极高
操作类型仅 read/writeread/write/fsync/open/close/…
内核版本2.5+5.1+

io_uring 编程示例#

// 简化的 io_uring 异步读示例
#include <liburing.h>
int main() {
struct io_uring ring;
// 1. 初始化 io_uring(SQ 和 CQ 各 256 个条目)
io_uring_queue_init(256, &ring, 0);
int fd = open("data.bin", O_RDONLY);
char buf[4096];
// 2. 准备一个异步读请求
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, 4096, 0);
// 可以设置用户数据,用于在完成时识别请求
io_uring_sqe_set_data(sqe, (void*)"my_read");
// 3. 提交请求(一次系统调用可以提交多个 SQE)
io_uring_submit(&ring);
// 4. 等待完成
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
// 5. 处理结果
if (cqe->res >= 0) {
printf("读取了 %d 字节\n", cqe->res);
} else {
printf("读取失败: %s\n", strerror(-cqe->res));
}
// 6. 清理
io_uring_cqe_seen(&ring, cqe);
io_uring_queue_exit(&ring);
close(fd);
return 0;
}

io_uring 的高级特性#

  • SQPOLL:内核轮询模式,由一个内核线程持续轮询 SQ,应用无需发起 io_uring_enter 系统调用——完全零系统调用的 I/O
  • 固定文件/缓冲区注册:通过 IORING_REGISTER_FILES / IORING_REGISTER_BUFFERS 预先注册文件描述符和缓冲区,避免每次 I/O 的文件引用计数和页表操作
  • 链式请求:通过 IOSQE_IO_LINK 标志,可以将多个 I/O 操作串联起来,前一个完成后才执行下一个
  • 超时和取消:支持为请求设置超时,或主动取消正在执行的请求
  • NAPI 轮询:对于网络 I/O,可以与 NAPI 配合减少中断开销
Tip

io_uring 不仅限于文件 I/O,它支持的操作类型不断扩展:文件读写、fsync、openat、close、statx、fallocate、网络 send/recv、timeout、signal、epoll_ctl 等。它正在成为 Linux 的通用异步系统调用接口

九、预读机制深入#

8.1 预读的状态机#

Linux 的预读机制实现为一个隐式的状态机,根据进程的读模式动态调整预读行为:

初始状态 (无预读历史)
│ 第一次缓存未命中
同步预读 (初始窗口 = 4 页)
│ 顺序读命中预读窗口
异步预读 (窗口逐步增大: 4→8→16→...→128 页)
│ 继续顺序读
稳态预读 (窗口 = ra_pages,通常 128 页 = 512KB)
│ 检测到随机读
禁用预读 (窗口 = 0 或 1 页)

预读窗口的最大值由 file_ra_state->ra_pages 控制,默认值可以通过 /proc/sys/vm/readahead_ratio 或在块设备层设置:

# 查看块设备的预读设置(单位:512 字节扇区数)
cat /sys/block/sda/queue/read_ahead_kb
# 通常是 128 (KB),即 256 个扇区

8.2 预读与 mmap 的交互#

mmap 也会触发预读,但机制略有不同。当进程首次访问 mmap 映射的页面时,触发缺页异常,内核在 filemap_fault() 中处理:

mm/filemap.c
vm_fault_t filemap_fault(struct vm_fault *vmf)
{
// 1. 在 Page Cache 中查找页
page = pagecache_get_page(mapping, index, ...);
if (page) {
// 缓存命中,但可能触发异步预读
if (PageReadahead(page))
page_cache_async_readahead(mapping, ra, filp, page, index);
} else {
// 缓存未命中,触发同步预读
page_cache_sync_readahead(mapping, ra, filp, index);
}
// ...
}

mmap 的预读与普通 read 的预读共享同一个 file_ra_state,因此内核可以统一跟踪文件的访问模式。

十、动手实践#

实践 1:观察 Page Cache 大小#

# 查看 Page Cache 占用(Cached 列)
cat /proc/meminfo | grep -E "Cached|Buffers|Dirty|Writeback"
# 输出示例:
# Cached: 8234567 kB ← Page Cache 大小
# Buffers: 123456 kB ← Buffer Cache(元数据缓存)
# Dirty: 12345 kB ← 脏页大小
# Writeback: 0 kB ← 正在写回的页
# 用 free 命令更直观地查看
free -h
# total used free shared buff/cache available
# Mem: 16Gi 6.2Gi 1.5Gi 256Mi 8.3Gi 9.1Gi
# ↑ buff/cache 包含 Page Cache

实践 2:用 vmtouch 查看文件的缓存状态#

# 安装 vmtouch
sudo apt install vmtouch # Debian/Ubuntu
# 或从源码编译:https://github.com/hoytech/vmtouch
# 查看文件在 Page Cache 中的缓存情况
vmtouch /var/log/syslog
# 输出示例:
# Files: 1
# Directories: 0
# Resident Pages: 245/1024 (957K/3M 23.9%)
# Elapsed: 0.000345 seconds
# ↑ 23.9% 的页面在 Page Cache 中
# 将文件全部加载到 Page Cache
vmtouch -t /var/log/syslog
# 将文件从 Page Cache 中驱逐
vmtouch -e /var/log/syslog

实践 3:手动清除 Page Cache#

# 警告:生产环境慎用!清除 Page Cache 会导致性能骤降
# 清除 Page Cache(仅干净页)
echo 1 > /proc/sys/vm/drop_caches
# 清除 dentry 和 inode 缓存
echo 2 > /proc/sys/vm/drop_caches
# 清除 Page Cache + dentry + inode 缓存
echo 3 > /proc/sys/vm/drop_caches
# 注意:drop_caches 只会清除干净页(非脏页)
# 如果有脏页,需要先 sync 或等待写回完成
sync && echo 3 > /proc/sys/vm/drop_caches

实践 4:用 dd 测试 Direct I/O#

# 普通 buffered I/O 写入(经过 Page Cache)
dd if=/dev/zero of=/tmp/buffered.bin bs=4K count=10000
# 观察写入速度和 Page Cache 变化
# Direct I/O 写入(绕过 Page Cache)
dd if=/dev/zero of=/tmp/direct.bin bs=4K count=10000 oflag=direct
# 对比速度差异
# Direct I/O 读取
dd if=/tmp/direct.bin of=/dev/null bs=4K count=10000 iflag=direct
# 测试不对齐的 Direct I/O(会失败)
dd if=/dev/zero of=/tmp/bad_direct.bin bs=1000 count=10 oflag=direct
# dd: error writing '/tmp/bad_direct.bin': Invalid argument

实践 5:调整脏页写回参数#

# 查看当前脏页参数
cat /proc/sys/vm/dirty_ratio # 默认 20
cat /proc/sys/vm/dirty_background_ratio # 默认 10
cat /proc/sys/vm/dirty_writeback_centisecs # 默认 500(5秒)
cat /proc/sys/vm/dirty_expire_centisecs # 默认 3000(30秒)
# 临时调低脏页比例(更频繁地写回,减少断电数据丢失风险)
sudo sysctl -w vm.dirty_ratio=10
sudo sysctl -w vm.dirty_background_ratio=5
# 临时调高脏页比例(减少写回频率,提升写入吞吐,但断电风险更大)
sudo sysctl -w vm.dirty_ratio=40
sudo sysctl -w vm.dirty_background_ratio=20
# 观察脏页变化
watch -n 1 "cat /proc/meminfo | grep -E 'Dirty|Writeback'"

实践 6:用 strace 追踪 fsync 行为#

# 编写一个简单的测试程序
cat > /tmp/test_fsync.c << 'EOF'
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main() {
int fd = open("/tmp/fsync_test.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
write(fd, "hello world\n", 12);
fsync(fd); // 数据 + 元数据
write(fd, "hello again\n", 12);
fdatasync(fd); // 仅数据
close(fd);
return 0;
}
EOF
gcc -o /tmp/test_fsync /tmp/test_fsync.c
# 用 strace 追踪
strace -e trace=write,fsync,fdatasync,sync /tmp/test_fsync
# 输出:
# write(3, "hello world\n", 12) = 12
# fsync(3) = 0
# write(3, "hello again\n", 12) = 12
# fdatasync(3) = 0

实践 7:io_uring 性能对比#

# 安装 liburing
sudo apt install liburing-dev
# 编写 io_uring vs 普通 read 的基准测试
cat > /tmp/io_uring_bench.c << 'EOF'
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <time.h>
#include <liburing.h>
#define FILE_SIZE (64 * 1024 * 1024) // 64MB
#define BLOCK_SIZE 4096
double now_sec() {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec + ts.tv_nsec * 1e-9;
}
int main() {
int fd = open("/tmp/testdata.bin", O_RDONLY | O_DIRECT);
if (fd < 0) { perror("open"); return 1; }
void *buf;
posix_memalign(&buf, 4096, BLOCK_SIZE);
// === 普通 pread ===
double t1 = now_sec();
for (off_t off = 0; off < FILE_SIZE; off += BLOCK_SIZE) {
pread(fd, buf, BLOCK_SIZE, off);
}
double t2 = now_sec();
printf("pread: %.3f 秒 (%.1f MB/s)\n", t2 - t1, FILE_SIZE / (t2 - t1) / 1e6);
// === io_uring ===
struct io_uring ring;
io_uring_queue_init(256, &ring, 0);
double t3 = now_sec();
off_t offset = 0;
int pending = 0;
while (offset < FILE_SIZE || pending > 0) {
// 提交请求
while (offset < FILE_SIZE && pending < 256) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, BLOCK_SIZE, offset);
io_uring_sqe_set_data64(sqe, offset);
offset += BLOCK_SIZE;
pending++;
}
io_uring_submit(&ring);
// 收割完成事件
struct io_uring_cqe *cqe;
while (pending > 0) {
if (io_uring_peek_cqe(&ring, &cqe) < 0) break;
io_uring_cqe_seen(&ring, cqe);
pending--;
}
}
double t4 = now_sec();
printf("io_uring: %.3f 秒 (%.1f MB/s)\n", t4 - t3, FILE_SIZE / (t4 - t3) / 1e6);
io_uring_queue_exit(&ring);
close(fd);
free(buf);
return 0;
}
EOF
gcc -o /tmp/io_uring_bench /tmp/io_uring_bench.c -luring
# 先创建测试文件
dd if=/dev/urandom of=/tmp/testdata.bin bs=1M count=64
# 运行基准测试
/tmp/io_uring_bench

实践 8:观察写回线程行为#

# 查看写回线程
ps -ef | grep -E "flush|kworker.*writeback"
# 使用 perf 追踪写回事件
sudo perf record -e 'writeback:*' -a sleep 10
sudo perf script
# 或使用 bpftrace 实时观察
sudo bpftrace -e '
tracepoint:writeback:writeback_dirty_page {
printf("脏页: ino=%lu index=%lu\n", args->ino, args->index);
}
tracepoint:writeback:writeback_mark_inode_dirty {
printf("标记脏 inode: ino=%lu\n", args->ino);
}
'

总结与关键要点#

本章从 Page Cache 的核心数据结构出发,追踪了 Linux I/O 路径的完整旅程。以下是关键要点回顾:

  1. Page Cache 是 Linux I/O 性能的基石address_space 及其 xarray 索引结构使得缓存查找极为高效,address_space_operations 则提供了文件系统级别的多态抽象。

  2. 读路径的核心是缓存命中/未命中:命中时零磁盘 I/O,未命中时触发预读。预读机制通过状态机动态调整窗口大小,最大化顺序读性能。

  3. 写路径采用延迟写策略write() 只修改 Page Cache 并标记脏页,真正的磁盘写入由后台写回线程异步完成。dirty_ratiodirty_background_ratio 控制写回的触发时机。

  4. fsync 是数据持久化的保障fsync 确保数据和元数据落盘,fdatasync 跳过不必要的元数据写回。数据库系统依赖这些系统调用保证事务的 ACID 特性。

  5. Direct I/O 和 io_uring 代表了 I/O 的两个方向:Direct I/O 让应用完全控制 I/O 行为(绕过缓存),io_uring 则提供了极致的异步 I/O 性能(零拷贝、零系统调用)。两者可以组合使用——O_DIRECT + io_uring 是高性能 I/O 的黄金组合。

参考资料#

内核源码#

文件内容
mm/filemap.cPage Cache 核心实现,读路径 generic_file_buffered_read(),写路径 generic_perform_write()
mm/readahead.c预读机制实现,page_cache_sync_readahead() / page_cache_async_readahead()
mm/page-writeback.c脏页跟踪与写回控制,balance_dirty_pages(),脏页比例计算
fs/fs-writeback.c写回线程核心逻辑,wb_writeback(),inode 脏页列表管理
fs/direct-io.cDirect I/O 旧路径实现
fs/iomap/Direct I/O 新路径(iomap 框架),现代文件系统推荐使用
io_uring/io_uring.cio_uring 核心实现
io_uring/rw.cio_uring 读写操作
include/linux/fs.hstruct address_spacestruct address_space_operations 定义
include/linux/pagemap.hPage Cache 辅助函数

权威书籍与文档#

在线资源#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

页缓存与 I/O 路径
https://blog.souloss.com/posts/linux-internals/page-cache-and-io/
作者
Souloss
发布于
2025-01-09
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时