原文地址:http://hscarb.github.io/java/20240827-reactor-java.html
Reactor 模式的 Java 实现(feat. Scalable IO in Java - Doug Lea)
1. 背景
Doug Lea 在 Scalable IO in Java 的 PPT 中描述了 Reactor 编程模型的思想,大部分 NIO 框架和一些中间件的 NIO 编程都与它一样或是它的变体,包括 Netty。
1.1 Reactor 模式是什么
内核空间的网络数据收发模型:阻塞 IO(BIO)、非阻塞 IO(NIO)、IO 多路复用、信号驱动 IO、异步 IO。
而 Reactor 模式是对用户空间的 IO 线程模型进行分工的模式,它基于 IO 多路复用来实现。
1.2 本文内容
本文将介绍 Reactor 编程模型使用 Java NIO 包的三种实现,并提供对应的源码实现和解释。
我会实现一个简单的服务端逻辑:以换行符来识别每次用户输入,将每次用户输入的字符都转成大写,返回给用户。
本文的代码完整实现地址:https://github.com/HScarb/reactor
2. 传统服务端设计模式(BIO)
一般的 Web 服务端或分布式服务端等应用中,大都具备这些处理流程:读请求(send)、解码(decode)、处理和计算(compute)、编码(encode)、发送响应(send)。
在传统服务端设计中,对每个新的客户端连接都启动一个新的线程去处理,在每个线程中串行执行上述处理流程。这种编程方式也就是 BIO。
2.1 BIO 服务端
public class BioServer implements Runnable {
public int port;
public BioServer(int port) {
this.port = port;
}
@Override
public void run() {
try (final ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("Server is listening on port " + port);
while (!Thread.interrupted()) {
try {
// 当有新的客户端连接时,accept() 方法会返回一个Socket对象,表示与客户端的连接
// 创建一个新的线程来处理该连接
new Thread(new BioHandler(serverSocket.accept())).start();
} catch (IOException e) {
System.out.println("Error handling client: " + e.getMessage());
}
}
} catch (IOException e) {
System.out.println("Server exception: " + e.getMessage());
}
}
}
上述代码中,为每个客户端连接都创建一个 Handler 线程,在 Handler 中处理读请求、解码、处理和计算、编码、发送响应的所有逻辑。
2.2 BIO Handler
/**
* 处理单个客户端连接的具体逻辑
*/
public class BioHandler implements Runnable {
public Socket socket;
public BioHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
System.out.println("New client connected");
try (
final BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
final PrintWriter writer = new PrintWriter(socket.getOutputStream(), true);
) {
writer.print("bio> ");
writer.flush();
String input;
// 读取客户端输入的一行内容
while ((input = reader.readLine()) != null) {
// 处理客户端输入的内容
final String output = process(input);
// 将处理后的内容写回给客户端
writer.println(output);
writer.print("bio> ");
writer.flush();
}
} catch (IOException e) {
System.out.println("Error handling io: " + e.getMessage());
} finally {
try {
socket.close();
} catch (IOException e) {
System.out.println("Failed to close socket: " + e.getMessage());
}
}
}
/**
* 将客户端输入的内容转换为大写
*/
private String process(String requestContent) {
return requestContent.toUpperCase(Locale.ROOT);
}
}
启动这个服务端程序
public class Main {
public static final int PORT = 8080;
public static void main(String[] args) throws IOException {
runBioServer();
}
public static void runBioServer() {
final BioServer bioServer = new BioServer(PORT);
ExecutorService mainThread = Executors.newSingleThreadExecutor();
mainThread.submit(bioServer);
mainThread.shutdown();
}
}
2.3 缺陷
上述程序存在缺陷:
- 线程资源消耗高:每个客户端连接都会创建一个线程,在高并发场景下会导致大量线程创建和销毁,消耗大量系统资源。线程上下文切换开销也会随之增加。
- 阻塞式 I/O:
accept()
、readLine()
和print()
方法都是阻塞式的,这意味着线程在等待I/O操作完成时会被阻塞,无法执行其他任务。这样会导致资源利用率低下。 - 难于管理和扩展:直接使用
new Thread()
的方式来处理连接,难以进行线程管理和池化,难以实现更复杂的并发控制和优化。
3. 优化思路
随着互联网的发展,对服务性能的挑战也越来越大。我们希望能构建更高性能且可伸缩的服务,能够达到:
- 随着客户端数量的增加而优雅降级
- 随着硬件资源的增加,性能持续提高
- 具备低延迟、高吞吐、高质量的服务
3.1 分而治之
要达到以上目标,我们先考虑将处理过程拆分成更小的任务,每个任务执行一个非阻塞操作,由一个 IO 事件来触发执行。
java.nio 包对这种机制提供了支持:
- 非阻塞的读和写
- 通过感知 IO 事件来分发 IO 事件关联的任务
BIO 线程是以 read->decode->process->encode->send 的顺序串行处理,NIO 将其分成了三个执行单元:读取、业务处理和发送,处理过程如下:
- 读取(read):如果无数据可读,线程返回线程池;发生读 IO 事件,申请一个线程处理读取,读取结束后处理业务
- 业务处理(decode、compute、encode):线程同步处理完业务后,生成响应内容并编码,返回线程池
- 发送(send):发生写 IO 事件,申请一个线程进行发送
与 BIO 明显的区别就是,一次请求的处理过程是由多个不同的线程完成的,感觉和指令的串行执行和并行执行有点类似。
分而治之的关键在于非阻塞,这样就能充分利用线程,压榨 CPU,提高系统的吞吐能力。
3.2 事件驱动
另一个优化思路是基于事件启动,它比其他模型更高效。
- 使用的资源更少:不用为每个客户端都启动一个线程
- 开销更少:减少上下文切换,锁的使用也更少
- 任务分发可能会更慢:必须手动绑定事件和动作
事件驱动架构的服务实现复杂度也更高,必须将处理过程拆分成多个非阻塞的动作,且持续跟踪服务的逻辑状态。并且事件启动无法避免所有的阻塞,比如 CG、缺页中断等。
4. 前置知识:Java NIO 包
上面提