冯·诺依曼结构中计算机结构被分为 5 大部分:运算器、控制器、存储器、输入设备、输出设备,输入设备向计算机输入数据,输出设备接收计算机输出的数据。从计算机结构的视角来看的话, I/O 描述了计算机系统与外部设备之间通信的过程。输入设备(比如键盘)和输出设备(比如显示器)都属于外部设备。 网卡、硬盘这种既可以属于输入设备,也可以属于输出设备。
所谓 IO 即 输入输出,是操作系统为了保证操作系统的稳定性和安全性,将一个进程的地址空间划分为 用户空间(User space) 和 内核空间(Kernel space ) 。我们运行的应用程序都是运行在用户空间,而只有内核空间才能进行系统态级别的资源有关的操作,比如文件管理、进程通信、内存管理等等。我们想要进行 IO 操作,一定是要依赖内核空间的能力。并且,用户空间的程序不能直接访问内核空间。当想要执行 IO 操作时,由于没有执行这些操作的权限,只能发起系统调用请求操作系统帮忙完成。因此,用户进程想要执行 IO 操作的话,必须通过 系统调用 来间接访问内核空间,而我们常见的 IO主要是磁盘 IO(读写文件) 和 网络 IO(网络请求和响应)。
从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),而具体的IO 操作则是系统的内核来执行。 当应用程序发起 I/O 调用后,会经历两个步骤:
- 内核等待 I/O 设备准备好数据
- 内核将数据从内核空间拷贝到用户空间。
1、IO 的分类
IO读写操作实际上 Read 系统调用是将数据从内核空间复制到用户空间;write系统调用是将数据从用户空间复制到内核空间;底层都是由内核来完成的。UNIX 系统下的 IO 模型一共有同步阻塞 I/O、同步非阻塞 I/O、I/O 多路复用、信号驱动 I/O 和异步 I/O 5 种,而信号驱动 I/O 使用中并不多见,这里不多做介绍了
java中的 IO 在 Java 1.4 中引入了 NIO 框架(java.nio 包),提供了 Channel、Selector、Buffer 等新的抽象,可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层的高性能数据操作方式。在 Java 7 中时,NIO 有了进一步的改进,也就是 NIO 2,引入了异步非阻塞 IO 方式,也有很多人叫它 AIO(Asynchronous IO)。异步 IO 操作基于事件和回调机制,可以简单理解为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后续工作。
a、同步阻塞 I/O
当用户程序执行 read ,线程会被阻塞,一直等到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程完成,read 才会返回。阻塞等待的是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程,在客户端连接数量不高的情况下,是没问题的。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。
工作原理
- 进程发起 I/O 请求后,会一直等待,直到数据传输完成。
- 在数据传输期间,进程被挂起,无法执行其他任务。
- 应用进程向内核发起 read 读取数据
- 等待内核缓冲区数据(阻塞)
- 内核缓冲区数据准备好后,复制数据到用户缓冲区(阻塞)
- 复制完成,返回成功
java中的实现
ServerSocket serverSocket = new ServerSocket(portNumber);
Socket clientSocket = serverSocket.accept(); // 阻塞直到有连接
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
String inputLine;
while ((inputLine = in.readLine()) != null) { // 阻塞直到数据可读
// 处理数据
}
b、同步非阻塞 NI/O
非阻塞的 read 请求在数据未准备好的情况下立即返回,可以继续往下执行,此时应用程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区,read 调用才可以获取到结果。
工作原理
- 进程发起 I/O 请求后,如果数据未准备好,请求会立即返回一个错误码。
- 进程需要不断轮询来检查数据是否准备好。
- 应用进程向内核发起 read 读取数据
- 数据没有准备好,直接返回 EWOULDBLOCK 错误(非阻塞)
- 应用进程不断发起 read 读取数据
- 内核缓冲区数据准备好后,将数据复制到用户缓冲区(阻塞)
- 复制完成,返回成功
java中的实现
SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false); // 设置为非阻塞模式
socketChannel.connect(new InetSocketAddress("example.com", 80));
while (!socketChannel.finishConnect()) {
// 非阻塞模式下,连接尚未完成,可以做其他事情
}
// 连接建立后,进行数据读写
c、I/O 多路复用
使用 select、poll、epoll 等系统调用,进程可以监视多个文件描述符,一旦某个文件描述符就绪(读写操作不会阻塞),则能够通知进程进行相应的读写操作。JAVA 中的 NIO 是使用的 IO 多路复用,Linux环境下底层使用的是 epoll。该模型适用于连接数多,连接时间短的场景,select阻塞,等待所有socket中的任意一个变成可读
工作原理
- 使用单个线程来监控多个文件描述符的 I/O 状态。
- 当某个文件描述符就绪时,线程会被唤醒来执行 I/O 操作。
- 进程受阻于select调用,等待多个 socket 中的任意一个变为可读(阻塞)
- 当socket变成可读时(说明内核缓冲区数据已经准备好),用户进程发起read读取数据,将数据复制到用户缓冲区(阻塞)
- 复制完成,返回成功
java中的实现
Selector selector = Selector.open();
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.bind(new InetSocketAddress(portNumber));
serverSocket.configureBlocking(false);
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
int readyChannels = selector.select();
if (readyChannels == 0) continue;
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
// 处理接受连接
} else if (key.isReadable()) {
// 处理读
}
keyIterator.remove();
}
}
d、异步 I/O
进程发起异步 I/O 操作后,可以立即返回继续执行其他任务。当整个 I/O 操作(包括数据准备和传输)完成时,进程会收到一个信号或者其他形式的通知。
工作原理
- 进程发起异步 I/O 操作后,可以立即返回继续执行其他任务。
- 当整个 I/O 操作完成时,进程会收到通知。
java中的实现
AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(Paths.get("data.txt"), StandardOpenOption.READ);
ByteBuffer buffer = ByteBuffer.allocate(1024);
fileChannel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer result, ByteBuffer attachment) {
// 读取完成时的处理
}
@Override
public void failed(Throwable exc, ByteBuffer attachment) {
// 读取失败时的处理
}
});
// 继续执行其他任务,不需要等待 I/O 完成
异步 I/O 模型虽然提供了非阻塞操作和高性能的潜力,但由于其编程复杂性、平台依赖性、调试难度以及对现有同步系统的重构成本,它并没有被广泛使用和推广。然而,在特定的高并发和 I/O 密集型应用场景中,异步 I/O 仍然是一个重要且有价值的工具
2、总结对比
优点 | 缺点 | |
同步阻塞 I/O | - 编程模型简单,易于理解和实现 - 适合连接数较少且持续时间短的应用 | - I/O 操作会阻塞线程,效率低下,不适合高并发场景 - 线程资源消耗大,每个连接都需要一个线程来处理 |
同步非阻塞 I/O | - 允许单线程管理多个连接,提高资源利用率 - 线程可以在没有数据可读写时执行其他任务 | - 编程模型复杂,需要处理轮询逻辑。 - 高负载下,CPU 轮询消耗可能会增加 |
I/O 多路复用 | - 单线程或少量线程就可以处理多个客户端连接,适合高并发场景。 - 通过减少线程数量,降低了资源消耗。 | - 编程模型相对复杂,需要维护多个文件描述符和事件。 - 在某些实现(如 select)中,有文件描述符数量限制 |
异步 I/O | - 真正的异步,应用程序无需等待 I/O 完成,可以立即处理其他任务。 - 提高了程序的吞吐量和响应性 | - 编程模型最为复杂,需要处理回调或者 Future 对象。 - 系统实现复杂,依赖操作系统的支持。 |
同步阻塞 I/O 适合简单的应用场景,而 I/O 多路复用和异步 I/O 更适合高并发和高吞吐量的场景。同步非阻塞 I/O 和信号驱动 I/O 在实际应用中较少见,通常被 I/O 多路复用和异步 I/O 所取代。