一、基础概念
在了解 I/O 之前我们要先了解 I/O 的定义,I/O 模型主要描述的是单台计算机内部处理输入输出操作的方式,尤其是用户空间与内核空间的数据交互。单台计算机内部进行处理这个问题非常关键,因为在了解 I/O 的本质之前,对于 I/O 模型发生经常理解其为客户端服务端在等待数据的交互,其实本质是数据在用户空间和内核空间进行交互。文件和文件描述符
文件可以理解为 N 比特的序列数据,在操作系统中,可以理解为一切皆为文件,比如磁盘、网络、终端,甚至于进程之间的通信工具 pipe 等。既然可以看作为文件,那么就有相应的一套接口进行读写文件。open()
read()
write()
close()
通过以上接口就可以完成 I/O 操作,这就是文件这个概念的强大之处。
使用 open("file path") 进行打开一个"文件",肯定要有一个返回值,用这个返回值来确定文件的位置,进而调用 read(), write() 函数读写文件。
在操作系统中,内核使用一种称为文件系统的数据结构来管理和组织文件。文件系统是一种存储、组织和访问计算机上文件的方法。它定义了文件如何在磁盘上存储,以及如何在目录结构中定位这些文件。
在大多数文件系统中,文件是通过索引节点(inode)来管理的。每个文件都有一个与之关联的 inode,它包含了文件的元数据,如文件大小、创建时间、修改时间、所有者等信息。最重要的是,inode 包含了一个指向文件数据块的指针数组。这些数据块是文件系统在磁盘上的实际存储单位。
当你打开一个文件时,操作系统会首先查找文件的路径。这个过程从文件系统的根目录开始,沿着路径中的每一个目录,直到找到文件名对应的目录项。目录项中包含了文件的 inode 号。然后,操作系统会在 inode 表中查找这个 inode 号,获取到 inode,从而得到文件的元数据以及数据块的位置。因此,你可以将 inode 看作是文件元数据和文件数据在磁盘上位置的桥梁。通过 inode,内核可以知道文件的属性,以及如何在磁盘上找到文件的数据。
简单来说,内核是通过一种数据结构来关联文件位置的
那么 open() 函数可以返回一个数字,只要把这个数字告诉内核,内核就可以根据这个数字找到数据结构,进而找到文件位置。继而真正的操作文件。这个数字就叫做文件描述符。
用户空间和内核空间
用户空间(User Space)和内核空间(Kernel Space)是操作系统中内存和权限的隔离设计,目的是保护系统的安全性与稳定性,安全性是指防止用户程序之间操作硬件或者修改内核,避免系统崩溃或被恶意攻击。稳定性是指用户程序的错误并不会影响内核和其他程序。操作系统由内核统一管理系统资源(cpu 和 内存),避免用户程序争抢资源导致混乱。用户程序也无需关注细节(比如不同型号的网卡),只需要调用统一的接口。1、内核空间(Kernel Space)
- 定义:操作系统内核运行的专有区域
- 权限:拥有最高权限,可以直接操作硬件,比如硬盘、CPU、内存和网卡
- 功能:
- 管理硬件资源,比如 CPU 调度,内存分配。
- 提供系统调用(System Call)接口,供用户程序间接访问硬件。
- 处理中断、异常等底层事件
- 安全性:如果内核代码出错,可能会导致整个系统崩溃。
2、用户空间(Kernel Space)
- 定义:普通程序运行的内存空间
- 权限:受限权限(CPU 的用户模式),无法直接操作硬件
- 功能:
- 运行用户程序
- 通过系统调用访问硬件资源
- 安全性:如果用户程序崩溃,并不会影响操作系统和其他应用程序
本地 I/O 和网络 I/O
本地 I/O:- 用户程序调用 read() 函数(系统调用)。
- CPU 切换到内核模式,内核从磁盘读取数据到内核缓冲区
- 数据从内核缓冲区复制到用户空间的内存
- CPU 切换回用户模式,用户程序继续执行
网络 I/O:
- 用户程序调用 send() 发送数据
- 内核将数据从用户空间复制到内核的网络缓冲区
- 内核通过网卡驱动将数据发送到网络中
二、零拷贝
零拷贝是一种减少或消除数据在内核空间和用户空间之间复制次数的技术,主要用于文件传输、网络通信等场景,目标是提升吞吐量并降低 CPU 开销。注意:数据复制过程中是由同一个线程去操作的。以网络 I/O 为例,会进行多次复制的情况:
- 磁盘 -> 内核缓冲区(DMA 复制)
- 内核缓冲区 -> 用户空间缓冲区(CPU 复制)
- 用户空间缓冲区 -> 内核 Socket 缓冲区(CPU 复制)
- Socket 缓冲区 -> 网卡(DMA 复制)
上面过程共有四次上下文切换(用户态 - 内核态)和 2 次 CPU 数据复制。
实现方式
方式一:sendfile() -- Linux- 功能:直接将文件数据从内核缓冲区发送到网络,无需经过用户空间
- 流程:磁盘 -> 内核缓冲区 -> socket 缓冲区 -> 网卡
- 场景:静态大文件传输,web 服务器发送大文件
方式二:mmap() + write() -- Linux
- 功能:将文件直接映射到用户空间的虚拟内存,直接操作内存地址
- 流程:磁盘 -> 内核缓冲区 -> 用户空间虚拟内存(映射而非复制) -> socket 缓冲区 -> 网卡
- 缺点:内存映射需要处理小文件分页问题
方式三:splice() -- Linux
- 功能:在内核缓冲区之间之间传输数据,无需经过用户空间
- 示例:管道(Pipe)或 Socket 之间的数据传输
Java NIO 的零拷贝
FileChannel sourceChannel = new FileInputStream("source.txt").getChannel();
FileChannel targetChannel = new FileOutputStream("target.txt").getChannel();
sourceChannel.transferTo(0, sourceChannel.size(), targetChannel); // 使用零拷贝
三、IO 模型
Unix 的 I/O 模型分为阻塞 I/O、非阻塞 I/O、多路复用、信号驱动 I/O 和异步 I/O 这五种。I/O 模型的核心区别在于如何等待数据就绪。但区别不仅如此,还在于数据就绪后如何处理数据复制以及用户线程的参与程度。
等待数据就绪是指等待数据从物理设备到内核缓冲区,例如磁盘数据加载到内核缓冲区、数据从网卡接受并存入内核 socket 缓冲区。
对比等待数据就绪和数据复制两个阶段的行为,可以对比 I/O 五种模型:
| I/O 模型 | 等待数据就绪阶段 | 数据复制阶段 | 用户线程参与方式 |
|---|---|---|---|
| 阻塞 I/O | 线程阻塞 | 线程阻塞,完成复制 | 全程阻塞 |
| 非阻塞 I/O | 线程轮训检查是否就绪 非阻塞 | 线程阻塞,完成复制 | 主动轮训 + 阻塞复制 |
| 多路复用 | 线程阻塞在 select/epoll | 线程阻塞,完成复制 | 集中监控 + 阻塞复制 |
| 信号驱动 | 内核通过信号通知数据就绪 | 线程阻塞,完成复制 | 异步通知 + 阻塞复制 |
| 异步 I/O | 内核完成数据就绪和复制后通知 | 内核完成复制 | 异步回调 |
I/O 多路复用
我们以网路编程为例,如果是 web 服务器,当三次握手成功之后,调用 int conn_fd = accept(""); 之后,同样会获取一个文件描述符,只不过这个文件描述符是进行网络通信的。通过读写该文件描述符,就可以进行客户端通信。服务端的处理逻辑,通常是读取客户端发送过来的请求。然后执行某些特定的逻辑。if(read(conn_fd,buff) > 0) {
do_something(buff)
}
假设有三个客户端与服务端进行交互, 如下所示:
if(read(fd1,buff) > 0) {
do_something(buff)
}
if(read(fd2,buff) > 0) {
do_something(buff)
}
if(read(fd3 ,buff) > 0) {
do_something(buff)
}
在这种情况下我们应该如何处理呢,最简单的方式是依次读取这三个文件描述符,然后进行相应的处理,在这里会有一个问题,read 是典型的阻塞操作,那么如果第一个read() 函数迟迟没有发送数据,就会导致其他客户端的处理全部阻塞住。并且这种串行处理的的效率太低。
一种更好的方法是将这些文件描述符都扔给内核,然后告诉内核,我这里有 1 万个文件描述符,你替我监视它们,如果有可以读写的文件描述符你就告诉我,我好处理。
fds = wait_files({fd1, fd2, ... fdn});
for (fd:fds) {
read(fd, buf);
do_something(buf);
}
以上就是 IO 多路复用,把一堆需要处理的文件描述符丢给内核,当任意一个文件描述符背后的文件具备读写条件时,就把这些文件描述符筛选出来,然后进行处理。