I/O多路复用机制:
Redis 是通过I/O多路复用机制来管理大量客户端连接。这使得redis可以实现通过单线程来处理多个客户端连接的请求,避免了为每个客户端创建独立的线程,从而减少了上下文切换的开销,提高了系统的并发性和性能。
理解:redis是通过一个线程来处理多个客户端的请求(通过 I/O 多路复用实现),意在减少CPU上下文的切换,提升系统的并发性和性能。
一、重点概念理解
1、什么是文件描述符?
文件描述符: 是操作系统为每个打开的文件、套接字、管道等 I/O 资源分配的一个整数标识符。它用于唯一标识进程与某个 I/O 资源之间的连接。
文件描述符是操作系统内核管理 I/O 资源的方式,应用程序通过文件描述符与这些资源进行交互。
简单理解下:
一个进程可以访问多个文件(或其他网络终端或通道连接等)资源,这些访问都需要进程与文件建立连接后才能进行数据传输。操作系统为了能清晰区分这些连接,不导致数据传输错乱,会给每一个的这种连接(进程和文件通信)都添加一个唯一整数标识,就是文件描述符。
如:一个快递站可以给人发快递(快递站如:操作系统),每个人通过快递站给别人寄快递(寄快递如:建立传输连接),快递站需要对这次寄件添加唯一的快递单号(快递单号如:添加文件描述符),这样才能保证寄件的安全对吧。
2、有哪些类型的文件描述符?
(1)、文件:普通文件(如文本文件、图片文件等)。
(2)、套接字:网络连接(如 TCP/UDP 连接)。
(3)、管道:进程间通信的通道。
(4)、设备:如终端、打印机等硬件设备。
上面介绍的I/O 资源,大概就是如上的4种类型。在简化下理解,I/O资源就是数据文件(你在电脑上看到的图片或文本等文件),这样就好理解吧?但实际上应该至少为上面的4种类型啊。
当你打开一个文件或创建一个网络连接时,操作系统会返回一个文件描述符,用于唯一区分下进程与文件之间的访问关系。
3、什么是“数据尚未准备好”?
“数据尚未准备好”是指当应用程序尝试从某个 I/O 资源(如文件、套接字等)读取或写入数据时,操作系统暂时无法提供所需的数据或无法立即处理写入请求。具体来说,这可以分为两种情况:
**(1)、读操作时数据尚未准备好:**当应用程序尝试从某个 I/O 资源读取数据时,操作系统还没有接收到任何数据,或者数据还没有完全到达。此时,操作系统无法立即提供数据,导致读操作暂时无法完成。
**(2)、写操作时数据尚未准备好:**当应用程序尝试向某个 I/O 资源写入数据时,操作系统可能暂时无法处理写入请求,例如网络连接中的缓冲区已满,或者磁盘写入速度较慢。此时,写操作也无法立即完成。
简单理解:
不考虑其他因素,简单理解为读和写实际都是耗时操作即可,这段读取和写入需要等待的时间就是数据尚未准备好的时间。
4、为什么会出现“数据尚未准备好”的情况?
出现这种情况的原因取决于 I/O 资源的类型和当前的状态:
(1)、网络套接字:在网络通信中,数据传输是有延迟的。当客户端发送数据到服务器时,服务器可能还没有接收到完整的数据包。同样,当服务器尝试向客户端发送数据时,网络可能暂时不可用,或者客户端的接收缓冲区已满,导致服务器无法立即发送数据。
(2)、文件 I/O:在读取文件时,如果文件位于磁盘上,操作系统需要从磁盘加载数据到内存中。如果文件较大或磁盘性能较差,数据加载可能需要一段时间,导致读操作无法立即完成。
(3)、管道或设备:在进程间通信或与硬件设备交互时,数据的产生和消费可能是异步的。例如,生产者进程可能还没有生成足够的数据(线程同步被阻塞等),或者消费者进程的缓冲区已满,导致读写操作无法立即完成。
二、5种I/O模型介绍
在计算机系统中,I/O 操作(如读取文件、网络通信等)通常是阻塞的,即当程序发起 I/O 请求(如:读取指定文件的内容)时,它会等待直到操作完成。
为了提高系统的并发性和性能,现代操作系统提供了多种 I/O 模型来处理 I/O 操作。
以下是五种常见的 I/O 模型:
1、阻塞IO(Blocking IO)
(1)、工作原理:
- 在阻塞 I/O 模型中,当应用程序发起 I/O 请求(如 read 或 write)时,进程会被阻塞,直到 I/O 操作完成。
- 如果数据尚未准备好(例如,网络连接中没有接收到数据),进程将一直处于等待状态,无法执行其他任务。
- 一旦 I/O 操作完成,操作系统会唤醒进程,继续执行后续代码。
即:当线程遇到阻塞任务(如InputStream.read())时,会一直卡在这里,直到处理完成后,才会继续向下执行代码
(2)、优点:
- 实现简单,编程模型直观,易于理解和使用。
(3)、缺点:
- 阻塞 I/O 会导致进程在等待 I/O 完成时无法做其他事情,浪费 CPU 资源,尤其是在高并发场景下,可能会导致大量进程处于等待状态,影响系统性能。
(4)、适用场景:
- 适用于单线程或低并发的应用程序,或者 I/O 操作非常少的场景。
代码示例:
如: InputStream.read(), OutputStream.write()或 serverSocket.accept()等方法都会阻塞,直到处理完成或接收到响应后才会放行
import java.io.*;
import java.net.*;
public class BlockingIOServer {
public static void main(String[] args) throws IOException {
// 创建一个阻塞的 ServerSocket,监听 8080 端口
ServerSocket serverSocket = new ServerSocket(8080);
System.out.println("服务器启动,等待客户端连接...");
while (true) {
// 阻塞等待客户端连接
Socket clientSocket = serverSocket.accept(); // serverSocket.accept()方法就是阻塞操作,当没有客户端连接,程序会一直卡在这里,直到有客户端连接后,才会放行并继续向下执行代码
System.out.println("客户端已连接: " + clientSocket.getInetAddress());
// 启动一个新线程处理每个客户端连接
new Thread(new ClientHandler(clientSocket)).start();
}
}
// 处理客户端连接的线程类
static class ClientHandler implements Runnable {
private final Socket clientSocket;
public ClientHandler(Socket socket) {
this.clientSocket = socket;
}
@Override
public void run() {
try (BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true)) {
String inputLine;
while ((inputLine = in.readLine()) != null) { // InputStream.read()方法都是阻塞的,只有数据完全返回后才会继续向下执行代码
System.out.println("收到客户端消息: " + inputLine);
out.println("服务器已收到: " + inputLine);
}
} catch (IOException e) {
System.err.println("客户端连接异常: " + e.getMessage());
} finally {
try {
clientSocket.close();
} catch (IOException e) {
System.err.println("关闭客户端连接时出错: " + e.getMessage());
}
}
}
}
}
2、非阻塞IO(Nonblocking IO)
(1)、工作原理:
- 在非阻塞 I/O 模型中,当应用程序发起 I/O 请求时,如果数据尚未准备好,操作系统不会阻塞进程,而是立即返回一个错误码(如
EAGAIN
或EWOULDBLOCK
)。 - 应用程序可以继续执行其他任务,直到数据准备好后再重新发起 I/O 请求。
- 这意味着应用程序需要不断地轮询(polling)检查 I/O 是否就绪,直到数据准备好为止。
理解:当读/写或连接请求发起后,会立即返回结果,不阻塞当前线程,继续向下执行代码。对于一些需要返回值的方法通常返回null或false等,不会造成主线程阻塞等待。但一般为了防止数据丢失,都会使用循环机制优化重试,如果不重试的话,那么任务可能就真的不做了。
(2)、优点:
- 进程不会被阻塞,可以在等待 I/O 的同时执行其他任务,避免了资源浪费。
(3)、缺点:
- 轮询机制会导致 CPU 频繁地检查 I/O 状态,增加了 CPU 开销,尤其是在 I/O 未准备好时,频繁的轮询会浪费大量的 CPU 时间。
- 编程复杂度增加,开发者需要手动管理 I/O 状态的检查和重试逻辑。
(4)、适用场景:
- 适用于对实时性要求较高的场景,或者 I/O 操作非常频繁但每次 I/O 量较小的场景。
代码示例:
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
public class NonblockingIOServer {
public static void main(String[] args) throws IOException {
// 创建一个非阻塞的 ServerSocketChannel,监听 8080 端口
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.bind(new InetSocketAddress(8080));
serverSocket.configureBlocking(false); // 设置为非阻塞模式
System.out.println("服务器启动,等待客户端连接...");
while (true) {
// 尝试接受客户端连接,不会阻塞
SocketChannel clientSocket = serverSocket.accept(); // 因为上面设置了非阻塞模式,这里不会阻塞,如果没有连接产生,这里会立即返回null并向下执行代码
if (clientSocket != null) {
System.out.println("客户端已连接: " + clientSocket.getRemoteAddress());
// 处理客户端连接
handleClient(clientSocket);
} else {
// 如果没有客户端连接,继续循环,不会阻塞
System.out.println("没有新的客户端连接...");
}
// 模拟其他任务
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
private static void handleClient(SocketChannel clientSocket) throws IOException {
// 非阻塞读取数据
clientSocket.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocate(1024);
int bytesRead = clientSocket.read(buffer);
if (bytesRead > 0) {
buffer.flip(); // 切换到读模式
byte[] data = new byte[buffer.remaining()];
buffer.get(data);
String message = new String(data).trim();
System.out.println("收到客户端消息: " + message);
// 回复客户端
ByteBuffer response = ByteBuffer.wrap(("服务器已收到: " + message).getBytes());
clientSocket.write(response);
}
clientSocket.close();
}
}
3、IO多路复用(IO Multiplexing)
(1)、工作原理:
- I/O 多路复用允许一个进程同时监视多个 I/O 文件描述符(如套接字),并等待其中任何一个文件描述符变为可读或可写。
- 当某个文件描述符就绪时,操作系统会通知应用程序,应用程序可以针对该文件描述符进行相应的 I/O 操作。
- 常见的 I/O 多路复用 API 包括 select、poll 和 epoll(Linux 特有)。
理解:一个线程同时监听多个客户端的连接,并对每一个连接的请求都能做出正确的回应。
(2)、优点:
- 单个线程可以同时处理多个 I/O 操作,避免了为每个 I/O 操作创建独立的线程或进程,减少了上下文切换的开销。
- 相比于非阻塞 I/O 的轮询机制,I/O 多路复用只需要在 I/O 就绪时才进行处理,避免了频繁的 CPU 检查,提高了效率。
(3)、缺点:
- 编程复杂度较高,开发者需要管理多个文件描述符的状态,并且需要处理 I/O 就绪的通知。
- select 和 poll 的性能随着文件描述符数量的增加而下降,因为它们需要遍历所有文件描述符来检查状态。epoll 在这方面做了优化,适合处理大量文件描述符。
(4)、适用场景:
- 适用于高并发场景,尤其是需要同时处理大量 I/O 操作的服务器应用程序,如 Web 服务器、数据库服务器等。
了解一下:
为了看懂之后的代码,这里需要先了解下几个概念。
Selector事件多路复用器:Selector 是 Java NIO 中的一个核心类,用于管理多个通道(Channel),并监听这些通道上的 I/O 事件(如连接、读、写等),当有事件发生时通知应用程序。它允许一个线程同时处理多个通道的 I/O 操作,而不需要为每个通道创建独立的线程。
ServerSocketChannel服务器端套接字通道:ServerSocketChannel是 Java NIO 中的一种特殊的通道,用于监听传入的客户端连接请求。它可以绑定到一个特定的端口,等待客户端发起连接请求。
Selector和ServerSocketChannel两者结合:
可以将 ServerSocketChannel 注册到 Selector 上,监听 OP_ACCEPT 事件。当有新的客户端连接请求到达时,Selector 会通知应用程序,应用程序可以调用 ServerSocketChannel.accept() 来接受这个连接,并将其转换为 SocketChannel。
SocketChannel和ServerSocketChannel的区别:
1、ServerSocketChannel:用于监听传入的客户端连接请求(OP_ACCEPT事件)。它绑定到一个特定的端口,等待客户端发起连接。当有新的客户端连接时,ServerSocketChannel会返回一个新的 SocketChannel,用于与该客户端进行数据通信。
2、SocketChannel:用于与客户端进行实际的数据传输。每个 SocketChannel 对应一个具体的客户端连接,负责读取和写入数据。你可以将 SocketChannel 注册到 Selector 上,监听 OP_READ或 OP_WRITE 事件,以便在数据准备好时进行处理。
具体使用流程如下:
1、创建一个 Selector实例。
2、创建一个 ServerSocketChannel,并将其绑定到某个端口。
3、将 ServerSocketChannel 注册到 Selector 上,监听 OP_ACCEPT事件。
4、使用 Selector.select() 阻塞线程,等待注册通道中发出 I/O 事件请求。
5、当有新的客户端连接请求时,Selector 会通知应用程序,应用程序可以通过 ServerSocketChannel.accept() 接受连接,并将新连接的 SocketChannel 注册到 Selector上,监听 OP_READ 或 OP_WRITE 事件。
6、继续处理其他 I/O 事件,直到服务器关闭。
如下图:
前半部分就描述了I/O多路复用的过程。
I/O多路复用程序就类似Selector的作用。
套接字S1,S2等就是发起请求的客户端。
S1,S2等客户端与I/O多路复用程序建立连接就相当于分别创建ServerSocketChannel且注册到Selector上。
建立完连接后,I/O多路复用程序会对每一个客户算S1,S2建立SocketChannel 通道,用于数据的传输。
I/O多路复用程序与客户端完成通道建立后,就会处于监听阻塞状态,等到管理的SocketChannel通道中存在I/O请求时,就会调用文件事务分派器去处理请求,并通过连接的通道将结果返回给客户端。
代码示例:
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
public class NIOServer {
public static void main(String[] args) throws IOException {
// 创建一个 Selector,用于管理多个ServerSocketChannel
Selector selector = Selector.open();
// 创建一个 ServerSocketChannel,监听 8080 端口
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.bind(new InetSocketAddress(8080));
serverSocket.configureBlocking(false); // 设置为非阻塞模式
// 注册 ServerSocketChannel 到 Selector,监听 OP_ACCEPT 事件
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
// 可以再次创建第2,3个ServerSocketChannel并注册到Selector,这里占时忽略
System.out.println("服务器启动,等待客户端连接...");
while (true) {
// 阻塞等待 I/O 事件
selector.select(); // 这里会阻塞当前线程,直到管理的通道中有 I/O 事件就绪
// 获取所有就绪的 SelectionKey
Set<SelectionKey> selectedKeys = selector.selectedKeys(); // 获取I/O请求的具体信息,如:是哪一个通道的请求,是什么类型请求(连接还是读还是写)等,然后在根据请求做出回应
Iterator<SelectionKey> iterator = selectedKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove(); // 仅处理一个请求,避免重复处理
if (key.isAcceptable()) {
// 处理新的客户端连接
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel clientSocket = server.accept(); // 获取数据通道
clientSocket.configureBlocking(false);
// 注册 SocketChannel 到 Selector,监听 OP_READ 事件
clientSocket.register(selector, SelectionKey.OP_READ);
System.out.println("客户端已连接: " + clientSocket.getRemoteAddress());
} else if (key.isReadable()) {
// 处理客户端读事件
SocketChannel clientSocket = (SocketChannel) key.channel();
handleClient(clientSocket, key);
}
}
}
}
private static void handleClient(SocketChannel clientSocket, SelectionKey key) throws IOException {
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 读取客户端数据
int bytesRead = clientSocket.read(buffer);
if (bytesRead > 0) {
buffer.flip(); // 切换到读模式
byte[] data = new byte[buffer.remaining()];
buffer.get(data);
String message = new String(data).trim();
System.out.println("收到客户端消息: " + message);
// 回复客户端
ByteBuffer response = ByteBuffer.wrap(("服务器已收到: " + message).getBytes());
clientSocket.write(response);
// 继续监听该客户端的读事件
key.interestOps(SelectionKey.OP_READ);
} else if (bytesRead == -1) {
// 客户端关闭连接
System.out.println("客户端断开连接");
clientSocket.close();
key.cancel();
}
}
}
4、信号驱动IO(Signal Driven IO)
(1)、工作原理:
- 信号驱动 I/O 使用信号机制来通知应用程序 I/O 操作是否就绪。
- 应用程序通过调用 sigaction 函数注册一个信号处理函数(如
SIGIO
信号),当 I/O 操作就绪时,操作系统会发送信号给应用程序,触发信号处理函数。 - 信号处理函数可以在后台异步处理 I/O 操作,而主线程可以继续执行其他任务。
(2)、优点:
- 信号驱动 I/O 允许应用程序在 I/O 就绪时立即得到通知,避免了阻塞和轮询,提高了响应速度。
- 信号处理函数可以在后台异步执行,不会阻塞主线程。
(3)、缺点:
- 信号处理函数的执行环境较为特殊,不能进行复杂的操作(如分配内存、锁操作等),限制了其应用场景。
- 信号驱动 I/O 的实现较为复杂,调试和维护难度较大。
- 信号的传递是异步的,可能会导致信号丢失或合并,影响可靠性。
Java 标准库中并没有直接支持信号驱动 I/O 的 API,一般在java中不推荐使用。
5、异步IO(Asynchronous IO)
(1)、工作原理:
- 异步 I/O 是一种真正的异步 I/O 模型,应用程序发起 I/O 请求后,操作系统会在后台异步执行 I/O 操作,而应用程序可以继续执行其他任务。
- 当 I/O 操作完成后,操作系统会通知应用程序,应用程序可以选择在回调函数中处理结果,或者通过轮询的方式检查 I/O 是否完成。
- 异步 I/O 的典型实现包括 POSIX AIO(Linux/Unix)和 Windows 的 I/O Completion Port(IOCP)。
理解:当发起这种IO请求后,会在后台创建新的线程去执行任务,主线程不会阻塞。如果是需要返回值的场景调用Future.get()方法时会阻塞。(任务会在后台完成)
(2)、优点:
- 应用程序可以在发起 I/O 请求后立即返回,继续执行其他任务,不会被阻塞,真正实现了 I/O 操作的异步化。
- 异步 I/O 可以显著提高系统的并发性和响应速度,尤其是在高并发场景下,能够有效减少 I/O 等待时间。
(3)、缺点:
- 异步 I/O 的实现较为复杂,编程模型不同于传统的同步 I/O,开发者需要处理异步回调和状态管理。
- 不同操作系统对异步 I/O 的支持不同,跨平台兼容性较差。
- 异步 I/O 的性能并不总是优于 I/O 多路复用,具体取决于操作系统的实现和应用场景。
(4)、适用场景:
- 适用于高并发、高性能的网络应用,尤其是需要处理大量 I/O 操作的场景,如 Web 服务器、数据库服务器等。
6、5种I/O模型对比
(1)、阻塞 I/O:该任务会阻塞线程执行,直到任务完成放行,适合低并发场景。
(2)、非阻塞 I/O:该任务会立即返回结果,不会阻塞主线程。如果返回的是异常结果,如:false或null时,表示该任务并没有完成,主线程如果不采取合适机制去处理的话,就代表放弃了这个请求任务。一般可以轮询检查 I/O 状态,通过重试的机制去完成这样的请求处理,但这样可能会浪费 CPU 资源。
(3)、I/O 多路复用:通过 Selector监听多个文件描述符的 I/O 事件,避免了轮询,适合高并发场景。
(4)、信号驱动 I/O:通过信号通知 I/O 事件的发生,适合对实时性要求较高的场景,但在 Java 中不常用。
(5)、异步 I/O:真正的异步 I/O 模型,允许应用程序在发起 I/O 请求后立即返回,任务会在后台创建新的线程去执行,适合高并发、高性能的应用场景。
三、Redis实现I/O多路复用的原理
Redis 实现了不同的 I/O 多路复用机制,具体选择取决于操作系统的支持。
Redis 内部实现了一个抽象层,称为 ae(async event)库,它封装了不同操作系统的 I/O 多路复用 API,使得 Redis 可以在不同的平台上保持一致的行为。
1、支持的主要 I/O 多路复用机制
(1)、epoll机制(Linux 特有)
- epoll 是 Linux 提供的一种高效的 I/O 多路复用机制,特别适合处理大量文件描述符。epoll的优势在于它只监听就绪的文件描述符,而不是像 select和poll 那样需要遍历所有文件描述符,因此性能更高。
- Redis 在 Linux 系统上默认使用epoll,因为它提供了更好的性能和扩展性。
(2)、kqueue机制(FreeBSD、macOS)
- kqueue 是 FreeBSD 和 macOS 提供的一种 I/O 多路复用机制,类似于 epoll,也支持高效的事件通知和文件描述符管理。
- Redis 在 FreeBSD 和 macOS 系统上使用 kqueue 来处理 I/O 事件。
(3)、select 和 poll
- select 和 poll 是较早的 I/O 多路复用机制,适用于大多数 Unix 系统。它们的性能随着文件描述符数量的增加而下降,因为它们需要遍历所有文件描述符来检查状态。
- Redis 在不支持 epoll 或 kqueue 的系统上会退回到使用 select 或 poll,但这通常不是首选,因为它们的性能不如 epoll 和 kqueue。
2、I/O多路复用流程
(1)、初始化事件循环:Redis 启动时,会初始化一个事件循环(event loop),该循环负责监听所有客户端连接的 I/O 事件(如读、写、关闭等)。
(2)、注册文件描述符:每当有新的客户端连接时,Redis 会将该连接的文件描述符注册到 I/O 多路复用器(如 epoll 或 kqueue)中,以便监听该连接的 I/O 事件。
(3)、等待事件:事件循环会进入等待状态,等待 I/O 多路复用器通知有文件描述符就绪。在这个过程中,Redis 不会被阻塞,可以继续处理其他任务。
(4)、处理事件:当某个文件描述符就绪时,I/O 多路复用器会通知 Redis,Redis 会根据事件类型(如读、写、关闭等)进行相应的处理。例如,如果是一个读事件,Redis 会从该连接中读取数据;如果是一个写事件,Redis 会将数据写入该连接。
(5)、继续循环:处理完当前事件后,事件循环会继续等待下一个事件,重复上述过程。
3、为什么Redis选择I/O多路复用?
(1)、高并发处理能力:Redis 采用 I/O 多路复用可以同时处理大量的客户端连接,而不需要为每个连接创建独立的线程或进程。这大大减少了上下文切换的开销,提高了系统的并发性和性能。
(2)、高效利用 CPU:I/O 多路复用避免了阻塞和轮询,只有在 I/O 就绪时才会进行处理,因此可以更高效地利用 CPU 资源。
(3)、简化编程模型:相比多线程或进程模型,I/O 多路复用的编程模型更加简单,开发者不需要处理复杂的线程同步和锁问题。
(4)、跨平台支持:Redis 通过 ae 库封装了不同操作系统的 I/O 多路复用 API,确保了 Redis 在不同平台上的一致行为。
学海无涯苦作舟!!!