一、引言
在上一章中,追踪了一条虚拟地址如何通过页表翻译为物理地址——那是 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() 到磁盘”的每一步都了如指掌。
二、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,为后续访问做准备。
Page Cache 使用的是匿名内存之外的物理内存。当系统内存紧张时,Page Cache 页可以被直接丢弃(干净页)或写回磁盘后丢弃(脏页),而不需要像匿名页那样换出到 swap。这也是为什么 Linux 总是倾向于把空闲内存用作 Page Cache——“空闲的内存就是浪费的内存”。
1.2 address_space:Page Cache 的组织核心
Page Cache 的核心数据结构是 struct address_space,每个 inode 都关联一个 address_space,它管理着该文件在内存中的所有缓存页:
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 与底层文件系统之间的”协议”,它定义了一组操作函数:
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等辅助函数中
理解 buffer_head 的历史有助于阅读旧版内核代码,但在现代内核开发中,你应该关注 bio 和 struct folio。Linux 6.x 正在将 Page Cache 从 struct page 迁移到 struct folio(一个 folio 可以包含多个连续 page),以减少内存管理开销。
三、读路径:从 read() 到数据返回
2.1 完整读路径流程
当用户程序调用 read() 时,数据经历以下旅程:
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()关键步骤解析:
- Page Cache 查找:
generic_file_buffered_read()首先在mapping->i_pages中查找目标页。查找使用xarray的 RCU 安全接口,无需加锁即可完成快速查找。 - 缓存命中:如果页存在且是最新的(
PG_uptodate标志置位),直接调用copy_page_to_iter()将数据拷贝到用户缓冲区,无需任何磁盘 I/O。 - 缓存未命中:如果页不存在,触发同步预读(
page_cache_sync_readahead),分配新页并提交 I/O 请求从磁盘读取。进程在lock_page()上等待 I/O 完成。 - 数据拷贝:I/O 完成后,页被标记为
PG_uptodate,进程被唤醒,数据从页缓存拷贝到用户空间。
注意这里有一次数据拷贝:从内核的 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 - 上次读位置(用于检测顺序/随机)}预读的两种模式:
- 同步预读(sync readahead):当缓存未命中时触发,进程必须等待 I/O 完成。初始预读窗口较小(4 页)。
- 异步预读(async readahead):当进程读到预读窗口的边缘时触发,内核提前发起下一轮预读,进程无需等待。预读窗口逐步增大。
预读算法通过 file_ra_state 结构跟踪每个文件的读模式:
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 并标记为脏,不立即写回磁盘。真正的磁盘写入由后台写回线程异步完成。
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) → 唤醒后台写回线程关键步骤解析:
- write_begin:文件系统的
write_begin操作负责准备目标页。如果页不在 Page Cache 中,需要先分配并可能从磁盘读取原始数据(因为写入可能只修改页的一部分,需要先读出完整页再部分修改——这就是读-改-写模式)。 - 数据拷贝:
copy_from_iter()将用户空间的数据拷贝到 Page Cache 中的对应位置。注意,这里只有一次拷贝——从用户空间到内核页缓存。 - 标记脏页:
set_page_dirty()将页标记为脏(设置PG_dirty标志),并将其加入 inode 的脏页集合。此时数据仅存在于内存中,write()系统调用即可返回。 - 后台写回:内核的写回线程会在合适的时机将脏页写回磁盘。
延迟写的设计带来了巨大的性能优势:多次对同一页的小写入只产生一次磁盘 I/O;对同一文件的多个进程的写入可以被合并;应用程序无需等待慢速的磁盘操作。但代价是数据在 write() 返回后可能尚未落盘——如果此时系统断电,数据将丢失。这就是 fsync() 存在的意义。
五、脏页写回机制
4.1 何时触发写回?
Linux 内核通过三个条件触发脏页写回:
| 触发条件 | 相关参数 | 默认值 | 含义 |
|---|---|---|---|
| 脏页比例超限 | vm.dirty_ratio | 20(%物理内存) | 脏页占总内存的比例上限,超限后写操作被阻塞 |
| 后台写回阈值 | vm.dirty_background_ratio | 10(%物理内存) | 脏页达到此比例时,后台写回线程开始工作 |
| 定时写回 | dirty_writeback_interval | 500(厘秒 = 5秒) | 每隔此时间,写回线程醒来扫描脏页 |
# 查看当前写回参数cat /proc/sys/vm/dirty_ratiocat /proc/sys/vm/dirty_background_ratiocat /proc/sys/vm/dirty_writeback_interval
# 也可以用字节而非百分比设置cat /proc/sys/vm/dirty_bytescat /proc/sys/vm/dirty_background_bytes三个条件的协作方式:
- 正常情况:脏页比例低于
dirty_background_ratio,写回线程休眠,write()立即返回。 - 后台写回:脏页比例超过
dirty_background_ratio,flush/kworker线程被唤醒,在后台异步写回脏页,write()仍立即返回。 - 阻塞写回:脏页比例超过
dirty_ratio,新的write()调用将被阻塞,调用者被迫等待脏页写回——这是内核的”紧急刹车”,防止内存被脏页耗尽。
4.2 flush/kworker 写回线程
Linux 内核使用专门的内核线程负责脏页写回:
flush-x:y线程:每个块设备一个,负责该设备上的脏页写回。在较新的内核中,这些线程由kworker工作队列实现。kworker线程:通用内核工作队列线程,也承担定时写回任务。
写回线程的核心逻辑在 mm/page-writeback.c 和 fs/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 进行中或正在被修改)
一个页可以同时处于 PG_dirty 和 PG_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 的核心工作是确保指定文件的所有脏页都写回磁盘,并且磁盘确认写入完成(即绕过磁盘的写缓存):
SYSCALL_DEFINE1(fsync, unsigned int, fd) → do_fsync(fd, 1) → vfs_fsync(fd, 1) → vfs_fsync_range(file, 0, LLONG_MAX, 1)
// fs/sync.cint 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;}fsync 与 fdatasync 的关键区别:
fsync会同步文件的元数据(inode 中的mtime、ctime、size等),确保文件系统的完整性fdatasync只同步文件数据,跳过元数据的写回——除非文件大小发生了变化(此时必须更新元数据,否则数据不可达)
对于大文件追加写入的场景,fdatasync 比 fsync 少一次元数据 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_DIRECTint 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()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 AIO(io_setup/io_submit/io_getevents),但它存在严重的设计缺陷:
- 仅支持 O_DIRECT:对于 buffered I/O,
io_submit会退化为同步操作——提交时即阻塞等待完成,完全失去了”异步”的意义 - API 复杂:需要先
io_setup创建上下文,再io_submit提交 I/O,最后io_getevents获取完成事件,每次系统调用都有开销 - 拷贝开销:I/O 提交和完成事件都需要在用户空间和内核之间拷贝数据
- 不可扩展:每个 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 AIO | io_uring |
|---|---|---|
| Buffered I/O 支持 | 退化为同步 | 真正异步 |
| 系统调用次数 | 每个请求至少 1 次 | 批量提交,可零系统调用 |
| 数据拷贝 | 需要拷贝 | 共享内存,零拷贝 |
| 可扩展性 | 受限 | 极高 |
| 操作类型 | 仅 read/write | read/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 配合减少中断开销
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() 中处理:
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 查看文件的缓存状态
# 安装 vmtouchsudo 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 Cachevmtouch -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 # 默认 20cat /proc/sys/vm/dirty_background_ratio # 默认 10cat /proc/sys/vm/dirty_writeback_centisecs # 默认 500(5秒)cat /proc/sys/vm/dirty_expire_centisecs # 默认 3000(30秒)
# 临时调低脏页比例(更频繁地写回,减少断电数据丢失风险)sudo sysctl -w vm.dirty_ratio=10sudo sysctl -w vm.dirty_background_ratio=5
# 临时调高脏页比例(减少写回频率,提升写入吞吐,但断电风险更大)sudo sysctl -w vm.dirty_ratio=40sudo 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 性能对比
# 安装 liburingsudo 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 10sudo 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 路径的完整旅程。以下是关键要点回顾:
-
Page Cache 是 Linux I/O 性能的基石:
address_space及其 xarray 索引结构使得缓存查找极为高效,address_space_operations则提供了文件系统级别的多态抽象。 -
读路径的核心是缓存命中/未命中:命中时零磁盘 I/O,未命中时触发预读。预读机制通过状态机动态调整窗口大小,最大化顺序读性能。
-
写路径采用延迟写策略:
write()只修改 Page Cache 并标记脏页,真正的磁盘写入由后台写回线程异步完成。dirty_ratio和dirty_background_ratio控制写回的触发时机。 -
fsync 是数据持久化的保障:
fsync确保数据和元数据落盘,fdatasync跳过不必要的元数据写回。数据库系统依赖这些系统调用保证事务的 ACID 特性。 -
Direct I/O 和 io_uring 代表了 I/O 的两个方向:Direct I/O 让应用完全控制 I/O 行为(绕过缓存),io_uring 则提供了极致的异步 I/O 性能(零拷贝、零系统调用)。两者可以组合使用——
O_DIRECT+io_uring是高性能 I/O 的黄金组合。
参考资料
内核源码
| 文件 | 内容 |
|---|---|
mm/filemap.c | Page 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.c | Direct I/O 旧路径实现 |
fs/iomap/ | Direct I/O 新路径(iomap 框架),现代文件系统推荐使用 |
io_uring/io_uring.c | io_uring 核心实现 |
io_uring/rw.c | io_uring 读写操作 |
include/linux/fs.h | struct address_space、struct address_space_operations 定义 |
include/linux/pagemap.h | Page Cache 辅助函数 |
权威书籍与文档
- 《Understanding the Linux Kernel》(Daniel P. Bovet 等)— 第 15、16 章对 Page Cache 和 I/O 有详细描述
- 《Linux Kernel Development》(Robert Love)— 第 13、14 章涵盖页缓存与 I/O
- 《Professional Linux Kernel Architecture》(Wolfgang Mauerer)— 深入分析 address_space 和写回机制
- Linux 内核官方文档 — io_uring — io_uring 的权威文档
- io_uring 源码仓库与示例 — Jens Axboe 维护的 liburing 库
- LWN.net: The page cache — LWN 对 Page Cache 的深度分析
- LWN.net: The writeback cache — LWN 对写回机制的分析
- Efficient IO with io_uring (PDF) — Jens Axboe 的 io_uring 设计文档
在线资源
- Bootlin Elixir Cross Referencer — 在线浏览内核源码
- Linux Kernel Newbies — 内核开发入门
- Brendan Gregg’s Linux Performance — I/O 性能分析方法
- kernel.org vm documentation — 内存管理文档
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






