概述
前几篇博客我介绍了几种常见的 I/O 模式,本篇博客我打算简单聊聊 Reactor 模式。其中 NIO 就可以抽象理解为 Reactor 模式的简易版,并且 Netty 框架很大一部分源码的编写也采用了 Reactor 模式。因此,学好 Reactor 模式可以加深后续对 Netty 框架的理解。
Reactor 模式
Reactor 是一种基于多路复用技术实现的 同步非阻塞 I/O 模式。本篇博客我打算分以下几个模块展开:
- I/O 模式
- Reactor 模式简介
- Reactor 模式示例
- Reactor 模式组件
- Reactor 模式的优缺点
I/O 模式
在正式开始介绍 Reactor 模式前,我先简单回顾部分 I/O 相关知识。
简单来说,一次 I/O 操作全过程可以分为以下两部分:
- 数据准备:将数据加载到内核缓存
- 数据复制:将内核中的数据复制到用户空间
具体原因是由于 I/O 操作需要调用底层系统调用,获取到的数据首先会被加载到内核缓存中,后续从内核缓存复制到具体应用的用户空间。根据这种模式我们再来看常见几种 I/O 模式:
- 同步阻塞 I/O (BIO):调用 read() 方法读取数据后,整个数据准备、数据复制阶段都在阻塞
- 同步非阻塞 I/O(NIO):调用 read() 方法读取数据后,数据准备阶段如果数据准备好,通知应用程序,应用程序主动复制数据、数据没准备好就返回 EWOULDBLOCK,一般情况下程序多次调用接口确定数据是否准备好
- 异步 I/O(AIO):调用 read() 方法后直接返回,应用程序不做任何处理,后续整个操作过程完毕后会主动通知应用程序。
异步操作不分阻塞和非阻塞,因为异步就代表了非阻塞,异步阻塞没有任何意义。
也就是说,阻塞主要关注数据准备阶段,调用系统调用会阻塞等待结果还是直接返回。同步异步主要关注数据复制阶段是应用程序主动发起还是在内核调用中主动完成。
Reactor 模式简介
有了上面的基础,我们再来看 Reactor 模式,因为它是基于 NIO 做的改进。关于 NIO 我之前已经写过博客介绍,大家可以 点击这里 进行参考,这里我简单描述 NIO 一次操作的全过程:
- 初始化多路复用器、初始化 NIO 套接字、设置非阻塞模式,绑定端口,设置 backlog 等属性
- 将套接字注册到多路复用器,监听 ACCEPT 事件,多路复用器线程调用 select() 方法阻塞等待就绪 I/O 事件
- 如果存在连接就绪事件,调用 accept() 方法建立数据链路,将该通管道的读就绪事件注册到多路复用器
- 如果存在读就绪事件,通过 ByteByffer 缓冲区读取数据,处理完毕后将结果返回客户端
上述示例就是 NIO 服务端最常见的处理模式,Reactor 其实就是在它的基础上进行抽象,将 NIO 各模块抽象为以下五个模块(此处是作者个人理解,可能有误):
- Reactor:响应 I/O 事件,所有的读就绪、写就绪、连接就绪、接受连接就绪等事件都注册在它上,类似多路复用器
- Acceptor:特殊的事件处理器,主要处理服务端接受连接就绪事件:创建通道,建立连接,注册读就绪事件到 Reactor
- Dispatcher:事件分发器,将不同类型的就绪事件分发到不同的处理器
- Handler:处理器,主要处理各种 I/O 事件
- RealHandler:具体处理器,根据 I/O 事件数据,具体的业务逻辑处理类
需要特别注意的一点是:Reactor 只是一种设计模式,并不是实际存在的框架
Reactor 模式根据具体功能的不同,将整个 NIO 模块抽象为不同模块,规范系统结构,使整体架构更清晰。
Reactor 模式示例
下面我们直接看单线程 Reactor 模式的示例图及代码:
服务端示例代码:
// Reactor 服务端启动类
public class Reactor implements Runnable {
public static void main(String[] args) {
new Thread(new Reactor()).start();
}
private static final int PORT = 8888;
/**
* NIO 服务端套接字
*/
private ServerSocketChannel socketChannel = null;
/**
* NIO 多路复用器
*/
private Selector selector = null;
private Reactor() {
try {
// 初始化多路复用器
selector = Selector.open();
// 初始化 NIO 服务端套接字
socketChannel = ServerSocketChannel.open();
// 服务端套接字绑定要监听的端口
socketChannel.socket().bind(new InetSocketAddress(PORT));
// 设置套接字为非阻塞类型
socketChannel.configureBlocking(false);
// 注册服务端套接字的请求就绪事件到多路复用器
SelectionKey key = socketChannel.register(selector, SelectionKey.OP_ACCEPT);
// 创建 Acceptor 对象,并将该对象附加到选择键,后续可以通过 attachment() 方法获取该附加对象
key.attach(new Acceptor());
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public void run() {
// 只要线程没停止,无限轮询多路复用器
while (!Thread.interrupted()) {
try {
// 多路复用器阻塞等待客户端连接
selector.select();
} catch (IOException e) {
e.printStackTrace();
}
// 返回就绪需要处理的选择键
Set<SelectionKey> keys = selector.selectedKeys();
Iterator iterator = keys.iterator();
while (iterator.hasNext()) {
SelectionKey key = (SelectionKey) iterator.next();
// 分发就绪的选择键到不同的 Handler
dispatch(key);
// 处理过的选择键从迭代器中删除
iterator.remove();
}
}
}
private void dispatch(SelectionKey key) {
// 从选择键中获取附加对象,一般为线程对象
Runnable runnable = (Runnable) key.attachment();
// 如果选择键附加属性不为空,就运行该线程
if (runnable != null) {
runnable.run();
}
}
private class Acceptor implements Runnable {
@Override
public void run() {
try {
// 完成三次握手,建立数据链路
SocketChannel channel = socketChannel.accept();
if (channel != null) {
// 创建新 Handler 处理已经连接的数据链路
new Handler(selector, channel);
}
} catch (IOException e) {
// 说明数据链路建立失败
e.printStackTrace();
}
}
}
}
// 具体 I/O 处理类
public class Handler implements Runnable {
/**
* 用来标记当前数据链路状态
*/
static final int READING = 0, SENDING = 1;
/**
* 默认状态为读取数据状态
*/
int state = READING;
/**
* 多路复用器对象
*/
final SocketChannel channel;
/**
* 选择键对象
*/
final SelectionKey selectionKey;
/**
* 读数据缓冲池
*/
ByteBuffer readBuffer = ByteBuffer.allocate(1024);
/**
* 写数据缓冲池
*/
ByteBuffer writeBuffer = ByteBuffer.allocate(1024);
/**
* 此时两个参数分别表示:服务端多路复用器、客户端新建立的数据链路
*
* @param selector
* @param socketChannel
* @throws IOException
*/
protected Handler(Selector selector, SocketChannel socketChannel) throws IOException {
channel = socketChannel;
// 设置新建立数据链路为非阻塞状态
channel.configureBlocking(false);
// 尝试注册一下,没有实际用处
// 使用0注册选择键不会报错,但没有实际用处,
// 本人理解可能是为了试错,判断该 channel 能否注册就绪事件到多路复用器
selectionKey = channel.register(selector, 0);
// 将当前类对象作为附加属性添加到选择键
selectionKey.attach(this);
// 将读就绪方法注册到多路复用器
selectionKey.interestOps(SelectionKey.OP_READ);
// 通过该方法唤醒阻塞在 select() 上的线程,使被唤醒线程即时去处理注册多路复用器等任务
// 本示例为单线程,无实际用处,可以删除
selector.wakeup();
}
@Override
public void run() {
try {
if (state == READING) {
read();
} else if (state == SENDING) {
send();
}
} catch (IOException e) {
e.printStackTrace();
}
}
private void read() throws IOException {
int length = channel.read(readBuffer);
System.out.println("数据已读取完毕");
state = SENDING;
// 将写就绪事件注册到多路复用器
selectionKey.interestOps(SelectionKey.OP_WRITE);
}
private void send() throws IOException {
channel.write(writeBuffer);
if (!writeBuffer.hasRemaining()) {
System.out.println("数据已写完毕");
selectionKey.cancel();
// 关闭该选择键
System.out.println("selectionKey关闭了");
}
}
}
单线程 Reactor 模式具有以下缺点:
- 某一个客户端的请求处理阻塞时,其它所有客户端的请求都无法及时处理
- 阻塞过长可能导致其它客户端无法连接,因为单线程阻塞在 Handler处理,无法及时调用 accept() 方法完成三次握手,建立数据链路
- 无法发挥多核 CPU 的优势
鉴于上述如此多的缺陷,对 Reactor 单线程模式进行优化,采用 Reactor 多线程模式:将具体处理逻辑提出,采用多线程处理。优化后的多线程 Reactor 模型如下:
下面我列出核心修改代码:
private void read() throws IOException {
int length = channel.read(readBuffer);
System.out.println("数据已读取完毕");
new Thread(new RealHandler()).start();
}
class RealHandler implements Runnable{
@Override
public void run() {
// 具体处理逻辑
// 这里省略具体处理逻辑
state = SENDING;
// 将写就绪事件注册到多路复用器
selectionKey.interestOps(SelectionKey.OP_WRITE);
}
}
使用多线程 Reactor 模式可以解决单线程场景下带来的阻塞问题,单个客户端请求处理的阻塞不会影响其他客户端请求的处理,更不会导致数据链路无法建立。
但多线程 Reactor 还可能存在性能问题: 所有数据链路的连接、读、写等事件处理都集中在某一个 Reactor 线程上,这可能导致该线程挂掉或处理响应速度减慢。
为了解决该问题,我们再对多线程 Reactor 模式进行优化,采用多 Reactor 模式。多 Reactor 模式模型如下:
本次优化后的核心代码如下:
public class MoreReactor implements Runnable {
public static void main(String[] args) {
new Thread(new MoreReactor()).start();
}
private static final int PORT = 8888;
/**
* NIO 服务端套接字
*/
private ServerSocketChannel socketChannel = null;
/**
* 两个 NIO 多路复用器
*/
private Selector[] selectors = new Selector[2];
private MoreReactor() {
try {
// 初始化多路复用器
selectors[0] = Selector.open();
selectors[1] = Selector.open();
// 初始化 NIO 服务端套接字
socketChannel = ServerSocketChannel.open();
// 服务端套接字绑定要监听的端口
socketChannel.socket().bind(new InetSocketAddress(PORT));
// 设置套接字为非阻塞类型
socketChannel.configureBlocking(false);
// 注册服务端套接字的请求就绪事件到多路复用器
SelectionKey key = socketChannel.register(selectors[0], SelectionKey.OP_ACCEPT);
// 创建 Acceptor 对象,并将该对象附加到选择键,后续可以通过 attachment() 方法获取该附加对象
key.attach(new Acceptor());
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public void run() {
// 只要线程没停止,无限轮询多路复用器
while (!Thread.interrupted()) {
for (Selector selector : selectors) {
try {
// 多路复用器阻塞等待客户端连接
selector.select(1000);
} catch (IOException e) {
e.printStackTrace();
}
// 返回就绪需要处理的选择键
Set<SelectionKey> keys = selector.selectedKeys();
Iterator iterator = keys.iterator();
while (iterator.hasNext()) {
SelectionKey key = (SelectionKey) iterator.next();
// 分发就绪的选择键到不同的 Handler
dispatch(key);
// 处理过的选择键从迭代器中删除
iterator.remove();
}
}
}
}
private void dispatch(SelectionKey key) {
// 从选择键中获取附加对象,一般为线程对象
Runnable runnable = (Runnable) key.attachment();
// 如果选择键附加属性不为空,就运行该线程
if (runnable != null) {
runnable.run();
}
}
private class Acceptor implements Runnable {
@Override
public void run() {
try {
// 完成三次握手,建立数据链路
SocketChannel channel = socketChannel.accept();
if (channel != null) {
// 获取随机值,将该通连接注册到随机某一个多路复用器上
int random = new Random().nextInt(2);
// 创建新 Handler 处理已经连接的数据链路
new Handler(selectors[random], channel);
}
} catch (IOException e) {
// 说明数据链路建立失败
e.printStackTrace();
}
}
}
}
多 Reactor 模式下建议使用 select(xxx) 设置最大阻塞时间。否则可能阻塞在某个没有就绪事件的多路复用器上,而有就绪事件的多路复用器反而得不到执行。本人在使用双多路复用器测试时,无论就绪事件注册到哪个多路复用器,都不会向下执行:
如果注册到第一个多路复用器,for 循环遍历时,会阻塞到第二个多路复用器
如果注册到第二个多路复用器,for 循环遍历到第二个多路复用器时,此时还未就绪,又会阻塞到第二轮循环的第一个多路复用器,因此建议使用 select(xxx) 设置最大阻塞时间。
在上述代码示例中,我们创建两个多路复用器,根据随机值注册监听事件到不同多路复用器上,防止所有客户端连接的 I/O 操作集中在同一个多路复用器上,造成 CPU 飙升影响性能等问题。
Reactor 模式组件
前面我根据具体功能给出我理解的 Reactor 的结构,下面我们来看看官方对于 Reactor 模式组件的分类:
- Handle:本质表示操作系统提供的一种资源,用来表示各种事件。在 linux 操作系统中即文件描述符,Windows 系统中称为句柄,其中它是事件的发源地。
- Synchronous Event Demultiplexer:同步事件分离器,其中它本身是系统调用,用于等待事件的发生。调用方在调用它的时候会被阻塞,直到同步分离器上有事件为止。在 linux 操作系统中,同步事件分离器即 I/O 多路复用,如 select,poll,epoll。在 Java NIO 中就表示 Selector ,多路复用器。
- EventHandler:事件处理器,本身由多个回调方法构成,这些回调方法即具体 I/O 事件处理完毕的反馈实现。Java NIO 默认没有提供该实现供我们去回调,需要我们手动自行开发。
- Concrete Event Handler:EventHandler 的具体实现,它本身实现了大量已经实现的回调方法
- Initiation Dispatcher:初始分发器,实际上就是 Reactor 对象。其中它是事件处理器的核心:内部定义规范维护事件的回调方式,并且对具体事件提供事件处理器的添加、删除操作等。当具体事件发生时,它可以剥离事件,调用具体事件处理类,最后调用相关回调方法。
基于这种组件结构,我简单聊聊 Reactor 模式的工作流程:
- 根据 Handler(事件类型)注册基于 EventHandler(事件处理器)实现的 Concrete Event Handler(事件处理器实现)到 Initiation Dispatcher(分发器)
- Initiation Dispatcher(分发器)启动 Synchronous Event Demultiplexer(同步事件分离器)阻塞等待就绪事件发生
- 如果就绪事件发生,Synchronous Event Demultiplexer(同步事件分离器)将具体事件通知到 Initiation Dispatcher(分发器)
- Initiation Dispatcher(分发器)根据事件类型,选择具体的 Concrete Event Handler(事件处理器)实现回调方法
从这里也就可以看出,Reactor 模式的具体处理过程和 NIO 其实是很相像的。
Reactor 模式的优缺点
最后我们来总结使用 Reactor 模式的优缺点:
Reactor 模式优点:
- 响应快:基于多路复用器实现,单个客户端的阻塞不会影响其它就绪客户端的响应
- 结构清晰:根据模块功能的不同,结构清晰,代码相对易读
- 可扩展:可以通过提升 Reactor 的数量提高系统的稳定性,性能提升应该不会很大
Reactor 模式缺点:
- 相比传统 I/O 模型,编程难度提升
- 多 Reactor 是将不同的 I/O 注册到不同的 Reactor 上,本身还是单线程,不会影响性能,但可以提高稳定性
- 虽然多个客户端之间阻塞不存在影响,但数据就绪后具体的复制操作还是在单个线程中实现,如果传输量本身非常大,数据传输过程很慢,即使在多 Reactor 情况下,也会影响其它客户端的处理速度
至此关于 Reactor 模式的介绍全部结束!