1. 概述
1.1 Linux I/O 的定义
**I/O(Input/Output)**是计算机系统中数据输入和输出的过程。I/O 操作是计算机系统最基础也是最重要的功能之一,它连接了用户空间应用程序和底层硬件设备,使得应用程序能够与外部世界进行数据交互。
在 Linux 系统中,I/O 操作涉及多个层面和多种类型:
-
文件 I/O:读写文件系统上的文件
- 这是最常见的 I/O 操作类型,应用程序通过文件 I/O 读写存储在磁盘上的文件
- 文件 I/O 涉及文件系统、块设备层、设备驱动等多个层次
- 系统通过页缓存机制优化文件 I/O 性能,减少实际的磁盘访问次数
-
设备 I/O:与硬件设备交互(磁盘、网络、串口等)
- 设备 I/O 是应用程序与硬件设备直接交互的方式
- 不同类型的设备有不同的 I/O 特性:块设备(如硬盘)以固定大小的块为单位,字符设备(如串口)以字节流为单位
- 设备 I/O 需要通过设备驱动来访问硬件,驱动负责将应用程序的 I/O 请求转换为硬件能够理解的控制信号
-
网络 I/O:通过网络进行数据传输
- 网络 I/O 是分布式系统和网络应用的基础
- 网络 I/O 涉及网络协议栈的多个层次:应用层、传输层(TCP/UDP)、网络层(IP)、数据链路层、物理层
- 网络 I/O 通常是异步的,需要事件驱动机制(如 epoll)来高效处理大量并发连接
-
内存 I/O:内存映射文件、共享内存等
- 内存 I/O 通过将文件映射到进程的虚拟地址空间,实现直接内存访问
- 内存映射 I/O 避免了传统 read/write 系统调用的开销,提高了 I/O 性能
- 共享内存是进程间通信的一种高效方式,多个进程可以共享同一块内存区域
1.2 Linux I/O 的分类
按数据流向分类:
I/O 操作根据数据流向可以分为输入(Input)和输出(Output)两大类。这种分类方式反映了数据在系统中的流动方向,是理解 I/O 操作的基础。
markdown
输入(Input):
├─ 从文件读取数据
│ └─ 这是最常见的输入操作,应用程序从磁盘文件读取数据到内存
│ └─ 数据流向:磁盘 → 页缓存 → 用户空间缓冲区
│
├─ 从网络接收数据
│ └─ 网络应用程序从网络接收数据包
│ └─ 数据流向:网络 → 网卡缓冲区 → 内核网络协议栈 → 用户空间缓冲区
│
├─ 从设备读取数据
│ └─ 从硬件设备(如传感器、串口)读取数据
│ └─ 数据流向:设备 → 设备驱动 → 内核缓冲区 → 用户空间缓冲区
│
└─ 从标准输入读取数据
└─ 从标准输入(通常是终端)读取用户输入
└─ 数据流向:终端 → 终端驱动 → 内核缓冲区 → 用户空间缓冲区
输出(Output):
├─ 向文件写入数据
│ └─ 应用程序将数据写入磁盘文件
│ └─ 数据流向:用户空间缓冲区 → 页缓存 → 磁盘
│
├─ 向网络发送数据
│ └─ 网络应用程序向网络发送数据包
│ └─ 数据流向:用户空间缓冲区 → 内核网络协议栈 → 网卡缓冲区 → 网络
│
├─ 向设备写入数据
│ └─ 向硬件设备(如显示器、串口)写入数据
│ └─ 数据流向:用户空间缓冲区 → 内核缓冲区 → 设备驱动 → 设备
│
└─ 向标准输出写入数据
└─ 向标准输出(通常是终端)写入数据
└─ 数据流向:用户空间缓冲区 → 终端驱动 → 终端
数据流向的重要性:
理解数据流向对于优化 I/O 性能至关重要。每个数据流向都涉及多个层次的数据传输,每个层次都可能成为性能瓶颈。例如,从文件读取数据时,如果数据在页缓存中,读取速度会非常快(纳秒级);如果数据不在页缓存中,需要从磁盘读取,速度会慢很多(毫秒级)。因此,理解数据流向可以帮助我们识别性能瓶颈并采取相应的优化措施。
按访问方式分类:
I/O 操作根据访问方式可以分为同步 I/O 和异步 I/O 两大类。这种分类方式反映了 I/O 操作与应用程序执行流程的关系,是选择 I/O 模型的基础。
css
同步 I/O:
├─ 阻塞 I/O(Blocking I/O)
│ └─ 这是最简单的 I/O 模型,也是最常用的模型
│ ├─ 特点:当应用程序调用 I/O 操作时,如果数据未就绪,进程会被阻塞,直到数据就绪或操作完成
│ ├─ 优点:编程简单,逻辑清晰,易于理解
│ ├─ 缺点:一个进程只能处理一个 I/O 操作,无法同时处理多个 I/O 操作
│ └─ 适用场景:简单的单线程应用程序,I/O 操作不频繁的场景
│
├─ 非阻塞 I/O(Non-blocking I/O)
│ └─ 非阻塞 I/O 是对阻塞 I/O 的改进
│ ├─ 特点:当应用程序调用 I/O 操作时,如果数据未就绪,立即返回错误(EAGAIN 或 EWOULDBLOCK),不会阻塞进程
│ ├─ 优点:进程不会被阻塞,可以继续执行其他操作
│ ├─ 缺点:需要轮询检查 I/O 是否就绪,浪费 CPU 资源
│ └─ 适用场景:需要同时处理多个 I/O 操作的场景,但 I/O 操作不频繁
│
└─ I/O 多路复用(I/O Multiplexing)
└─ I/O 多路复用是对非阻塞 I/O 的进一步改进
├─ 特点:使用 select、poll、epoll 等系统调用同时监控多个文件描述符,当任何一个就绪时返回
├─ 优点:一个进程可以同时处理多个 I/O 操作,不需要为每个 I/O 操作创建线程
├─ 缺点:编程复杂度较高,需要理解事件驱动模型
└─ 适用场景:高并发网络服务器,需要同时处理大量连接
异步 I/O:
├─ 信号驱动 I/O(Signal-driven I/O)
│ └─ 信号驱动 I/O 使用信号机制通知 I/O 就绪
│ ├─ 特点:应用程序发起 I/O 操作后立即返回,当 I/O 就绪时,内核发送信号(SIGIO)通知应用程序
│ ├─ 优点:不需要轮询,CPU 利用率高
│ ├─ 缺点:信号处理复杂,信号可能丢失,不适合高并发场景
│ └─ 适用场景:I/O 操作不频繁,对实时性要求不高的场景
│
└─ 异步 I/O(Asynchronous I/O,AIO)
└─ 异步 I/O 是真正的异步 I/O 模型
├─ 特点:应用程序发起 I/O 操作后立即返回,I/O 操作在后台执行,完成后通过回调函数或完成事件通知应用程序
├─ 优点:真正的异步,CPU 利用率最高,适合高并发场景
├─ 缺点:编程复杂度最高,需要理解异步编程模型
└─ 适用场景:高并发、高吞吐量的 I/O 密集型应用,如数据库、Web 服务器
同步 I/O 与异步 I/O 的本质区别:
同步 I/O 和异步 I/O 的本质区别在于 I/O 操作的完成时机。在同步 I/O 中,应用程序发起 I/O 操作后,必须等待 I/O 操作完成(无论是阻塞等待还是轮询等待)才能继续执行。在异步 I/O 中,应用程序发起 I/O 操作后立即返回,I/O 操作在后台执行,应用程序可以继续执行其他操作,当 I/O 操作完成时,通过回调函数或事件通知应用程序。
这种区别导致了不同的编程模型和性能特征。同步 I/O 适合简单的顺序处理逻辑,但无法充分利用系统资源。异步 I/O 可以充分利用系统资源,实现高并发和高吞吐量,但编程复杂度较高。
按缓存方式分类:
css
缓冲 I/O(Buffered I/O):
├─ 使用页缓存(Page Cache)
├─ 提高性能
└─ 延迟写入
直接 I/O(Direct I/O):
├─ 绕过页缓存
├─ 直接访问设备
└─ 适用于大文件或数据库
1.3 Linux I/O 的层次结构
Linux I/O 系统采用分层的架构设计,每一层都有明确的职责和接口,这种设计使得系统具有良好的可扩展性和可维护性。理解这个层次结构对于深入理解 Linux I/O 机制至关重要。
css
用户空间
├─ 这是应用程序运行的空间,应用程序通过系统调用接口与内核交互
├─ 应用程序不直接访问硬件,而是通过内核提供的接口访问
└─ 用户空间和内核空间是隔离的,这提供了安全性和稳定性
↓
系统调用接口(read, write, open, close)
├─ 这是用户空间和内核空间之间的桥梁
├─ 系统调用接口提供了一组标准化的函数,应用程序通过这些函数访问内核功能
├─ 系统调用接口隐藏了内核实现的复杂性,为应用程序提供了简单易用的接口
├─ 当应用程序调用系统调用时,CPU 从用户模式切换到内核模式,执行内核代码
└─ 系统调用接口的设计遵循 POSIX 标准,保证了应用程序的可移植性
↓
VFS(虚拟文件系统)
├─ VFS 是 Linux 内核的一个抽象层,它为所有文件系统提供了统一的接口
├─ VFS 的核心作用是隐藏不同文件系统的实现细节,使得应用程序可以用统一的方式访问不同的文件系统
├─ VFS 管理文件描述符、inode、dentry 等核心数据结构
├─ VFS 负责路径解析、权限检查、文件查找等通用功能
└─ 不同的文件系统(ext4、xfs、btrfs 等)只需要实现 VFS 定义的接口,就可以无缝集成到系统中
↓
文件系统(ext4, xfs, btrfs 等)
├─ 文件系统层负责将文件的逻辑结构映射到块设备的物理结构
├─ 不同的文件系统有不同的实现方式:ext4 使用 inode 和块映射,xfs 使用 B+ 树,btrfs 使用写时复制(COW)
├─ 文件系统层负责文件的数据组织、目录管理、元数据管理等
├─ 文件系统层与页缓存紧密配合,通过页缓存提高 I/O 性能
└─ 文件系统层将文件的逻辑地址转换为块设备的物理地址(扇区号)
↓
块设备层(Block Device Layer)
├─ 块设备层是文件系统和设备驱动之间的桥梁
├─ 块设备层将文件系统的 I/O 请求转换为块设备的 I/O 请求
├─ 块设备层使用 bio 结构表示 I/O 请求,bio 包含了 I/O 的所有信息:设备、地址、长度、方向等
├─ 块设备层负责 I/O 请求的管理、合并、拆分等操作
└─ 块设备层将 bio 提交到 I/O 调度器,由调度器决定何时以及如何执行 I/O 请求
↓
I/O 调度器(I/O Scheduler)
├─ I/O 调度器是块设备层和设备驱动之间的中间层
├─ I/O 调度器的主要作用是优化 I/O 性能,通过合并、排序、调度等策略减少磁盘寻道时间
├─ 不同的 I/O 调度器有不同的策略:CFQ 追求公平,Deadline 防止饥饿,NOOP 简单快速
├─ I/O 调度器将多个 I/O 请求组织成请求队列,按照调度策略选择下一个要执行的请求
└─ I/O 调度器对于机械硬盘特别重要,因为机械硬盘的寻道时间很长,合理的调度可以显著提高性能
↓
设备驱动(Device Driver)
├─ 设备驱动是内核与硬件设备之间的接口
├─ 设备驱动负责将 I/O 请求转换为硬件能够理解的控制信号
├─ 设备驱动管理设备的寄存器、中断、DMA 等硬件资源
├─ 设备驱动处理硬件的中断,将 I/O 完成事件通知上层
└─ 设备驱动是硬件相关的,不同的硬件需要不同的驱动
↓
硬件设备(Hardware Device)
├─ 硬件设备是实际执行 I/O 操作的物理设备
├─ 硬件设备包括磁盘、SSD、网卡、串口等各种 I/O 设备
├─ 硬件设备通过总线(如 PCI、USB、SATA)连接到系统
├─ 硬件设备执行实际的 I/O 操作:磁盘读取/写入数据,网卡发送/接收数据包等
└─ 硬件设备的性能直接影响整个 I/O 系统的性能
层次结构的设计原则:
Linux I/O 系统的层次结构设计遵循了几个重要原则:
-
分层抽象:每一层都向上层提供抽象的接口,隐藏下层的实现细节。这种设计使得每一层都可以独立演进,不影响其他层。
-
职责分离:每一层都有明确的职责,VFS 负责文件系统抽象,文件系统负责数据组织,块设备层负责 I/O 请求管理,I/O 调度器负责性能优化,设备驱动负责硬件交互。这种职责分离使得系统结构清晰,易于理解和维护。
-
可扩展性:通过定义标准的接口,系统可以轻松添加新的文件系统、新的 I/O 调度器、新的设备驱动,而不需要修改其他层的代码。
-
性能优化:每一层都可以进行针对性的性能优化,例如 VFS 的路径缓存、文件系统的预读、I/O 调度器的请求合并、设备驱动的 DMA 传输等。
理解这个层次结构对于深入理解 Linux I/O 机制至关重要。每个 I/O 操作都会经过这些层次,每一层都会对 I/O 操作进行处理和优化。
2. 系统调用接口
2.1 基本 I/O 系统调用
系统调用是用户空间应用程序与内核交互的唯一方式。Linux 提供了丰富的系统调用来支持各种 I/O 操作,这些系统调用封装了底层实现的复杂性,为应用程序提供了简单易用的接口。
open 系统调用:
open 系统调用是文件 I/O 的入口,它打开或创建一个文件,并返回一个文件描述符。文件描述符是后续所有文件 I/O 操作的标识符。
c
#include <fcntl.h>
#include <sys/stat.h>
int open(const char *pathname, int flags, mode_t mode);
open 的参数详细说明:
-
pathname:文件路径- 这是一个字符串,指定要打开或创建的文件路径
- 路径可以是绝对路径(如 "/home/user/file.txt")或相对路径(如 "file.txt")
- 内核会解析这个路径,查找对应的文件或目录
-
flags:打开标志- 这是一个位掩码,可以组合多个标志来控制打开文件的行为
O_RDONLY:只读模式。文件只能读取,不能写入。这是最常用的模式之一,适用于只需要读取文件的场景。O_WRONLY:只写模式。文件只能写入,不能读取。适用于只需要写入文件的场景,如日志文件。O_RDWR:读写模式。文件既可以读取也可以写入。这是最灵活的模式,适用于需要同时读写文件的场景。O_CREAT:如果文件不存在则创建。这个标志通常与O_WRONLY或O_RDWR一起使用,用于创建新文件。O_TRUNC:如果文件存在则截断。这个标志会将文件大小截断为 0,清空文件内容。通常用于覆盖现有文件。O_APPEND:追加模式。所有写入操作都会追加到文件末尾,而不是覆盖现有内容。这对于日志文件特别有用。O_NONBLOCK:非阻塞模式。打开文件后,后续的 I/O 操作不会阻塞进程。如果数据未就绪,立即返回错误。O_SYNC:同步写入。每次写入操作都会等待数据真正写入磁盘后才返回。这保证了数据的持久性,但会影响性能。O_DIRECT:直接 I/O。绕过页缓存,直接访问设备。这适用于大文件或数据库等需要自己管理缓存的场景。
-
mode:文件权限(仅在创建文件时使用)- 当使用
O_CREAT标志时,需要指定新创建文件的权限 - 权限使用八进制表示,如 0644 表示所有者可读写,组和其他用户可读
- 实际的文件权限会受到 umask 的影响,最终权限 = mode & ~umask
- 当使用
open 系统调用的工作流程:
-
路径解析:内核解析文件路径,查找对应的文件或目录。这涉及 VFS 的路径查找机制,可能需要遍历多个目录。
-
权限检查:内核检查当前进程是否有权限打开指定的文件。这包括文件系统级别的权限检查和文件本身的权限检查。
-
文件查找或创建 :如果文件存在,查找对应的 inode;如果文件不存在且指定了
O_CREAT,创建新文件。 -
分配文件描述符:内核为打开的文件分配一个文件描述符,这是进程文件描述符表中的索引。
-
初始化 file 结构:内核创建并初始化 file 结构,这个结构包含了文件的状态信息,如文件位置、打开标志等。
-
返回文件描述符:内核返回文件描述符给用户空间,应用程序使用这个文件描述符进行后续的 I/O 操作。
read 系统调用:
read 系统调用从文件描述符读取数据到用户空间缓冲区。这是最常用的 I/O 操作之一,几乎所有的文件读取操作都通过这个系统调用完成。
c
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
read 的参数详细说明:
-
fd:文件描述符- 这是之前通过
open系统调用获得的文件描述符 - 文件描述符是一个整数,在进程的文件描述符表中索引对应的 file 结构
- 文件描述符 0、1、2 是标准输入、标准输出、标准错误,由系统自动打开
- 这是之前通过
-
buf:缓冲区- 这是用户空间的内存缓冲区,用于存储从文件读取的数据
- 缓冲区必须由应用程序分配,大小应该足够容纳要读取的数据
- 内核会将数据从内核空间(页缓存)复制到这个用户空间缓冲区
-
count:要读取的字节数- 这指定了要读取的最大字节数
- 实际读取的字节数可能小于这个值,例如文件剩余数据不足、遇到文件结束等
- 读取的字节数不能超过缓冲区的大小,否则会导致缓冲区溢出
read 的返回值详细说明:
-
成功:返回读取的字节数
- 返回值是一个非负整数,表示实际读取的字节数
- 如果返回 0,表示已经到达文件末尾(EOF),没有更多数据可读
- 如果返回值小于
count,可能是文件剩余数据不足,或者遇到了其他情况(如信号中断)
-
失败:返回 -1,并设置 errno
- 当
read返回 -1 时,表示发生了错误 errno变量会被设置为具体的错误代码,常见的错误包括:EBADF:文件描述符无效EFAULT:缓冲区地址无效EINTR:操作被信号中断EIO:I/O 错误EAGAIN或EWOULDBLOCK:非阻塞模式下数据未就绪
- 当
read 系统调用的工作流程:
-
参数验证:内核验证文件描述符是否有效,缓冲区地址是否在用户空间,是否有读取权限等。
-
查找 file 结构:根据文件描述符在进程的文件描述符表中查找对应的 file 结构。
-
检查文件权限:检查文件是否以可读模式打开,当前进程是否有读取权限。
-
VFS 层处理:调用 VFS 层的读取函数,VFS 会调用具体文件系统的读取函数。
-
页缓存处理:文件系统层会检查数据是否在页缓存中。如果在,直接从页缓存读取;如果不在,从磁盘读取并缓存。
-
数据复制:将数据从内核空间(页缓存)复制到用户空间缓冲区。这个过程涉及内存复制操作。
-
更新文件位置:更新文件的当前位置(file->f_pos),为下次读取做准备。
-
返回结果:返回实际读取的字节数,或者返回错误码。
read 系统调用的性能考虑:
read 系统调用的性能受到多个因素的影响:
-
页缓存命中率:如果数据在页缓存中,读取速度非常快(纳秒级);如果不在,需要从磁盘读取,速度会慢很多(毫秒级)。
-
数据复制开销 :数据需要从内核空间复制到用户空间,这个复制操作有一定的开销。对于大文件,可以考虑使用
mmap来避免复制。 -
系统调用开销 :每次
read调用都有系统调用的开销(用户空间到内核空间的切换)。对于小数据量,可以考虑使用更大的缓冲区减少系统调用次数。 -
磁盘 I/O 性能:如果数据不在页缓存中,需要从磁盘读取,磁盘的 I/O 性能(特别是机械硬盘的寻道时间)会显著影响读取速度。
write 系统调用:
write 系统调用将用户空间缓冲区的数据写入文件。与 read 类似,这是最常用的 I/O 操作之一,几乎所有的文件写入操作都通过这个系统调用完成。
c
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
write 的参数详细说明:
-
fd:文件描述符- 这是之前通过
open系统调用获得的文件描述符 - 文件必须以可写模式(
O_WRONLY或O_RDWR)打开 - 如果文件以
O_APPEND模式打开,所有写入都会追加到文件末尾
- 这是之前通过
-
buf:要写入的数据- 这是用户空间的内存缓冲区,包含了要写入文件的数据
- 缓冲区必须由应用程序分配,并且包含有效的数据
- 内核会将数据从这个用户空间缓冲区复制到内核空间(页缓存)
-
count:要写入的字节数- 这指定了要写入的字节数
- 实际写入的字节数可能小于这个值,例如磁盘空间不足、遇到写入错误等
- 写入的字节数不能超过缓冲区的大小
write 的返回值详细说明:
-
成功:返回写入的字节数
- 返回值是一个非负整数,表示实际写入的字节数
- 在正常情况下,返回值应该等于
count - 如果返回值小于
count,可能是磁盘空间不足,或者遇到了其他情况(如信号中断)
-
失败:返回 -1,并设置 errno
- 当
write返回 -1 时,表示发生了错误 errno变量会被设置为具体的错误代码,常见的错误包括:EBADF:文件描述符无效EFAULT:缓冲区地址无效EINTR:操作被信号中断EIO:I/O 错误ENOSPC:磁盘空间不足EAGAIN或EWOULDBLOCK:非阻塞模式下写入缓冲区满
- 当
write 系统调用的工作流程:
-
参数验证:内核验证文件描述符是否有效,缓冲区地址是否在用户空间,是否有写入权限等。
-
查找 file 结构:根据文件描述符在进程的文件描述符表中查找对应的 file 结构。
-
检查文件权限:检查文件是否以可写模式打开,当前进程是否有写入权限,磁盘空间是否充足等。
-
VFS 层处理:调用 VFS 层的写入函数,VFS 会调用具体文件系统的写入函数。
-
页缓存处理:文件系统层会将数据写入页缓存。页缓存是内核中的内存缓冲区,用于缓存文件数据,提高 I/O 性能。
-
数据复制:将数据从用户空间缓冲区复制到内核空间(页缓存)。这个过程涉及内存复制操作。
-
标记页为脏:将写入的页标记为脏(dirty),表示这些页需要写回磁盘。
-
延迟写回:在大多数情况下,数据不会立即写入磁盘,而是延迟写回。这提高了写入性能,因为可以合并多个写入操作。
-
更新文件位置和大小:更新文件的当前位置(file->f_pos)和文件大小(如果文件被扩展)。
-
返回结果:返回实际写入的字节数,或者返回错误码。
write 系统调用的性能考虑:
write 系统调用的性能受到多个因素的影响:
-
页缓存性能 :数据首先写入页缓存,这个过程很快(纳秒级)。实际的磁盘写入是异步的,不会阻塞
write调用。 -
数据复制开销 :数据需要从用户空间复制到内核空间,这个复制操作有一定的开销。对于大文件,可以考虑使用
mmap来避免复制。 -
系统调用开销 :每次
write调用都有系统调用的开销。对于小数据量,可以考虑使用更大的缓冲区减少系统调用次数。 -
磁盘写入性能 :虽然
write调用本身很快(因为只是写入页缓存),但实际的磁盘写入性能会影响系统的整体性能。如果写入速度跟不上,页缓存会被填满,后续的write调用可能会阻塞。 -
同步写入 :如果使用
O_SYNC标志,每次write都会等待数据真正写入磁盘后才返回。这保证了数据的持久性,但会显著影响性能。
close 系统调用:
c
#include <unistd.h>
int close(int fd);
close 的功能:
- 关闭文件描述符
- 释放相关资源
- 刷新缓冲区(如果适用)
2.2 系统调用的底层实现
系统调用的底层实现是理解 Linux I/O 机制的关键。系统调用涉及用户空间和内核空间之间的切换,这是一个复杂的过程,涉及 CPU 模式切换、寄存器保存和恢复、栈切换等多个步骤。
系统调用的执行流程详细说明:
perl
用户空间调用 read(fd, buf, count)
├─ 这是应用程序中的函数调用
├─ 应用程序调用 glibc 提供的 read() 函数
└─ 这个函数实际上是一个包装函数,它会准备系统调用参数并触发系统调用
↓
系统调用入口(glibc 包装函数)
├─ glibc 的 read() 函数会准备系统调用参数
├─ 将参数放入特定的寄存器(ARM64: x0-x7, x86_64: rdi, rsi, rdx 等)
├─ 将系统调用号放入特定的寄存器(ARM64: x8, x86_64: rax)
└─ 触发系统调用指令,切换到内核模式
↓
触发系统调用指令(ARM64: svc, x86_64: syscall)
├─ 这是 CPU 指令级别的操作
├─ ARM64: svc #0 指令会触发同步异常,CPU 切换到 EL1(内核模式)
├─ x86_64: syscall 指令会触发系统调用,CPU 切换到内核模式
├─ CPU 会自动保存用户空间的上下文(寄存器、程序计数器等)
├─ CPU 会切换到内核栈
└─ CPU 会跳转到系统调用处理函数
↓
进入内核空间
├─ 此时 CPU 已经在内核模式运行
├─ 可以访问内核内存空间和特权资源
├─ 可以执行特权指令
└─ 可以访问所有硬件资源
↓
系统调用处理函数(sys_read)
├─ 这是内核中的系统调用处理函数
├─ 函数会从寄存器中读取系统调用参数
├─ 验证参数的有效性(指针是否在用户空间,是否有权限等)
├─ 调用 VFS 层的读取函数
└─ 处理返回值和错误码
↓
VFS 层处理
├─ VFS 层是文件系统的抽象层
├─ 根据文件描述符查找对应的 file 结构
├─ 检查文件权限和状态
├─ 调用具体文件系统的读取函数
└─ 管理文件位置和状态
↓
文件系统层处理
├─ 这是具体文件系统的实现层(如 ext4、xfs 等)
├─ 将文件的逻辑地址转换为块设备的物理地址
├─ 检查数据是否在页缓存中
├─ 如果不在,从磁盘读取数据并缓存
└─ 将数据复制到用户空间缓冲区
↓
设备驱动层处理
├─ 这是设备驱动的实现层
├─ 将 I/O 请求转换为硬件控制信号
├─ 配置设备寄存器
├─ 启动 DMA 传输或 PIO 传输
└─ 处理硬件中断
↓
硬件操作
├─ 这是实际的硬件 I/O 操作
├─ 磁盘读取数据、网卡发送数据包等
├─ 硬件执行实际的 I/O 操作
└─ 硬件完成后发送中断信号
↓
返回用户空间
├─ 系统调用处理函数设置返回值
├─ CPU 恢复用户空间的上下文(寄存器、程序计数器等)
├─ CPU 切换回用户模式
├─ CPU 跳转回用户空间的调用点
└─ 应用程序继续执行,获得返回值
系统调用切换的详细机制:
系统调用切换是用户空间和内核空间之间的桥梁,这个过程涉及多个步骤:
-
CPU 模式切换:CPU 从用户模式切换到内核模式。在用户模式下,CPU 只能访问用户空间内存和执行非特权指令;在内核模式下,CPU 可以访问所有内存空间和执行特权指令。
-
上下文保存 :CPU 自动保存用户空间的上下文,包括通用寄存器、程序计数器、程序状态寄存器等。这些信息保存在内核栈上的
pt_regs结构中。 -
栈切换:CPU 从用户栈切换到内核栈。内核栈是每个进程独立的内核空间栈,用于内核代码的执行。
-
权限检查:内核验证系统调用参数的有效性,检查指针是否在用户空间,是否有权限访问等。这是安全性的重要保障。
-
执行内核代码:内核执行系统调用的处理逻辑,这包括 VFS 层、文件系统层、设备驱动层等的处理。
-
上下文恢复:系统调用完成后,CPU 恢复用户空间的上下文,从内核栈恢复寄存器值。
-
模式切换:CPU 从内核模式切换回用户模式,跳转回用户空间的调用点。
系统调用切换的开销:
系统调用切换有一定的开销,主要包括:
-
CPU 模式切换开销:CPU 模式切换需要保存和恢复寄存器,这需要几个时钟周期(通常 10-50 周期)。
-
栈切换开销:从用户栈切换到内核栈需要更新栈指针,这需要几个时钟周期。
-
TLB 刷新开销:在某些情况下,模式切换可能导致 TLB 刷新,这会增加地址转换的时间。
-
缓存失效:模式切换可能导致某些缓存失效,影响后续执行的性能。
-
安全检查开销:内核需要验证系统调用参数的有效性,这需要一些时间。
虽然系统调用切换有一定的开销,但这个开销相对于实际的 I/O 操作(特别是磁盘 I/O)来说是很小的。对于频繁的系统调用,可以考虑使用批量操作或异步 I/O 来减少切换次数。
3. VFS(虚拟文件系统)
3.1 VFS 的作用
VFS(Virtual File System,虚拟文件系统)是 Linux 内核中一个非常重要的抽象层,它的核心作用是统一不同文件系统的接口,使得应用程序可以用统一的方式访问不同的文件系统,而不需要关心底层文件系统的具体实现。
VFS 的功能详细说明:
-
统一接口:为所有文件系统提供统一的接口
- Linux 支持多种文件系统,如 ext4、xfs、btrfs、NTFS、FAT32 等。每种文件系统都有不同的实现方式和特性
- VFS 为所有这些文件系统提供了一个统一的接口,应用程序只需要使用标准的系统调用(如 read、write、open、close),就可以访问任何文件系统
- 这种统一接口的设计使得应用程序具有很好的可移植性,可以在不同的文件系统上运行而不需要修改代码
- VFS 定义了标准的文件操作接口(file_operations),每个文件系统只需要实现这些接口,就可以集成到系统中
-
抽象层:隐藏不同文件系统的实现细节
- 不同的文件系统有不同的数据组织方式:ext4 使用 inode 和块映射,xfs 使用 B+ 树,btrfs 使用写时复制(COW)
- VFS 将这些不同的实现细节隐藏起来,向上层提供统一的数据结构(如 inode、dentry、file)和操作接口
- 这种抽象使得文件系统的实现可以独立演进,不影响应用程序和其他内核组件
- VFS 还提供了通用的功能,如路径解析、权限检查、文件查找等,这些功能可以被所有文件系统共享
-
文件描述符管理:管理文件描述符到文件的映射
- 文件描述符是应用程序访问文件的标识符,它是一个整数,在进程的文件描述符表中索引对应的 file 结构
- VFS 管理每个进程的文件描述符表,维护文件描述符到 file 结构的映射关系
- 当应用程序打开文件时,VFS 分配一个文件描述符;当应用程序关闭文件时,VFS 释放文件描述符
- VFS 还管理文件描述符的继承、复制等操作,这些操作对于进程创建、进程间通信等场景非常重要
-
路径解析:解析文件路径,查找文件
- 应用程序通过文件路径(如 "/home/user/file.txt")访问文件,VFS 负责解析这个路径,找到对应的文件
- 路径解析是一个复杂的过程,需要遍历目录树,查找每个路径组件对应的目录项(dentry)
- VFS 使用 dentry 缓存来加速路径解析,避免重复的文件系统查找
- VFS 还处理符号链接、硬链接、挂载点等特殊情况,确保路径解析的正确性
VFS 的设计优势:
VFS 的设计遵循了几个重要原则,这些原则使得 Linux 文件系统具有很好的可扩展性和可维护性:
-
接口与实现分离:VFS 定义了标准的接口,具体的文件系统只需要实现这些接口。这种设计使得可以轻松添加新的文件系统,而不需要修改 VFS 或其他文件系统的代码。
-
代码复用:VFS 提供了很多通用的功能,如路径解析、权限检查等,这些功能可以被所有文件系统共享,避免了代码重复。
-
性能优化:VFS 提供了多种缓存机制(如 dentry 缓存、inode 缓存),这些缓存可以显著提高文件访问的性能。
-
灵活性:VFS 支持多种文件系统特性,如符号链接、硬链接、扩展属性、ACL 等,这些特性可以根据文件系统的支持情况选择性启用。
3.2 VFS 的核心数据结构
VFS 使用几个核心数据结构来表示文件系统的抽象,这些数据结构是理解 VFS 机制的关键。每个数据结构都有明确的职责,它们之间的关系构成了 VFS 的完整模型。
inode 结构:
inode(index node,索引节点)是 VFS 中最重要的数据结构之一,它代表文件系统中的文件或目录。每个文件或目录都有一个唯一的 inode,inode 包含了文件的所有元数据信息。
c
// include/linux/fs.h (简化版本)
struct inode {
umode_t i_mode; // 文件类型和权限
uid_t i_uid; // 用户 ID
gid_t i_gid; // 组 ID
loff_t i_size; // 文件大小
struct timespec64 i_atime; // 访问时间
struct timespec64 i_mtime; // 修改时间
struct timespec64 i_ctime; // 状态改变时间
unsigned long i_ino; // inode 号
dev_t i_sb; // 超级块
const struct inode_operations *i_op; // inode 操作
const struct file_operations *i_fop; // 文件操作
struct address_space *i_mapping; // 地址空间(页缓存)
// ... 其他字段
};
inode 结构字段的详细说明:
-
i_mode:文件类型和权限- 这是一个 16 位的字段,包含了文件的类型(普通文件、目录、符号链接、设备文件等)和访问权限(读、写、执行)
- 文件类型使用高 4 位表示,如 S_IFREG(普通文件)、S_IFDIR(目录)、S_IFLNK(符号链接)等
- 访问权限使用低 12 位表示,包括所有者、组、其他用户的读、写、执行权限
-
i_uid和i_gid:用户 ID 和组 ID- 这些字段标识了文件的所有者和所属组
- 用于权限检查,决定哪些用户可以访问文件
- 当进程访问文件时,内核会检查进程的有效用户 ID 和组 ID 是否匹配文件的权限
-
i_size:文件大小- 这是文件的大小,以字节为单位
- 对于普通文件,这是文件的实际大小
- 对于目录,这是目录项的总大小
- 文件大小会随着文件的写入和截断而改变
-
i_atime、i_mtime、i_ctime:时间戳i_atime(access time):文件最后访问时间,当文件被读取时更新i_mtime(modify time):文件最后修改时间,当文件内容被修改时更新i_ctime(change time):文件状态最后改变时间,当文件的元数据(如权限、所有者)被修改时更新- 这些时间戳用于文件管理、备份、审计等场景
-
i_ino:inode 号- 这是 inode 的唯一标识符,在文件系统内是唯一的
- inode 号用于在文件系统中查找 inode
- 不同的文件系统有不同的 inode 号分配策略
-
i_sb:超级块指针- 超级块(superblock)包含了文件系统的元数据信息
- 通过这个指针可以访问文件系统的全局信息,如文件系统类型、块大小、总容量等
-
i_op:inode 操作函数指针- 这是一个函数指针表,包含了 inode 相关的操作函数
- 不同的文件系统可以实现不同的操作函数,如创建、删除、重命名、查找等
- 这些函数是文件系统特定的,VFS 通过这个指针调用具体文件系统的实现
-
i_fop:文件操作函数指针- 这是一个函数指针表,包含了文件 I/O 相关的操作函数
- 包括 read、write、open、close、mmap 等操作
- 这些函数也是文件系统特定的,VFS 通过这个指针调用具体文件系统的实现
-
i_mapping:地址空间指针- 这是页缓存的关键,指向 address_space 结构
- address_space 管理文件的页缓存,包括缓存哪些页、页的状态等
- 通过这个指针,VFS 可以访问文件的页缓存,实现高效的文件 I/O
inode 的生命周期:
inode 的生命周期包括创建、使用、释放等阶段:
-
创建:当文件或目录被创建时,文件系统会分配一个新的 inode,初始化其字段,并将其添加到文件系统中。
-
使用:当文件被访问时,VFS 会根据 inode 号查找 inode,将其加载到内存中(如果不在内存中),并增加引用计数。
-
缓存:inode 会被缓存在 inode 缓存中,以提高后续访问的性能。inode 缓存使用哈希表组织,通过 inode 号和超级块快速查找。
-
更新:当文件被修改时,inode 的相应字段会被更新,如文件大小、修改时间等。
-
释放:当文件被删除或不再被引用时,inode 会被释放,其占用的磁盘空间会被回收。
file 结构:
file 结构代表一个打开的文件,它是文件描述符和 inode 之间的桥梁。每个打开的文件都有一个 file 结构,这个结构包含了文件打开时的状态信息。
c
// include/linux/fs.h (简化版本)
struct file {
struct path f_path; // 文件路径
struct inode *f_inode; // 关联的 inode
const struct file_operations *f_op; // 文件操作
loff_t f_pos; // 文件位置
unsigned int f_flags; // 文件标志
fmode_t f_mode; // 文件模式
struct mutex f_pos_lock; // 位置锁
// ... 其他字段
};
file 结构字段的详细说明:
-
f_path:文件路径- 这个字段包含了文件的路径信息,包括 dentry 和 vfsmount
- dentry 指向文件的目录项,包含了文件的名称和父目录信息
- vfsmount 指向文件所在的文件系统挂载点,用于区分不同的文件系统实例
-
f_inode:关联的 inode 指针- 这个指针指向文件对应的 inode 结构
- 通过这个指针可以访问文件的所有元数据信息
- 多个 file 结构可以指向同一个 inode(例如,同一个文件被多次打开)
-
f_op:文件操作函数指针- 这个指针通常指向 inode 的
i_fop,包含了文件 I/O 相关的操作函数 - 某些特殊文件(如管道、socket)可能会覆盖这个指针,提供特殊的操作函数
- VFS 通过这个指针调用具体的文件操作函数
- 这个指针通常指向 inode 的
-
f_pos:文件位置(文件指针)- 这是文件的当前读写位置,表示下次读写操作从文件的哪个位置开始
- 对于顺序读取,每次读取后
f_pos会增加;对于随机访问,可以设置f_pos到任意位置 f_pos是每个打开的文件实例独立的,多个进程打开同一个文件时,每个进程都有自己的f_pos
-
f_flags:文件标志- 这个字段包含了文件打开时的标志,如
O_RDONLY、O_WRONLY、O_APPEND、O_NONBLOCK等 - 这些标志影响文件的操作行为,如
O_APPEND使得所有写入都追加到文件末尾 - 这些标志在文件打开时设置,在文件关闭前保持不变
- 这个字段包含了文件打开时的标志,如
-
f_mode:文件模式- 这个字段表示文件的打开模式,如只读、只写、读写等
- 用于快速检查文件是否可以进行某种操作,避免不必要的函数调用
-
f_pos_lock:位置锁- 这是一个互斥锁,用于保护
f_pos字段的并发访问 - 在多线程环境中,多个线程可能同时访问同一个文件,需要锁来保护文件位置的更新
- 这是一个互斥锁,用于保护
file 结构与 inode 的关系:
file 结构和 inode 结构的关系是理解 VFS 机制的关键:
-
一对多关系:一个 inode 可以对应多个 file 结构。当同一个文件被多次打开时,每次打开都会创建一个新的 file 结构,但它们都指向同一个 inode。
-
生命周期不同:inode 的生命周期与文件相同,只要文件存在,inode 就存在;file 结构的生命周期与文件打开相同,文件关闭时 file 结构被释放。
-
信息不同:inode 包含文件的永久信息(如大小、权限、时间戳等),这些信息在文件关闭后仍然存在;file 结构包含文件的临时信息(如文件位置、打开标志等),这些信息只在文件打开期间有效。
-
操作不同:inode 操作(i_op)是文件系统级别的操作,如创建、删除、重命名等;file 操作(f_op)是文件 I/O 操作,如读取、写入、映射等。
dentry 结构:
dentry(directory entry,目录项)结构代表文件系统中的一个目录项,它是路径解析的结果。dentry 结构是 VFS 中用于路径查找和缓存的核心数据结构。
c
// include/linux/dcache.h (简化版本)
struct dentry {
unsigned int d_flags; // 目录项标志
struct seqcount d_seq; // 序列计数
struct hlist_bl_node d_hash; // 哈希表节点
struct dentry *d_parent; // 父目录
struct qstr d_name; // 目录项名称
struct inode *d_inode; // 关联的 inode
unsigned char d_iname[DNAME_INLINE_LEN]; // 短名称
// ... 其他字段
};
dentry 结构字段的详细说明:
-
d_flags:目录项标志- 这个字段包含了目录项的各种标志,如是否为正(positive)、是否为负(negative)、是否已挂载等
- 正 dentry 表示目录项存在,有对应的 inode;负 dentry 表示目录项不存在,用于缓存查找失败的结果
- 这些标志用于优化路径查找,避免重复的文件系统查找
-
d_seq:序列计数- 这是一个序列计数器,用于 RCU(Read-Copy-Update)锁机制
- RCU 是一种无锁的同步机制,允许多个读者并发访问,同时允许写者更新数据
- 序列计数器用于检测并发修改,确保读取的一致性
-
d_hash:哈希表节点- 这是 dentry 缓存哈希表的节点,用于快速查找 dentry
- dentry 缓存使用哈希表组织,通过父目录和名称计算哈希值,快速定位 dentry
- 哈希表的使用大大提高了路径查找的性能
-
d_parent:父目录指针- 这个指针指向父目录的 dentry,用于构建目录树结构
- 通过这个指针可以向上遍历目录树,实现路径的完整解析
- 根目录的
d_parent指向自己
-
d_name:目录项名称- 这是一个字符串结构,包含了目录项的名称
- 名称是路径解析的关键,VFS 通过比较名称来查找目录项
- 名称可以是文件名、目录名、符号链接名等
-
d_inode:关联的 inode 指针- 这个指针指向目录项对应的 inode
- 对于文件,这个指针指向文件的 inode;对于目录,这个指针指向目录的 inode
- 对于负 dentry,这个指针为 NULL
-
d_iname:短名称内联存储- 对于短名称(通常小于 36 字节),可以直接存储在 dentry 结构中,避免额外的内存分配
- 这优化了常见情况(大多数文件名都很短)的性能和内存使用
dentry 缓存的作用:
dentry 缓存是 VFS 性能优化的关键机制之一:
-
加速路径查找:路径查找是文件系统操作中最频繁的操作之一。每次访问文件都需要解析路径,查找每个路径组件对应的目录项。dentry 缓存将最近访问的目录项缓存在内存中,避免重复的文件系统查找。
-
减少磁盘访问:文件系统的目录查找通常需要读取磁盘上的目录块,这是相对较慢的操作。dentry 缓存将目录项信息缓存在内存中,大大减少了磁盘访问次数。
-
支持负缓存:dentry 缓存不仅缓存存在的目录项,还缓存不存在的目录项(负 dentry)。当查找一个不存在的文件时,如果之前已经查找过,可以直接从负缓存中知道文件不存在,避免重复的文件系统查找。
-
内存效率:dentry 缓存使用 LRU(Least Recently Used)算法管理,当内存不足时,会释放最近最少使用的 dentry,保持内存使用的合理性。
dentry 与路径解析的关系:
路径解析是 VFS 的核心功能之一,dentry 在这个过程中起到关键作用:
-
路径分解:VFS 将路径(如 "/home/user/file.txt")分解为多个路径组件("home"、"user"、"file.txt")。
-
逐级查找:从根目录开始,逐级查找每个路径组件对应的 dentry。首先查找 "home" 的 dentry,然后在其子目录中查找 "user" 的 dentry,最后在 "user" 目录中查找 "file.txt" 的 dentry。
-
缓存利用:在查找过程中,VFS 首先检查 dentry 缓存。如果 dentry 在缓存中,直接使用;如果不在,从文件系统查找,并将结果添加到缓存中。
-
构建路径 :通过 dentry 的
d_parent指针,可以构建从根目录到目标文件的完整路径,这对于路径显示、权限检查等场景非常有用。
4. 页缓存(Page Cache)
4.1 页缓存的作用
页缓存(Page Cache)是 Linux 内核中一个非常重要的性能优化机制,它的核心作用是将文件数据缓存在内存中,避免频繁的磁盘访问,从而显著提高文件 I/O 的性能。页缓存是 Linux 文件系统性能的关键,理解页缓存的工作原理对于优化 I/O 性能至关重要。
页缓存的功能详细说明:
-
提高性能:缓存文件数据,减少磁盘访问
- 磁盘访问是计算机系统中最慢的操作之一,特别是机械硬盘,其访问延迟通常在毫秒级(5-20 毫秒)
- 内存访问比磁盘访问快几个数量级,从内存读取数据只需要纳秒级(几十到几百纳秒)
- 页缓存将最近访问的文件数据缓存在内存中,当应用程序再次访问这些数据时,可以直接从内存读取,避免了磁盘访问
- 这种缓存机制可以将文件读取的性能提高 100-1000 倍,特别是对于重复访问的文件
-
统一缓存:统一管理文件缓存和内存
- 页缓存是 Linux 内存管理系统的一部分,它与虚拟内存管理紧密集成
- 页缓存使用的页与进程虚拟内存使用的页是同一套机制,可以统一管理和调度
- 当系统内存不足时,页缓存的页可以被回收,用于其他用途(如进程内存分配)
- 这种统一管理使得系统可以灵活地在文件缓存和进程内存之间平衡资源使用
-
延迟写入:延迟写入磁盘,提高写入性能
- 当应用程序写入文件时,数据首先写入页缓存,而不是立即写入磁盘
- 这种延迟写入机制可以合并多个小的写入操作,减少磁盘访问次数
- 延迟写入还可以优化写入顺序,按照磁盘的物理布局组织写入,减少寻道时间
- 延迟写入显著提高了写入性能,特别是对于频繁的小写入操作
-
预读优化:预读文件数据,提高读取性能
- 页缓存实现了智能预读机制,当检测到顺序访问模式时,会提前读取后续的数据
- 预读机制利用了程序的局部性原理:如果程序访问了文件的某个位置,很可能接下来会访问相邻的位置
- 预读可以在应用程序实际需要数据之前就将数据加载到内存中,当应用程序真正需要时,数据已经在内存中了
- 这种预读机制对于顺序读取场景特别有效,可以将顺序读取的性能提高数倍
页缓存的工作原理:
页缓存的工作原理基于几个关键机制:
-
页映射:每个文件都有一个 address_space 结构,这个结构管理文件的所有缓存页。address_space 使用 Radix Tree 数据结构组织页,通过文件偏移量(页号)快速查找对应的页。
-
页状态管理:每个缓存页都有状态标志,如是否脏(dirty)、是否已更新(uptodate)、是否锁定(locked)等。这些状态用于管理页的生命周期和同步。
-
写回机制:脏页(被修改但未写入磁盘的页)会被定期写回磁盘。写回机制包括后台写回线程、内存压力触发写回、同步操作触发写回等。
-
预读机制:页缓存实现了多种预读策略,如顺序预读、随机预读等。预读机制会根据访问模式动态调整预读大小和策略。
页缓存的性能影响:
页缓存对系统性能的影响是巨大的:
-
读取性能:如果数据在页缓存中,读取速度非常快(纳秒级);如果不在,需要从磁盘读取,速度会慢很多(毫秒级)。页缓存命中率是影响读取性能的关键因素。
-
写入性能:延迟写入机制使得写入操作非常快(只是写入内存),但实际的磁盘写入是异步的。这种机制提高了写入性能,但需要注意数据持久性的问题。
-
内存使用:页缓存会占用大量内存,这对于系统性能是好事(提高缓存命中率),但也可能影响其他应用的内存使用。系统需要在文件缓存和进程内存之间平衡。
-
系统响应性:当系统内存不足时,页缓存的页可以被回收,这可能导致后续的文件访问变慢。系统需要智能地管理页缓存,在性能和内存使用之间找到平衡。
4.2 页缓存的实现
address_space 结构:
address_space 结构是页缓存的核心数据结构,它管理一个文件的所有缓存页。每个文件都有一个 address_space 结构,这个结构通过 inode 的 i_mapping 指针访问。理解 address_space 的结构和作用对于深入理解页缓存机制至关重要。
c
// include/linux/fs.h (简化版本)
struct address_space {
struct inode *host; // 关联的 inode
struct radix_tree_root page_tree; // 页树(存储页)
spinlock_t tree_lock; // 树锁
unsigned long nrpages; // 页数量
const struct address_space_operations *a_ops; // 地址空间操作
// ... 其他字段
};
address_space 结构字段的详细说明:
-
host:关联的 inode 指针- 这个指针指向拥有这个 address_space 的 inode
- 通过这个指针可以访问文件的所有元数据信息,如文件大小、权限、时间戳等
- address_space 和 inode 是一对一的关系,每个 inode 都有一个 address_space,用于管理文件的页缓存
- 这种设计使得文件系统可以将文件的元数据(inode)和数据缓存(address_space)分开管理,提高了系统的灵活性
-
page_tree:页树(Radix Tree)- 这是页缓存的核心数据结构,用于存储和管理文件的所有缓存页
- Radix Tree 是一种高效的多级索引树,支持快速的插入、删除和查找操作
- 页树使用文件偏移量(页号)作为键,页结构指针作为值
- 通过页号可以快速查找对应的页,时间复杂度为 O(log n),其中 n 是页的数量
- Radix Tree 的设计使得即使对于大文件(包含大量页),查找操作仍然非常高效
-
tree_lock:树锁- 这是一个自旋锁,用于保护页树的并发访问
- 在查找、插入、删除页时,需要获取这个锁来保证操作的原子性
- 自旋锁适用于锁持有时间很短的场景,避免了进程切换的开销
- 多个进程或线程可能同时访问同一个文件的页缓存,需要锁来保证数据一致性
-
nrpages:页数量- 这个字段记录当前缓存的页数量,用于统计和监控页缓存的使用情况
- 当页被添加到缓存时,这个计数器会增加;当页被移除时,计数器会减少
- 这个信息对于内存管理和性能监控非常有用,可以帮助系统决定是否需要回收页缓存的页
-
a_ops:地址空间操作函数指针- 这是一个函数指针表,包含了地址空间相关的操作函数
- 包括读取页(readpage)、写入页(writepage)、预读(readpages)等操作
- 不同的文件系统可以实现不同的操作函数,提供文件系统特定的优化
- 例如,某些文件系统可能实现特殊的预读策略,或者优化写入顺序
Radix Tree 的工作原理:
Radix Tree(基数树)是页缓存使用的核心数据结构,理解它的工作原理对于理解页缓存非常重要:
-
树结构:Radix Tree 是一个多级索引树,每一级索引地址的一部分。对于 64 位系统,通常使用 6 位作为一级索引(可以索引 64 个槽),可以支持最多 11 级的树结构,理论上可以支持 2^64 个页的索引。
-
键值映射:页号(文件偏移量除以页大小)作为键,页结构指针作为值。通过键可以快速查找对应的页。键的每一部分用于索引树的相应级别。
-
查找操作:查找操作从根节点开始,逐级向下查找,每一级使用键的相应部分作为索引。查找的时间复杂度为 O(log n),其中 n 是页的数量。对于大多数实际应用,查找只需要几次内存访问。
-
插入和删除:插入和删除操作需要更新树结构,可能需要分配或释放节点。这些操作需要获取树锁来保证并发安全。插入和删除操作的时间复杂度也是 O(log n)。
-
内存效率:Radix Tree 使用紧凑的节点结构,只存储实际使用的节点,避免了大量空节点的内存浪费。这种设计使得 Radix Tree 在内存使用上非常高效。
页缓存的查找流程:
页缓存的查找是文件 I/O 操作的关键步骤,理解这个流程对于优化 I/O 性能非常重要:
-
计算页号:根据文件偏移量计算页号,页号 = 文件偏移量 / 页大小(通常是 4KB)。这个计算非常简单,只需要一次位移操作。
-
查找页树:在 address_space 的页树中查找对应页号的页。查找操作从根节点开始,逐级向下查找,使用页号的相应部分作为索引。如果找到,返回页结构指针;如果未找到,返回 NULL。
-
检查页状态:如果找到页,需要检查页的状态,如是否有效(PageUptodate)、是否已锁定(PageLocked)等。如果页无效,需要重新从磁盘读取。
-
增加引用计数:如果页有效,增加页的引用计数,防止页在使用过程中被释放。引用计数是页生命周期管理的关键机制。
-
返回页 :返回页结构指针,调用者可以使用这个指针访问页的数据。页的数据可以通过
page_address()或kmap()等函数访问。
页缓存的查找:
页缓存的查找是文件 I/O 操作中最频繁的操作之一,每次读取文件数据都需要查找对应的页。查找操作的性能直接影响整个 I/O 系统的性能。
c
// mm/filemap.c (简化版本)
struct page *find_get_page(struct address_space *mapping, pgoff_t offset)
{
struct page *page;
// ========== 步骤 1:RCU 读锁保护 ==========
// RCU(Read-Copy-Update)是一种无锁的同步机制,允许多个读者并发访问
// 使用 RCU 读锁可以避免使用传统的读写锁,提高并发性能
// RCU 读锁的开销很小,只需要禁用抢占,不需要获取实际的锁
rcu_read_lock();
// ========== 步骤 2:在页树中查找页 ==========
// 2.1 使用 Radix Tree 查找页
// Radix Tree 的查找操作非常高效,时间复杂度为 O(log n)
// 对于大多数实际应用,查找只需要几次内存访问
page = radix_tree_lookup(&mapping->page_tree, offset);
// ========== 步骤 3:如果找到页,增加引用计数 ==========
if (page) {
// 3.1 增加页引用计数
// 这防止页在使用的过程中被释放
// 引用计数是页生命周期管理的关键机制
// 当引用计数为 0 时,页可以被回收
page_cache_get(page);
// 3.2 验证页的有效性
// 页可能被标记为无效(例如,文件被截断)
// 如果页无效,需要重新从磁盘读取
if (unlikely(!PageUptodate(page))) {
// 页数据可能已过期,需要重新读取
page_cache_release(page);
page = NULL;
}
}
// ========== 步骤 4:释放 RCU 读锁 ==========
rcu_read_unlock();
return page;
}
页缓存查找的性能分析:
页缓存查找的性能受到多个因素的影响:
-
Radix Tree 的深度:Radix Tree 的深度取决于页的数量。对于小文件,树可能只有 1-2 级;对于大文件,树可能有 3-4 级。树的深度直接影响查找的时间复杂度。
-
缓存命中率:如果页在页缓存中(缓存命中),查找非常快(纳秒级);如果不在(缓存未命中),需要从磁盘读取,速度会慢很多(毫秒级)。提高缓存命中率是优化 I/O 性能的关键。
-
并发访问:多个进程或线程可能同时访问同一个文件的页缓存。RCU 机制允许多个读者并发访问,但写者需要等待所有读者完成。这种机制在大多数情况下(读多写少)性能很好。
-
内存访问模式:Radix Tree 的节点访问可能触发缓存未命中,影响查找性能。优化内存布局和预取可以改善这种情况。
5. 文件系统层
5.1 文件系统的注册
文件系统类型:
c
// include/linux/fs.h (简化版本)
struct file_system_type {
const char *name; // 文件系统名称
int fs_flags; // 文件系统标志
struct dentry *(*mount)(struct file_system_type *, int,
const char *, void *); // 挂载函数
void (*kill_sb)(struct super_block *); // 卸载函数
struct module *owner; // 模块所有者
struct file_system_type *next; // 下一个文件系统类型
struct hlist_head fs_supers; // 超级块列表
};
5.2 ext4 文件系统的 I/O 操作
ext4 的读取操作:
c
// fs/ext4/file.c (简化版本)
static ssize_t ext4_file_read_iter(struct kiocb *iocb, struct iov_iter *iter)
{
struct file *file = iocb->ki_filp;
struct inode *inode = file->f_mapping->host;
// 1. 检查文件是否可读
if (!(file->f_mode & FMODE_READ)) {
return -EBADF;
}
// 2. 使用通用文件读取函数
return generic_file_read_iter(iocb, iter);
}
6. 块设备层
6.1 块设备的概念
块设备层是 Linux I/O 系统中连接文件系统和设备驱动的重要桥梁。它负责将文件系统的逻辑 I/O 请求转换为块设备的物理 I/O 请求,并管理这些请求的执行。理解块设备层的工作原理对于深入理解 Linux I/O 机制和优化 I/O 性能至关重要。
块设备的特点:
块设备是 Linux 系统中最重要的 I/O 设备类型之一,包括硬盘、SSD、USB 存储设备等。块设备的特点是数据以固定大小的块为单位进行访问,这与字符设备(以字节流为单位)形成了对比。
-
固定大小块:数据以固定大小的块为单位
- 块设备将存储空间划分为固定大小的块(通常为 512 字节或 4KB)
- 所有的 I/O 操作都以块为单位进行,即使只访问一个字节,也需要读取或写入整个块
- 这种设计简化了存储管理,提高了 I/O 效率
- 块大小是块设备的基本属性,不同的块设备可能有不同的块大小
-
随机访问:可以随机访问任意块
- 块设备支持随机访问,可以访问存储空间中的任意位置
- 这与顺序访问设备(如磁带)不同,随机访问使得块设备非常适合文件系统等需要随机访问的应用
- 随机访问的性能受到设备类型的影响:SSD 的随机访问性能很好,而机械硬盘的随机访问性能较差(因为需要寻道)
-
缓存支持:支持页缓存和块缓存
- 块设备支持多级缓存机制,包括页缓存(Page Cache)和块缓存(Buffer Cache)
- 页缓存缓存文件数据,以页(通常 4KB)为单位
- 块缓存缓存块设备数据,以块为单位
- 这些缓存机制可以显著提高 I/O 性能,减少实际的设备访问
-
I/O 调度:支持 I/O 调度器优化
- 块设备层实现了 I/O 调度器,用于优化 I/O 请求的执行顺序
- I/O 调度器可以合并相邻的请求、排序请求以减少寻道时间、公平调度多个进程的 I/O 请求等
- 不同的 I/O 调度器有不同的策略,适用于不同的应用场景
块设备与字符设备的区别:
块设备和字符设备是 Linux 系统中两种主要的设备类型,它们有不同的特点和用途:
-
数据单位:块设备以块为单位,字符设备以字节为单位。这种区别影响了 I/O 操作的粒度和效率。
-
访问方式:块设备支持随机访问,字符设备通常是顺序访问。这种区别影响了应用场景的选择。
-
缓存机制:块设备有复杂的缓存机制,字符设备通常没有或只有简单的缓存。这种区别影响了 I/O 性能。
-
性能特征:块设备的性能通常更好,因为可以批量处理和缓存优化;字符设备的性能通常较差,因为需要逐个字节处理。
块设备在 I/O 系统中的作用:
块设备层在 Linux I/O 系统中起到关键作用:
-
抽象设备差异:不同的块设备(如机械硬盘、SSD、USB 存储)有不同的硬件特性,块设备层隐藏了这些差异,向上层提供统一的接口。
-
请求管理:块设备层管理所有的 I/O 请求,包括请求的创建、合并、排序、调度等。这种管理可以优化 I/O 性能,提高设备利用率。
-
性能优化:块设备层实现了多种性能优化机制,如请求合并、I/O 调度、预读等。这些机制可以显著提高 I/O 性能,特别是对于机械硬盘。
-
错误处理:块设备层处理 I/O 错误,包括设备错误、传输错误等。这种处理保证了系统的稳定性和数据的完整性。
6.2 块设备的数据结构
块设备层使用几个核心数据结构来表示和管理 I/O 请求,理解这些数据结构对于深入理解块设备层的工作原理非常重要。
bio 结构:
bio(Block I/O)结构是块设备层中最基本的数据结构,它代表一个 I/O 请求。bio 结构包含了 I/O 操作的所有信息:要访问的设备、地址、长度、方向、数据缓冲区等。
c
// include/linux/bio.h (简化版本)
struct bio {
struct bio *bi_next; // 下一个 bio
struct block_device *bi_bdev; // 块设备
unsigned long bi_flags; // bio 标志
bvec_iter bi_iter; // 迭代器
unsigned short bi_vcnt; // 向量数量
struct bio_vec *bi_io_vec; // I/O 向量
bio_end_io_t *bi_end_io; // 完成回调
void *bi_private; // 私有数据
// ... 其他字段
};
bio 结构字段的详细说明:
-
bi_next:下一个 bio 指针- 多个 bio 可以链接在一起,形成一个链表
- 这种设计允许将多个相关的 I/O 请求组织在一起,便于批量处理
- I/O 调度器可以使用这个链表来管理和调度 I/O 请求
-
bi_bdev:块设备指针- 这个指针指向要访问的块设备
- 块设备结构包含了设备的所有信息,如设备号、容量、请求队列等
- 通过这个指针可以访问设备的驱动和硬件资源
-
bi_flags:bio 标志- 这是一个位掩码,包含了 bio 的各种标志
- 常见的标志包括:BIO_READ(读取操作)、BIO_WRITE(写入操作)、BIO_SYNC(同步操作)等
- 这些标志影响 I/O 操作的执行方式和行为
-
bi_iter:迭代器- 这是一个迭代器结构,用于遍历 bio 中的数据段
- 迭代器包含了当前的位置信息:扇区号、偏移量、剩余长度等
- 使用迭代器可以方便地处理分散的数据(scatter-gather I/O)
-
bi_vcnt:向量数量- 这个字段记录 bio 中包含的数据段(bio_vec)的数量
- bio 可以包含多个不连续的数据段,这允许处理分散的数据缓冲区
- 向量数量决定了需要多少次 DMA 传输
-
bi_io_vec:I/O 向量数组- 这是一个 bio_vec 结构数组,每个 bio_vec 代表一个数据段
- bio_vec 包含了页指针、偏移量、长度等信息
- 这种设计允许 bio 处理分散的数据,提高了灵活性
-
bi_end_io:完成回调函数- 这是一个函数指针,当 I/O 操作完成时会被调用
- 回调函数用于通知上层 I/O 操作已完成,可以执行后续处理
- 这种异步机制允许 I/O 操作在后台执行,不阻塞调用者
-
bi_private:私有数据指针- 这是一个通用指针,可以指向任何私有数据
- 上层可以使用这个指针存储与 bio 相关的上下文信息
- 完成回调函数可以使用这个指针访问这些上下文信息
bio 的设计优势:
bio 结构的设计有几个重要优势:
-
灵活性:bio 可以处理分散的数据(scatter-gather I/O),这对于处理不连续的内存缓冲区非常有用。这种设计避免了不必要的数据复制,提高了性能。
-
可扩展性:bio 结构可以链接成链表,支持批量处理。I/O 调度器可以使用这种机制来合并和优化 I/O 请求。
-
异步支持:bio 通过完成回调函数支持异步 I/O,允许 I/O 操作在后台执行,不阻塞调用者。这种机制对于高并发场景非常重要。
-
通用性:bio 结构是通用的,可以用于各种类型的块设备 I/O 操作,包括文件 I/O、直接 I/O、swap I/O 等。
bio_vec 结构:
bio_vec(bio vector)结构代表 bio 中的一个数据段,它描述了数据在内存中的位置。一个 bio 可以包含多个 bio_vec,这使得 bio 可以处理分散的数据(scatter-gather I/O)。
c
// include/linux/bio.h
struct bio_vec {
struct page *bv_page; // 页
unsigned int bv_len; // 长度
unsigned int bv_offset; // 偏移量
};
bio_vec 结构字段的详细说明:
-
bv_page:页指针- 这个指针指向包含数据的页结构
- 页是 Linux 内存管理的基本单位,通常是 4KB
- 页可能来自页缓存、用户空间缓冲区、或其他内存区域
- 通过页指针可以访问页的物理地址,用于 DMA 传输
-
bv_len:长度- 这是数据段的长度,以字节为单位
- 长度可以小于页大小,表示只使用页的一部分
- 长度也可以跨越多个页,但每个 bio_vec 只描述一个页内的数据段
-
bv_offset:偏移量- 这是数据在页内的偏移量,以字节为单位
- 偏移量表示数据从页的哪个位置开始
- 偏移量和长度一起定义了数据在页中的位置和范围
bio_vec 的作用:
bio_vec 结构的设计使得 bio 可以处理分散的数据,这对于以下场景非常有用:
-
用户空间分散缓冲区:用户空间的数据可能分散在多个不连续的内存区域中,bio_vec 可以描述这些分散的数据段。
-
页缓存分散页:文件数据可能分散在多个不连续的页中,bio_vec 可以描述这些分散的页。
-
DMA 分散传输:DMA 控制器可以处理分散的数据传输,bio_vec 提供了 DMA 所需的信息。
-
避免数据复制:通过直接使用分散的数据,避免了将分散数据复制到连续缓冲区的开销,提高了性能。
7. I/O 调度器
7.1 I/O 调度器的作用
I/O 调度器是块设备层中的一个关键组件,它的主要作用是优化 I/O 请求的执行顺序,提高 I/O 性能。I/O 调度器对于机械硬盘特别重要,因为机械硬盘的寻道时间很长,合理的调度可以显著减少寻道时间,提高吞吐量。
I/O 调度器的功能详细说明:
-
合并请求:合并相邻的 I/O 请求
- 当多个 I/O 请求访问相邻的磁盘区域时,I/O 调度器可以将它们合并成一个请求
- 请求合并可以减少 I/O 操作的次数,降低系统开销
- 合并请求还可以减少磁盘的寻道次数,提高 I/O 效率
- 例如,如果有一个读取扇区 100-200 的请求和一个读取扇区 201-300 的请求,调度器可以将它们合并成一个读取扇区 100-300 的请求
-
排序请求:按磁盘位置排序请求(减少寻道时间)
- 机械硬盘的寻道时间很长(通常 3-10 毫秒),是 I/O 性能的主要瓶颈
- I/O 调度器可以将请求按照磁盘位置排序,使得磁头可以顺序访问磁盘区域,减少寻道时间
- 排序算法通常使用电梯算法(Elevator Algorithm),磁头在一个方向上移动,处理所有请求,然后反向移动
- 这种排序可以显著提高顺序 I/O 的性能,但对于随机 I/O 的效果有限
-
公平调度:公平分配 I/O 带宽
- 多个进程可能同时进行 I/O 操作,I/O 调度器需要公平地分配 I/O 带宽
- 公平调度可以防止某个进程独占 I/O 资源,导致其他进程饥饿
- 不同的 I/O 调度器有不同的公平策略:CFQ 为每个进程维护独立的队列,Deadline 使用截止时间防止饥饿
- 公平调度在桌面系统和多用户系统中特别重要,可以保证系统的响应性
-
优先级管理:管理 I/O 请求的优先级
- 不同的 I/O 请求可能有不同的优先级,如实时任务、交互式任务、后台任务等
- I/O 调度器可以根据优先级调整请求的执行顺序,优先处理高优先级的请求
- 优先级管理可以保证关键任务的 I/O 性能,提高系统的实时性和响应性
- 优先级通常与进程的优先级相关联,实时进程的 I/O 请求具有更高的优先级
I/O 调度器的重要性:
I/O 调度器对于 I/O 性能的影响是巨大的,特别是对于机械硬盘:
-
机械硬盘的性能特征:机械硬盘的性能主要受到寻道时间的影响。随机 I/O 需要频繁寻道,性能很差;顺序 I/O 寻道次数少,性能较好。I/O 调度器通过排序和合并请求,可以将随机 I/O 转换为顺序 I/O,显著提高性能。
-
SSD 的性能特征:SSD 没有机械部件,寻道时间几乎为零,随机 I/O 和顺序 I/O 的性能差异不大。因此,I/O 调度器对于 SSD 的重要性较低,简单的调度策略(如 NOOP)就足够了。
-
系统响应性:I/O 调度器的公平调度机制可以保证系统的响应性,防止某个进程的 I/O 操作阻塞其他进程。这对于桌面系统和多用户系统特别重要。
-
吞吐量优化:通过合并和排序请求,I/O 调度器可以显著提高 I/O 吞吐量,充分利用设备的带宽。
7.2 常见的 I/O 调度器
Linux 内核提供了多种 I/O 调度器,每种调度器都有不同的策略和适用场景。选择合适的 I/O 调度器对于优化 I/O 性能非常重要。
CFQ(Completely Fair Queuing):
CFQ 调度器追求完全公平的 I/O 调度,为每个进程维护独立的 I/O 队列,公平地分配 I/O 带宽。
-
为每个进程维护独立的 I/O 队列:
- CFQ 为每个进程(或进程组)维护一个独立的 I/O 队列
- 每个队列都有自己的时间片,调度器按照时间片轮转的方式调度各个队列
- 这种设计保证了每个进程都能获得公平的 I/O 带宽,不会出现某个进程独占 I/O 资源的情况
- 队列的优先级可以根据进程的优先级动态调整,实时进程的队列具有更高的优先级
-
公平分配 I/O 带宽:
- CFQ 使用时间片机制公平地分配 I/O 带宽
- 每个队列在获得 I/O 带宽时,会按照时间片执行,时间片用完后切换到下一个队列
- 这种机制保证了所有进程都能获得 I/O 服务,提高了系统的公平性和响应性
- 对于桌面系统,这种公平性特别重要,可以保证用户交互的流畅性
-
适合桌面系统:
- CFQ 的公平调度特性使得它特别适合桌面系统
- 桌面系统通常有多个应用程序同时运行,CFQ 可以保证所有应用程序都能获得 I/O 服务
- CFQ 还可以根据进程的交互性调整优先级,交互式进程(如浏览器、文本编辑器)可以获得更高的 I/O 优先级
Deadline:
Deadline 调度器为每个请求设置截止时间,防止请求饥饿,特别适合对延迟敏感的应用。
-
为每个请求设置截止时间:
- Deadline 调度器为每个 I/O 请求设置一个截止时间(deadline)
- 读请求的默认截止时间通常是 500 毫秒,写请求的默认截止时间是 5 秒
- 当请求接近截止时间时,调度器会优先处理这些请求,防止请求饥饿
- 这种机制保证了 I/O 请求的延迟有上限,提高了系统的可预测性
-
防止请求饥饿:
- 在某些调度策略下,某些请求可能会长时间得不到服务,导致饥饿
- Deadline 调度器通过截止时间机制防止这种情况,确保所有请求都能在截止时间内得到服务
- 这种机制对于实时应用和数据库应用特别重要,可以保证 I/O 延迟的可预测性
-
适合数据库应用:
- 数据库应用对 I/O 延迟非常敏感,需要可预测的 I/O 性能
- Deadline 调度器的截止时间机制可以保证 I/O 延迟有上限,满足数据库应用的需求
- Deadline 调度器还支持请求排序和合并,可以提高 I/O 吞吐量
NOOP:
NOOP(No Operation)调度器是最简单的调度器,它不进行任何优化,只是简单地将请求按照 FIFO(First In First Out)顺序执行。
-
简单的 FIFO 队列:
- NOOP 调度器使用简单的 FIFO 队列管理 I/O 请求
- 请求按照到达的顺序执行,不进行任何排序或合并
- 这种设计使得 NOOP 调度器的开销最小,几乎没有 CPU 开销
-
不进行排序和合并:
- NOOP 调度器不进行请求排序,不优化寻道时间
- NOOP 调度器也不进行请求合并,每个请求独立执行
- 这种设计使得 NOOP 调度器对于有自己 I/O 优化策略的设备(如 SSD)特别适合
-
适合 SSD:
- SSD 没有机械部件,寻道时间几乎为零,不需要排序优化
- SSD 的随机 I/O 和顺序 I/O 性能差异不大,不需要合并优化
- SSD 通常有自己的内部调度和优化机制,NOOP 调度器可以让这些机制发挥作用
- 对于 SSD,NOOP 调度器通常是最佳选择,可以最大化 SSD 的性能
mq-deadline:
mq-deadline 是 Deadline 调度器的多队列版本,支持多队列块设备,适合现代存储设备。
-
Deadline 的多队列版本:
- mq-deadline 继承了 Deadline 调度器的截止时间机制
- 同时支持多队列块设备,可以为每个 CPU 或每个硬件队列维护独立的调度队列
- 这种设计可以充分利用多核 CPU 和现代存储设备的多队列特性
-
支持多队列块设备:
- 现代存储设备(如 NVMe SSD)支持多队列,可以并行处理多个 I/O 请求
- mq-deadline 可以为每个队列维护独立的调度逻辑,充分利用并行性
- 这种设计可以显著提高多核系统上的 I/O 性能
-
适合现代存储设备:
- 现代存储设备通常支持多队列和高并发
- mq-deadline 可以充分利用这些特性,提供更好的性能和可预测性
- 对于高性能存储设备,mq-deadline 通常是最佳选择
I/O 调度器的选择建议:
选择合适的 I/O 调度器需要考虑多个因素:
- 设备类型:机械硬盘适合 CFQ 或 Deadline,SSD 适合 NOOP 或 mq-deadline
- 应用场景:桌面系统适合 CFQ,数据库应用适合 Deadline,高性能存储适合 mq-deadline
- 性能需求:对延迟敏感的应用适合 Deadline,对吞吐量敏感的应用适合 CFQ
- 系统配置:多核系统适合 mq-deadline,单核系统适合传统的单队列调度器
8. 设备驱动层
8.1 块设备驱动
块设备驱动的结构:
c
// include/linux/blkdev.h (简化版本)
struct block_device_operations {
int (*open)(struct block_device *, fmode_t);
void (*release)(struct gendisk *, fmode_t);
int (*ioctl)(struct block_device *, fmode_t, unsigned, unsigned long);
int (*compat_ioctl)(struct block_device *, fmode_t, unsigned, unsigned long);
int (*getgeo)(struct block_device *, struct hd_geometry *);
void (*unlock_native_capacity)(struct gendisk *);
// ... 其他操作
};
8.2 设备驱动的 I/O 处理
设备驱动的请求处理:
c
// 块设备驱动的请求处理函数(简化版本)
static void device_request_handler(struct request_queue *q)
{
struct request *req;
while ((req = blk_peek_request(q)) != NULL) {
// 1. 从请求队列获取请求
blk_start_request(req);
// 2. 处理请求
process_request(req);
// 3. 完成请求
__blk_end_request_all(req, 0);
}
}
9. 异步 I/O
9.1 异步 I/O 的概念
异步 I/O 的特点:
- 非阻塞:I/O 操作不阻塞调用线程
- 回调机制:通过回调函数通知完成
- 高性能:适合高并发场景
- 复杂实现:实现相对复杂
9.2 Linux AIO(异步 I/O)
AIO 系统调用:
c
#include <aio.h>
// 异步读取
int aio_read(struct aiocb *aiocbp);
// 异步写入
int aio_write(struct aiocb *aiocbp);
// 等待异步操作完成
int aio_suspend(const struct aiocb *const list[], int nent,
const struct timespec *timeout);
// 获取异步操作状态
ssize_t aio_return(struct aiocb *aiocbp);
aiocb 结构:
c
struct aiocb {
int aio_fildes; // 文件描述符
off_t aio_offset; // 文件偏移量
volatile void *aio_buf; // 缓冲区
size_t aio_nbytes; // 字节数
int aio_reqprio; // 请求优先级
struct sigevent aio_sigevent; // 信号事件
int aio_lio_opcode; // 操作码
};
10. 直接 I/O
10.1 直接 I/O 的概念
直接 I/O 的特点:
- 绕过页缓存:直接访问设备
- 同步操作:操作是同步的
- 适合场景:大文件、数据库、自缓存应用
- 性能考虑:可能比缓冲 I/O 慢(小文件)
10.2 直接 I/O 的使用
启用直接 I/O:
c
// 使用 O_DIRECT 标志打开文件
int fd = open("/path/to/file", O_RDWR | O_DIRECT);
// 直接 I/O 的要求:
// 1. 缓冲区必须对齐到块大小边界
// 2. 文件偏移量必须对齐到块大小边界
// 3. 传输大小必须是块大小的整数倍
11. 内存映射 I/O
11.1 mmap 系统调用
mmap 的使用:
c
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
mmap 的参数:
addr:建议的映射地址(通常为 NULL)length:映射长度prot:保护标志(PROT_READ, PROT_WRITE, PROT_EXEC)flags:映射标志(MAP_SHARED, MAP_PRIVATE, MAP_ANONYMOUS)fd:文件描述符offset:文件偏移量
mmap 的优势:
- 减少系统调用:不需要 read/write 系统调用
- 共享内存:多个进程可以共享同一映射
- 延迟加载:按需加载页面
- 高效访问:直接内存访问
12. 网络 I/O
12.1 Socket I/O
Socket 系统调用:
c
#include <sys/socket.h>
// 创建 socket
int socket(int domain, int type, int protocol);
// 绑定地址
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
// 监听连接
int listen(int sockfd, int backlog);
// 接受连接
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
// 连接服务器
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
// 发送数据
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
// 接收数据
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
12.2 epoll I/O 多路复用
epoll 的使用:
c
#include <sys/epoll.h>
// 创建 epoll 实例
int epoll_create(int size);
// 控制 epoll
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 等待事件
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
13. I/O 性能优化
13.1 性能优化策略
优化方法:
- 使用页缓存:利用页缓存提高读取性能
- 批量 I/O:合并多个小 I/O 请求
- 预读优化:预读文件数据
- 直接 I/O:对于大文件或数据库使用直接 I/O
- 异步 I/O:使用异步 I/O 提高并发性能
13.2 I/O 性能测量
性能测量工具:
- iostat:监控 I/O 统计信息
- iotop:监控进程 I/O 使用情况
- blktrace:跟踪块设备 I/O
- perf:性能分析工具
14. 总结
14.1 Linux I/O 的关键点
- 层次结构:从用户空间到硬件设备的完整层次
- 缓存机制:页缓存提高性能
- 调度优化:I/O 调度器优化磁盘访问
- 多种方式:同步、异步、直接 I/O 等多种方式
14.2 最佳实践
- 选择合适的 I/O 方式:根据应用场景选择
- 优化 I/O 模式:顺序 I/O 比随机 I/O 快
- 使用缓存:充分利用页缓存
- 监控性能:使用工具监控 I/O 性能
15. 系统调用的详细底层实现
15.1 ARM64 架构的系统调用实现
ARM64 系统调用指令:
assembly
// ARM64 系统调用使用 SVC(Supervisor Call)指令
// arch/arm64/kernel/entry.S (简化版本)
// 系统调用入口
ENTRY(vectors)
kernel_ventry 1, sync_invalid // 同步异常
kernel_ventry 1, irq_invalid // IRQ 异常
kernel_ventry 1, fiq_invalid // FIQ 异常
kernel_ventry 1, error_invalid // 错误异常
END(vectors)
// 系统调用处理
ENTRY(el0_sync)
kernel_entry 0
mov x0, sp // 保存用户栈指针
bl el0_sync_handler // 调用处理函数
kernel_exit 0
END(el0_sync)
// 系统调用处理函数
el0_sync_handler:
mrs x25, esr_el1 // 读取异常状态寄存器
lsr x24, x25, #ESR_ELx_EC_SHIFT // 提取异常类别
cmp x24, #ESR_ELx_EC_SVC64 // 检查是否为系统调用
b.eq el0_svc // 如果是,跳转到系统调用处理
// ... 其他异常处理
系统调用表:
c
// arch/arm64/kernel/sys.c
// 系统调用表(简化版本)
const sys_call_ptr_t sys_call_table[] = {
[0] = sys_io_setup,
[1] = sys_io_destroy,
[2] = sys_io_submit,
[3] = sys_io_cancel,
[4] = sys_io_getevents,
[__NR_read] = sys_read, // read 系统调用
[__NR_write] = sys_write, // write 系统调用
[__NR_open] = sys_open, // open 系统调用
[__NR_close] = sys_close, // close 系统调用
// ... 其他系统调用
};
15.2 x86_64 架构的系统调用实现
x86_64 系统调用指令:
assembly
// x86_64 系统调用使用 SYSCALL 指令
// arch/x86/entry/entry_64.S (简化版本)
// 系统调用入口
ENTRY(entry_SYSCALL_64)
// 1. 保存用户空间寄存器
pushq %rdi
pushq %rsi
pushq %rdx
pushq %r10
pushq %r8
pushq %r9
// 2. 切换到内核栈
movq %rsp, PER_CPU_VAR(cpu_current_top_of_stack)
// 3. 调用系统调用处理函数
call do_syscall_64
// 4. 恢复用户空间寄存器
popq %r9
popq %r8
popq %r10
popq %rdx
popq %rsi
popq %rdi
// 5. 返回用户空间
sysretq
END(entry_SYSCALL_64)
系统调用处理函数:
c
// arch/x86/entry/common.c
__visible noinstr void do_syscall_64(struct pt_regs *regs, int nr)
{
// 1. 检查系统调用号
if (likely(nr < NR_syscalls)) {
// 2. 从系统调用表获取处理函数
sys_call_ptr_t call = sys_call_table[nr];
// 3. 调用系统调用处理函数
regs->ax = call(regs);
}
}
15.3 read 系统调用的完整流程
read 系统调用的实现:
c
// fs/read_write.c (简化版本)
SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
struct fd f = fdget_pos(fd);
ssize_t ret = -EBADF;
if (f.file) {
loff_t pos = file_pos_read(f.file);
// 调用 VFS 层的读取函数
ret = vfs_read(f.file, buf, count, &pos);
if (ret >= 0)
file_pos_write(f.file, pos);
fdput_pos(f);
}
return ret;
}
// fs/read_write.c
ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos)
{
ssize_t ret;
// 1. 检查文件是否可读
if (!(file->f_mode & FMODE_READ))
return -EBADF;
// 2. 检查文件是否已打开
if (!file->f_op->read && !file->f_op->read_iter)
return -EINVAL;
// 3. 调用文件系统的读取函数
if (file->f_op->read)
ret = file->f_op->read(file, buf, count, pos);
else
ret = new_sync_read(file, buf, count, pos);
return ret;
}
16. VFS 的详细实现机制
16.1 路径解析的详细流程
路径解析的实现:
c
// fs/namei.c (简化版本)
struct dentry *path_lookup(const char *name, unsigned int flags,
struct nameidata *nd)
{
struct dentry *dentry;
struct path path;
int err;
// 1. 解析路径
err = path_init(name, flags, nd);
if (err)
return ERR_PTR(err);
// 2. 遍历路径组件
while (*name == '/')
name++;
if (!*name)
goto out;
// 3. 查找每个路径组件
for (;;) {
struct dentry *parent = nd->path.dentry;
struct qstr this;
// 3.1 提取下一个路径组件
err = do_lookup(nd, &this, &dentry);
if (err)
break;
// 3.2 检查是否是最后一个组件
if (!*name)
break;
// 3.3 进入下一级目录
err = follow_mount(&nd->path, &dentry);
if (err < 0)
break;
err = -ENOTDIR;
if (!dentry->d_inode || !S_ISDIR(dentry->d_inode->i_mode))
break;
nd->path.dentry = dentry;
nd->path.mnt = mntget(nd->path.mnt);
}
out:
return dentry;
}
16.2 文件描述符的管理
文件描述符表:
c
// include/linux/fdtable.h (简化版本)
struct fdtable {
unsigned int max_fds;
struct file __rcu **fd; // 文件描述符数组
unsigned long *close_on_exec;
unsigned long *open_fds;
struct rcu_head rcu;
};
// include/linux/fdtable.h
struct files_struct {
atomic_t count;
struct fdtable __rcu *fdt;
struct fdtable fdtab;
// ... 其他字段
};
文件描述符的分配:
c
// fs/file.c (简化版本)
int __alloc_fd(struct files_struct *files, unsigned start, unsigned end,
unsigned flags)
{
unsigned int fd;
struct fdtable *fdt;
// 1. 获取文件描述符表
fdt = files_fdtable(files);
// 2. 查找空闲的文件描述符
fd = find_next_fd(fdt, start);
if (fd >= end)
return -EMFILE;
// 3. 扩展文件描述符表(如果需要)
if (fd >= fdt->max_fds) {
expand_files(files, fd);
fdt = files_fdtable(files);
}
// 4. 分配文件描述符
__set_open_fd(fd, fdt);
if (flags & O_CLOEXEC)
__set_close_on_exec(fd, fdt);
else
__clear_close_on_exec(fd, fdt);
return fd;
}
17. 页缓存的详细实现
17.1 页缓存的查找和分配
页缓存的查找:
c
// mm/filemap.c (简化版本)
struct page *__page_cache_alloc(gfp_t gfp_mask)
{
struct page *page;
// 1. 从页分配器分配页
page = alloc_pages(gfp_mask, 0);
if (page) {
// 2. 初始化页
__SetPageLocked(page);
__SetPageSwapBacked(page);
}
return page;
}
// mm/filemap.c
struct page *page_cache_alloc_cb(struct address_space *mapping,
pgoff_t offset, int (*gfp_mask)(void *),
void *data)
{
struct page *page;
gfp_t gfp = gfp_mask ? gfp_mask(data) : mapping_gfp_mask(mapping);
// 1. 分配页
page = __page_cache_alloc(gfp);
if (page) {
// 2. 设置页的地址空间和偏移量
page->mapping = mapping;
page->index = offset;
}
return page;
}
17.2 页缓存的读取流程
通用文件读取函数:
c
// mm/filemap.c (简化版本)
ssize_t generic_file_read_iter(struct kiocb *iocb, struct iov_iter *iter)
{
struct file *file = iocb->ki_filp;
struct address_space *mapping = file->f_mapping;
struct inode *inode = mapping->host;
loff_t *ppos = &iocb->ki_pos;
ssize_t retval = 0;
// 1. 检查文件大小
if (unlikely(*ppos >= inode->i_sb->s_maxbytes))
return 0;
// 2. 调用通用读取函数
retval = generic_file_read(iocb, iter);
return retval;
}
// mm/filemap.c
static ssize_t generic_file_read(struct kiocb *iocb, struct iov_iter *iter)
{
struct file *file = iocb->ki_filp;
struct address_space *mapping = file->f_mapping;
struct inode *inode = mapping->host;
loff_t *ppos = &iocb->ki_pos;
ssize_t retval = 0;
size_t count = iov_iter_count(iter);
// 1. 循环读取数据
while (count) {
struct page *page;
pgoff_t index = *ppos >> PAGE_SHIFT;
unsigned long offset = *ppos & ~PAGE_MASK;
size_t bytes = min_t(size_t, count, PAGE_SIZE - offset);
// 2. 查找或分配页
page = find_get_page(mapping, index);
if (!page) {
// 2.1 页不在缓存中,从磁盘读取
page = read_cache_page(mapping, index, NULL, NULL);
if (IS_ERR(page)) {
retval = PTR_ERR(page);
break;
}
}
// 3. 从页复制数据到用户空间
bytes = copy_page_to_iter(page, offset, bytes, iter);
// 4. 更新位置和计数
*ppos += bytes;
count -= bytes;
retval += bytes;
// 5. 释放页引用
put_page(page);
}
return retval;
}
18. 块设备层的详细实现
18.1 bio 结构的详细分析
bio 的创建和初始化:
c
// block/bio.c (简化版本)
struct bio *bio_alloc(gfp_t gfp_mask, unsigned int nr_vecs)
{
struct bio *bio;
void *p;
// 1. 分配 bio 结构
if (nr_vecs <= BIO_INLINE_VECS) {
// 使用内联向量
p = kmalloc(sizeof(struct bio) +
sizeof(struct bio_vec) * BIO_INLINE_VECS,
gfp_mask);
bio = p;
bio_init(bio, NULL, 0);
} else {
// 使用外部向量
bio = kmalloc(sizeof(struct bio), gfp_mask);
if (!bio)
return NULL;
bio_init(bio, NULL, 0);
bio->bi_io_vec = bvec_alloc(gfp_mask, nr_vecs, &bio->bi_max_vecs);
}
return bio;
}
// block/bio.c
void bio_init(struct bio *bio, struct bio_vec *table, unsigned short max_vecs)
{
memset(bio, 0, sizeof(*bio));
atomic_set(&bio->__bi_remaining, 1);
atomic_set(&bio->__bi_cnt, 1);
bio->bi_io_vec = table;
bio->bi_max_vecs = max_vecs;
}
bio 的提交流程:
c
// block/blk-core.c (简化版本)
void submit_bio(struct bio *bio)
{
struct block_device *bdev = bio->bi_bdev;
struct request_queue *q = bdev->bd_queue;
// 1. 检查 bio 是否有效
if (unlikely(!bio->bi_iter.bi_size)) {
bio_endio(bio);
return;
}
// 2. 设置 bio 标志
bio->bi_flags |= (1 << BIO_SUBMITTED);
// 3. 提交到请求队列
blk_queue_bio(q, bio);
}
// block/blk-core.c
static void blk_queue_bio(struct request_queue *q, struct bio *bio)
{
struct request *req;
int el_ret, rw_flags, where = ELEVATOR_INSERT_SORT;
// 1. 尝试合并到现有请求
el_ret = elv_merge(q, &req, bio);
if (el_ret == ELEVATOR_BACK_MERGE) {
// 向后合并
if (bio_attempt_back_merge(q, req, bio)) {
if (!attempt_back_merge(q, req))
elv_merged_request(q, req, el_ret);
goto out_unlock;
}
} else if (el_ret == ELEVATOR_FRONT_MERGE) {
// 向前合并
if (bio_attempt_front_merge(q, req, bio)) {
if (!attempt_front_merge(q, req))
elv_merged_request(q, req, el_ret);
goto out_unlock;
}
}
// 2. 无法合并,创建新请求
get_rq:
req = get_request(q, bio->bi_opf, bio, GFP_NOIO);
// 3. 初始化请求
blk_rq_set_mixed_merge(req);
req->__data_len = bio->bi_iter.bi_size;
req->__sector = bio->bi_iter.bi_sector;
req->bio = req->biotail = bio;
// 4. 添加到请求队列
add_acct_request(q, req, where);
// 5. 唤醒请求处理
__blk_run_queue(q);
out_unlock:
spin_unlock_irq(q->queue_lock);
}
18.2 请求队列的详细机制
请求队列的结构:
c
// include/linux/blkdev.h (简化版本)
struct request_queue {
struct list_head queue_head; // 请求队列头
struct request *last_merge; // 最后合并的请求
struct elevator_queue *elevator; // I/O 调度器
struct blk_queue_stats *stats; // 统计信息
unsigned long nr_requests; // 请求数量
unsigned int queue_flags; // 队列标志
spinlock_t queue_lock; // 队列锁
struct kobject kobj; // kobject
// ... 其他字段
};
// include/linux/blkdev.h
struct request {
struct request_queue *q; // 请求队列
struct blk_mq_ctx *mq_ctx; // 多队列上下文
u64 __sector; // 起始扇区
unsigned int __data_len; // 数据长度
struct bio *bio; // bio 链表头
struct bio *biotail; // bio 链表尾
struct list_head queuelist; // 队列链表
unsigned int cmd_flags; // 命令标志
// ... 其他字段
};
19. I/O 调度器的详细算法
19.1 Deadline 调度器的详细实现
Deadline 调度器的数据结构:
c
// block/deadline-iosched.c (简化版本)
struct deadline_data {
struct rb_root sort_list[2]; // 排序列表(读/写)
struct rb_root *fifo_list[2]; // FIFO 列表(读/写)
struct request *next_rq[2]; // 下一个请求(读/写)
unsigned long fifo_batch; // FIFO 批次大小
unsigned long fifo_expire[2]; // FIFO 过期时间(读/写)
unsigned long fifo_batch_expire; // FIFO 批次过期时间
int writes_starved; // 写请求饥饿计数
int front_merges; // 是否允许前向合并
sector_t last_sector[2]; // 最后访问的扇区(读/写)
unsigned int batching; // 批处理计数
};
Deadline 调度器的请求处理:
c
// block/deadline-iosched.c (简化版本)
static struct request *deadline_dispatch_requests(struct request_queue *q,
struct deadline_data *dd)
{
struct request *rq;
int data_dir;
// 1. 检查是否有待处理的请求
if (unlikely(!dd->next_rq[READ] && !dd->next_rq[WRITE]))
return NULL;
// 2. 检查读请求是否饥饿
if (dd->writes_starved < dd->fifo_batch) {
// 2.1 优先处理读请求
if (dd->next_rq[READ]) {
rq = dd->next_rq[READ];
dd->next_rq[READ] = deadline_latter_request(rq);
data_dir = READ;
goto dispatch;
}
}
// 3. 处理写请求
if (dd->next_rq[WRITE]) {
rq = dd->next_rq[WRITE];
dd->next_rq[WRITE] = deadline_latter_request(rq);
data_dir = WRITE;
dd->writes_starved++;
goto dispatch;
}
// 4. 处理读请求
if (dd->next_rq[READ]) {
rq = dd->next_rq[READ];
dd->next_rq[READ] = deadline_latter_request(rq);
data_dir = READ;
dd->writes_starved = 0;
goto dispatch;
}
return NULL;
dispatch:
// 5. 从调度器移除请求
deadline_remove_request(q, rq);
// 6. 更新统计信息
dd->batching++;
dd->last_sector[data_dir] = blk_rq_pos(rq) + blk_rq_sectors(rq);
return rq;
}
19.2 CFQ 调度器的详细实现
CFQ 调度器的数据结构:
c
// block/cfq-iosched.c (简化版本)
struct cfq_data {
struct request_queue *queue; // 请求队列
struct cfq_rb_root service_tree; // 服务树
unsigned int busy_queues; // 繁忙队列数
unsigned int busy_sync_queues; // 繁忙同步队列数
unsigned int dispatch_limit; // 分发限制
unsigned int cfq_quantum; // 时间片大小
unsigned int cfq_fifo_expire[2]; // FIFO 过期时间(读/写)
unsigned int cfq_slice[2]; // 时间片(读/写)
unsigned int cfq_slice_async_rq; // 异步请求时间片
unsigned int cfq_slice_idle; // 空闲时间片
unsigned int cfq_group_idle; // 组空闲时间
unsigned int cfq_latency; // 延迟目标
struct cfq_queue *active_queue; // 活动队列
struct cfq_io_context *active_cic; // 活动 I/O 上下文
};
CFQ 调度器的公平调度:
c
// block/cfq-iosched.c (简化版本)
static struct cfq_queue *cfq_select_queue(struct cfq_data *cfqd)
{
struct cfq_queue *cfqq;
struct cfq_rb_root *service_tree;
unsigned long slice;
// 1. 如果有活动队列,继续服务
if (cfqd->active_queue && !cfq_class_idle(cfqd->active_queue)) {
cfqq = cfqd->active_queue;
if (cfqq->slice_end > jiffies) {
// 时间片未用完,继续服务
return cfqq;
}
}
// 2. 从服务树选择下一个队列
service_tree = service_tree_for(cfqd->active_cic->ioc->iocg,
cfqd->active_cic->ioprio,
cfqd->active_cic->ioprio_class);
cfqq = cfq_rb_first(service_tree);
if (cfqq) {
// 3. 设置时间片
slice = cfq_prio_to_slice(cfqd, cfqq);
cfqq->slice_end = jiffies + slice;
cfqq->slice_start = jiffies;
cfqq->allocated_slice = slice;
// 4. 设置为活动队列
cfqd->active_queue = cfqq;
}
return cfqq;
}