Linux IO 栈深度解析:从 VFS 到块设备

存储 IO 是操作系统中最复杂也最影响性能的子系统之一。一次看似简单的文件读写,实际上会穿越用户空间、虚拟文件系统、页缓存、块层、设备驱动直至物理硬件多个层级。理解 Linux IO 栈的完整路径,是诊断性能瓶颈、优化数据库响应、以及合理选型存储方案的前提。

存储 IO 是操作系统中最复杂也最影响性能的子系统之一。一次看似简单的文件读写,实际上会穿越用户空间、虚拟文件系统、页缓存、块层、设备驱动直至物理硬件多个层级。理解 Linux IO 栈的完整路径,是诊断性能瓶颈、优化数据库响应、以及合理选型存储方案的前提。本文将从上到下拆解整个 IO 栈,对比不同 IO 模式的适用场景,并深入 Page Cache、块层调度、NVMe 多队列以及 io_uring 等核心机制。

一、Linux IO 架构全景

Linux 的 IO 路径可以抽象为一条自上而下的流水线:

┌─────────────────────────────────────────────────┐
│  用户空间:read()/write()/mmap() / fsync()      │
├─────────────────────────────────────────────────┤
│  VFS 层:通用文件系统抽象(inode、file、dentry)│
├─────────────────────────────────────────────────┤
│  具体文件系统:ext4 / XFS / Btrfs / F2FS        │
├─────────────────────────────────────────────────┤
│  Page Cache:文件页缓存(以页为单位缓存数据)   │
├─────────────────────────────────────────────────┤
│  块层(Block Layer):IO 合并、调度、请求队列   │
├─────────────────────────────────────────────────┤
│  设备驱动:SCSI / SATA / NVMe 驱动              │
├─────────────────────────────────────────────────┤
│  硬件:HDD / SSD / NVMe                         │
└─────────────────────────────────────────────────┘

用户进程通过系统调用进入内核,VFS 提供统一抽象,使上层无需关心底层是 ext4 还是 XFS。数据在 Page Cache 中以页(通常 4KB)为单位缓存;若未命中缓存,则向下进入块层,由 IO 调度器组织请求,最终经驱动达到物理设备。这个架构的每一层都留下了优化和监控的空间。

二、IO 模式对比:Buffered、Direct 与 mmap

Linux 提供了三种主流的 IO 访问方式,它们在数据拷贝次数、缓存利用率和编程模型上差异显著:

特性Buffered IODirect IOmmap
是否经过 Page Cache是(按需加载)
用户态数据拷贝2 次(磁盘→页缓存→用户空间)1 次(磁盘→用户缓冲区)0 次(直接访问映射内存)
缓冲区对齐要求必须按扇区/页对齐无(由内核管理)
适用场景通用文件读写数据库、自管理缓存大文件共享、进程间通信
复杂度中高(需处理对齐)中(需处理缺页、同步)

Buffered IO 是默认模式。数据先读入 Page Cache,再拷贝到用户空间;写入时数据写入 Page Cache 即返回,真正的落盘由内核后台线程异步完成。这种方式充分利用了内核的预读和缓存管理能力,适合绝大多数应用。

Direct IO 通过 O_DIRECT 标志打开文件,绕过 Page Cache,要求用户缓冲区按设备扇区大小(通常 512B 或 4KB)对齐。数据库如 MySQL InnoDB、PostgreSQL 通常选择 Direct IO,因为它们自身实现了更精细的用户态缓冲池(Buffer Pool),不想再让内核 Page Cache 多一层管理。

mmap 将文件映射到进程的虚拟地址空间,用户通过访问内存的方式读写文件,无需显式调用 read()/write()。内核会在首次访问时触发缺页中断,将对应页从磁盘加载到 Page Cache。mmap 适合大文件的随机访问和进程间共享数据,但需要注意 SIGBUS 等缺页异常以及数据持久化时的 msync() 调用。

三、Page Cache:读写路径与写回策略

Page Cache 是 Linux 文件 IO 性能的核心支柱。它缓存的是文件页(file pages),而不是原始磁盘块。这意味着缓存语义与文件路径和偏移量绑定,而非设备号与扇区号。

读路径

当进程调用 read() 时,内核首先检查 Page Cache:

  1. 缓存命中:目标页已在缓存中,直接将其内容拷贝到用户空间缓冲区,无需访问磁盘。
  2. 缓存未命中:内核分配一个空白页,发起真正的块设备读请求;数据从磁盘读入页缓存后,再拷贝到用户空间。同时,内核可能根据读取模式触发预读(read-ahead),将后续相邻页提前载入缓存,降低后续读取延迟。

写路径

write() 系统调用的语义是异步且缓冲的:

  1. 数据从用户空间拷贝到 Page Cache 中的对应页。
  2. 该页被标记为脏页(dirty page)。
  3. 系统调用返回,此时数据尚未落盘。

脏页的持久化由写回(write-back)机制负责。内核线程 pdflush(旧内核)或 flush-设备号(新内核,per-BDI)周期性地扫描脏页,将其刷写到块设备。写回触发条件涉及多个 sysctl 阈值:

  • dirty_ratio / dirty_background_ratio:脏页占可用内存的比例上限,达到后会主动或后台刷盘。
  • dirty_expire_centisecs:脏页在内存中最长允许停留的时间。

进程可以通过 sync() 同步全局文件系统缓存,fsync() 同步某个文件的全部数据和元数据,fdatasync() 仅同步数据而跳过元数据(除非文件大小发生变化)。数据库写前日志(WAL)的 fsync() 调用是保障事务持久性的关键,但频繁 fsync 会导致显著的 IO 延迟。

缓存回收

当可用内存不足时,Page Cache 的回收基于 LRU(Least Recently Used)近似算法。内核维护活跃链表(active list)和非活跃链表(inactive list),优先淘汰不常访问的页。匿名页(堆内存)若无交换空间则无法轻易丢弃,而文件页因为有磁盘上的“干净的”备份,回收代价更低,因此系统倾向于保留匿名页、回收文件页。

四、块层与 IO 调度

块层是连接文件系统和设备驱动的枢纽,负责将上层发出的 BIO(Block IO)结构合并、排序并发送到驱动。

IO 调度算法

传统块层使用请求队列(request queue)和电梯调度算法来优化磁盘访问模式:

  • Noop:不做复杂调度,基本按 FIFO 顺序合并相邻请求后下发。逻辑简单,适用于 SSD、NVMe 等无机械寻道延迟的设备。
  • CFQ(Completely Fair Queuing):为每个进程维护独立的 IO 队列,按时间片轮转分配磁盘带宽,追求 IO 公平性。曾在台式机和多用户服务器中流行,但在高并发和低延迟场景下开销较大。
  • Deadline:为每个请求设置读 500ms、写 5s 的截止期限,防止某些请求被无限期饥饿。同时维护按扇区排序的队列和按截止时间排序的队列,平衡吞吐与延迟,适合混合读写负载。
  • BFQ(Budget Fair Queuing):基于 CFQ 改进,将 IO 带宽按“预算”分配给进程,对交互式应用(如桌面环境的编辑器)非常友好,能获得较低的响应延迟。

blk-mq 与 NVMe 时代

传统块层的单队列设计(一个 request queue 伴随一个锁)在 CPU 核心数增多和 SSD 并发能力爆发式增长后成为瓶颈。blk-mq(multi-queue block layer)引入硬件队列与软件队列分离的架构,每个 CPU 核心可将 IO 提交到本地软件队列,再映射到设备的多个硬件队列,大幅减少锁竞争。

NVMe 设备原生支持 64K 级别的并发队列,每个队列深度可达 64K。blk-mq 的设计与 NVMe 的多队列特性天然契合,因此现代系统在 NVMe SSD 上通常默认采用 none(即 Noop 等价)调度策略——硬件足够快,软件调度反而增加延迟。

五、NVMe 与现代存储架构

NVMe(Non-Volatile Memory Express)重新定义了主机与存储设备之间的通信协议:

  • Queue Pairs:每个核心可独享一对提交队列(Submission Queue, SQ)和完成队列(Completion Queue, CQ)。SQ 用于主机向控制器发送命令,CQ 用于控制器向主机回报完成状态,二者解耦后可并行工作。
  • MSI-X 中断:支持每个 CQ 绑定独立中断,实现 CPU 亲和性,避免单一中断线成为瓶颈。
  • 协议精简:NVMe 的命令集精简到十几条,相比 SCSI/ATA 的数百条命令大幅降低了处理开销。NVMe 直接通过 PCIe 总线与主机通信,无需经过传统 SATA/SAS 控制器堆栈。

在 Linux 中,NVMe 驱动直接与 blk-mq 对接,绕过 SCSI 中层(除非使用 SCSI 兼容模式)。对于追求极致性能的场景,SPDK(Storage Performance Development Kit) 提供用户态的 NVMe 轮询驱动,完全绕过内核块层和中断机制;NVMe-oF(NVMe over Fabrics)则将这些高性能语义扩展到网络,使远程 NVMe 子系统的延迟接近本地访问。

六、异步 IO:从 POSIX AIO 到 io_uring

同步 IO 让进程在数据就绪或写入完成前阻塞,而异步 IO 允许进程提交请求后继续执行其他逻辑,待 IO 完成后收到通知。

  • POSIX AIO:Linux 的实现基于线程池模拟,且仅对 O_DIRECT 文件有效,生态支持差,实际生产中使用较少。
  • Linux Native AIO:通过 io_setup / io_submit / io_getevents 接口实现,仍需配合 O_DIRECT,功能受限,且接口设计不够自然。
  • io_uring:Linux 5.1 引入的下一代异步 IO 框架,设计目标是统一网络与磁盘 IO 的异步模型。

io_uring 的核心创新是共享内存中的环形缓冲区

  1. Submission Queue Ring(SQ)Completion Queue Ring(CQ) 通过 mmap 映射到用户空间。
  2. 用户进程将 IO 请求写入 SQ,通过一次 io_uring_enter 系统调用通知内核(或在 polling 模式下完全无需系统调用)。
  3. 内核完成 IO 后,将结果写入 CQ;用户进程通过读取 CQ 消费完成事件。

io_uring 支持批量提交(batch submit)和批量收割(batch reap),在 polling 模式下甚至可实现零系统调用的纯用户态 IO 处理。由于它同时支持 buffered 和 direct IO,且在网络(socket)IO 上也表现优异,io_uring 正在逐步取代 epoll + 同步读写 和旧的 AIO 方案,成为高吞吐服务器(如下一代 Nginx、PostgreSQL)的首选异步 IO 机制。

七、性能分析与基准测试

理解 IO 栈后,需要工具将性能指标量化。常用工具组合如下:

  • iostat:报告 CPU 利用率与设备级别的 IO 统计,关键指标包括 r/s(每秒读请求)、w/s(每秒写请求)、rkB/swkB/sawait(平均等待时间)、%util(设备饱和度)。注意 %util 对 NVMe 等多队列设备意义有限,因为它只反映队列忙碌程度,不代表真实吞吐上限。
  • iotop:按进程维度展示实时 IO 带宽,适合快速定位 IO 大户。
  • blktrace:在块层捕获每个 BIO 的详细生命周期——何时入队、何时被调度、何时驱动下发、何时完成,是分析 IO 抖动和调度器行为的最精细工具。
  • fio(Flexible IO Tester):工业标准的 IO 基准测试工具。通过参数组合可模拟多种负载:
    • --rw=randread / --rw=randwrite / --rw=randrw:随机读、随机写、混合读写
    • --bs=4k / --bs=1m:块大小,模拟小文件或大文件场景
    • --iodepth=32:队列深度,测试设备并发能力
    • --direct=1:启用 Direct IO,绕过 Page Cache
    • 输出关注 IOPS(每秒 IO 数)、带宽(MB/s)以及延迟分布(slat、clat、lat)。

分析 fio 结果时,IOPS 与带宽的换算关系为:带宽 = IOPS × 块大小。随机小 IO 主要考察 IOPS 和延迟,顺序大 IO 则看带宽。混合读写测试更能反映真实业务负载下的设备表现。

总结

Linux IO 栈是一个层次分明、不断演进的架构。Page Cache 以极低的成本掩盖了磁盘延迟,Direct IO 和 mmap 为特定负载提供了更细粒度的控制。块层的 IO 调度器从旋转磁盘的seek优化,演进为 NVMe 时代的低延迟直通。io_uring 的出现正在重塑异步编程范式,使单机 IO 吞吐逼近硬件理论上限。理解这些层次之间的交互与权衡,是构建高性能、可预测的存储系统的基石。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「os」更多文章

  1. 进程与线程:从 PCB 到内核调度实体
  2. 虚拟内存与分页机制:从 MMU 到 TLB
  3. 系统性能诊断与调优:strace、perf、bpftrace