Java NIO学习与记录(八): Reactor两种多线程模型的实现
注:本篇文章例子基于上一篇进行:Java NIO学习与记录(七): Reactor单线程模型的实现
前言:单线程Reactor模型的缺点
紧接着上篇Reactor单线程模型的例子来,假设Handler的read那里的处理方式延迟5s,当做是业务性能瓶颈,改变下原来的Handler,让其read方法在处理时延迟5s:
private void read() throws IOException {
if (selectionKey.isValid()) {
System.out.println("服务端读取数据前");
readBuffer.clear();
int count = socketChannel.read(readBuffer);
if (count > 0) {
try {
Thread.sleep(5000L); //读取信息后睡眠5s当做业务处理瓶颈
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(String.format("收到来自 %s 的消息: %s",
socketChannel.getRemoteAddress(),
new String(readBuffer.array())));
status = SEND;
selectionKey.interestOps(SelectionKey.OP_WRITE);
} else {
selectionKey.cancel();
socketChannel.close();
System.out.println("read时-------连接关闭");
}
}
}
现在同样开启两个客户端同时连接到该服务端,然后请求-->收到响应-->再次请求的流程走10次,会发现,客户端每收到一次响应需要10s,同样的如果开启3个客户端,则需要15s,因为单线程的Reactor模型是串行的,业务处理的瓶颈可以影响到全局的事件分发,这种模型下,如果存在类似例子中的瓶颈点是致命的(例子的5s是夸张处理),因为新进来的连接也会排队,整个select都会被Handler的处理给阻塞掉,举个实际点的例子,redis在使用时大部分时候会避免使用类似keys这种重操作,为什么呢?就是因为redis是单线程,这里说的单线程其实并不是说redis服务端就一个线程,而是说redis采用的NIO Reactor模型就是单线程的Reactor模型,跟上面代码里做的改动一样,5s可以理解成重操作,影响整个模型的正常运作,redis之所以采用单线程模式,是因为redis大部分操作实在是太快了,快到使用这种模式也可以提供近十万/秒的并发能力,单线程模型实现起来简单且可控性强,所以redis很自然的选择了这种模式。回到问题本身,我们自己的业务可能并没有redis那样高的处理能力,搞不好几个网络请求就可以造成性能瓶颈,拖慢甚至拖垮整个处理模型,所以大部分RPC框架和web容器并不会采用单线程的Reactor模型实现,那么有没有什么方法可以优化这种模型呢?比如,把这个瓶颈点利用独立线程异步出去处理,这样可以保证不影响select的执行,也就很好的避免了上面的问题了,下面介绍两种多线程异步的Reactor模型。
一、单Reactor多线程模型
模型图:
图1
上图与单线程Reactor模型对比可以看出,读入数据后,对数据的业务处理部分被线程池做了异步处理,也就是说,上述5s的那段瓶颈被放到了子线程去处理,select的执行不会受到任何影响,因此对新的连接处理、多个客户端的响应速度都应该可以得到保障。
现在来改写下前篇文章里的单线程处理模式的Handler,更名为AsyncHandler:
public class AsyncHandler implements Runnable {
private final Selector selector;
private final SelectionKey selectionKey;
private final SocketChannel socketChannel;
private ByteBuffer readBuffer = ByteBuffer.allocate(1024);
private ByteBuffer sendBuffer = ByteBuffer.allocate(2048);
private final static int READ = 0; //读取就绪
private final static int SEND = 1; //响应就绪
private final static int PROCESSING = 2; //处理中
private int status = READ; //所有连接完成后都是从一个读取动作开始的
//开启线程数为5的异步处理线程池
private static final ExecutorService workers = Executors.newFixedThreadPool(5);
AsyncHandler(SocketChannel socketChannel, Selector selector) throws IOException {
this.socketChannel = socketChannel;
this.socketChannel.configureBlocking(false);
selectionKey = socketChannel.register(selector, 0);
selectionKey.attach(this);
selectionKey.interestOps(SelectionKey.OP_READ);
this.selector = selector;
this.selector.wakeup();
}
@Override
public void run() { //如果一个任务正在异步处理,那么这个run是直接不触发任何处理的,read和send只负责简单的数据读取和响应,业务处理完全不阻塞这里的处理
switch (status) {
case READ:
read();
break;
case SEND:
send();
break;
default:
}
}
private void read() {
if (selectionKey.isValid()) {
try {
readBuffer.clear();
int count = socketChannel.read(readBuffer);
if (count > 0) {
status = PROCESSING; //置为处理中,处理完成后该状态为响应,表示读入处理完成,接下来可以响应客户端了
workers.execute(this::readWorker); //异步处理
} else {
selectionKey.cancel();
socketChannel.close();
System.out.println("read时-------连接关闭");
}
} catch (IOException e) {
System.err.println("处理read业务时发生异常!异常信息:" + e.getMessage());
selectionKey.cancel();
try {
socketChannel.close();
} catch (IOException e1) {
System.err.println("处理read业务关闭通道时发生异常!异常信息:" + e.getMessage());
}
}
}
}
void send() {
if (selectionKey.isValid()) {
status = PROCESSING; //置为执行中
workers.execute(this::sendWorker); //异步处理
selectionKey.interestOps(SelectionKey.OP_READ); //重新设置为读
}
}
//读入信息后的业务处理
private void readWorker() {
try {
Thread.sleep(5000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(String.format("收到来自客户端的消息: %s",
new String(readBuffer.array())));
status = SEND;
selectionKey.interestOps(SelectionKey.OP_WRITE); //注册写事件
this.selector.wakeup(); //唤醒阻塞在select的线程,因为该interestOps写事件是放到子线程的,select在该channel还是对read事件感兴趣时又被调用,因此如果不主动唤醒,select可能并不会立刻select该读就绪事件(在该例中,可能永远不会被select到)
}
private void sendWorker() {
try {
sendBuffer.clear();
sendBuffer.put(String.format("我收到来自%s的信息辣:%s, 200ok;",
socketChannel.getRemoteAddress(),
new String(readBuffer.array())).getBytes());
sendBuffer.flip();
int count = socketChannel.write(sendBuffer);
if (count < 0) {
selectionKey.cancel();
socketChannel.close();
System.out.println("send时-------连接关闭");
} else {
//再次切换到读
status = READ;
}
} catch (IOException e) {
System.err.println("异步处理send业务时发生异常!异常信息:" + e.getMessage());
selectionKey.cancel();
try {
socketChannel.close();
} catch (IOException e1) {
System.err.println("异步处理send业务关闭通道时发生异常!异常信息:" + e.getMessage());
}
}
}
}
可以看到,read里、send里的逻辑处理被异步出去执行,新增了中间状态“执行中”,主要用来防止事件重复触发,重复执行异步逻辑,当异步逻辑处理完毕才会更改状态值,这时候可以继续处理接下来的事件(读或写)。
把Accptor类里的实现换成AsyncHandler,运行服务端和客户端会发现,两个客户端的响应均为5s,也不会阻塞新增的连接,新增至三个或者更多的客户端基本可以保持客户端响应均为5s(说明:这里5s是夸张比喻,正常瓶颈没这么夸张,若开了n多客户端,每个都阻塞5s,那么线程池也会发生排队,因为子线程个数有限,处理不过来,最后还是阻塞,一定会远超过5s)。
通过多线程Reactor模型,降低了业务代码瓶颈导致影响整个Reactor执行链路的风险,但是即便如此,read、send操作仍然和接收请求(accept)处于同一个线程,这就意味着read、send的处理可能会影响到对客户端连接的接收能力,那么有没有一种办法,可以把读写流程彻底异步出去,负责连接的线程就只负责接收连接?于是多Reactor多线程模型就产生了,这种模型也叫主从Reactor模型,该模型下可以分为一个主Reactor专门处理连接事件,而多个从Reactor负责读写、业务处理等,这样服务端可以接收并处理更多的请求,提升服务端的吞吐能力(该模型或者说所有基于NIO的Reactor模型,都是以提升服务端处理能力为基础的,NIO在某些情况下不一定会比BIO处理速度快,但一定比BIO稳,就像NIO可以利用很少的线程处理大量的客户端请求,而BIO在大量客户端请求过来的情况下,由于各种操作均会阻塞线程,会处理不过来)。
二、主从Reactor模型
还是把之前文章的图拿来展示下这种模型的流程,可以与上面图1进行对比,看看发生了哪些变化:
图2
上图就是主从Reactor模型的一个流程,看下与图1的不同之处,多了SubReactor这样一个角色,这个角色就是用来处理读写操作的Reactor,现在仍然基于之前的例子,进行改写,明确需要改写的点:
①新增SubReactor
②Acceptor那里进行初始化一批SubReactor,进行分发处理
③为了区分客户端分别是被哪个SubReactor处理的读写操作,还需要改写下AsyncHandler,在里面加上SubReactor的序号,打印信息时进行区分。
ok,总结完改动点,现在基于上面的代码(代码初代目版本:Reactor单线程模型的实现)改写一下这几个类:
step1.首先新增SubReactor类
public class SubReactor implements Runnable {
private final Selector selector;
private boolean register = false; //注册开关表示,为什么要加这么个东西,可以参考Acceptor设置这个值那里的描述
private int num; //序号,也就是Acceptor初始化SubReactor时的下标
SubReactor(Selector selector, int num) {
this.selector = selector;
this.num = num;
}
@Override
public void run() {
while (!Thread.interrupted()) {
System.out.println(String.format("%d号SubReactor等待注册中...", num));
while (!Thread.interrupted() && !register) {
try {
if (selector.select() == 0) {
continue;
}
} catch (IOException e) {
e.printStackTrace();
}
Set selectedKeys = selector.selectedKeys();
Iterator it = selectedKeys.iterator();
while (it.hasNext()) {
dispatch(it.next());
it.remove();
}
}
}
}
private void dispatch(SelectionKey key) {
Runnable r = (Runnable) (key.attachment());
if (r != null) {
r.run();
}
}
void registering(boolean register) {
this.register = register;
}
}
这个类负责Acceptor交给自己的事件select(例子中实际上就是read、send)。
step2.Acceptor类的更改
public class Acceptor implements Runnable {
private final ServerSocketChannel serverSocketChannel;
private final int coreNum = Runtime.getRuntime().availableProcessors(); // 获取CPU核心数
private final Selector[] selectors = new Selector[coreNum]; // 创建selector给SubReactor使用,个数为CPU核心数(如果不需要那么多可以自定义,毕竟这里会吞掉一个线程)
private int next = 0; // 轮询使用subReactor的下标索引
private SubReactor[] reactors = new SubReactor[coreNum]; // subReactor
private Thread[] threads = new Thread[coreNum]; // subReactor的处理线程
Acceptor(ServerSocketChannel serverSocketChannel) throws IOException {
this.serverSocketChannel = serverSocketChannel;
// 初始化
for (int i = 0; i < coreNum; i++) {
selectors[i] = Selector.open();
reactors[i] = new SubReactor(selectors[i], i); //初始化sub reactor
threads[i] = new Thread(reactors[i]); //初始化运行sub reactor的线程
threads[i].start(); //启动(启动后的执行参考SubReactor里的run方法)
}
}
@Override
public void run() {
SocketChannel socketChannel;
try {
socketChannel = serverSocketChannel.accept(); // 连接
if (socketChannel != null) {
System.out.println(String.format("收到来自 %s 的连接",
socketChannel.getRemoteAddress()));
socketChannel.configureBlocking(false); //
reactors[next].registering(true); // 注意一个selector在select时是无法注册新事件的,因此这里要先暂停下select方法触发的程序段,下面的weakup和这里的setRestart都是做这个事情的,具体参考SubReactor里的run方法
selectors[next].wakeup(); // 使一個阻塞住的selector操作立即返回
SelectionKey selectionKey = socketChannel.register(selectors[next],
SelectionKey.OP_READ); // 当前客户端通道SocketChannel向selector[next]注册一个读事件,返回key
selectors[next].wakeup(); // 使一個阻塞住的selector操作立即返回
reactors[next].registering(false); // 本次事件注册完成后,需要再次触发select的执行,因此这里Restart要在设置回false(具体参考SubReactor里的run方法)
selectionKey.attach(new AsyncHandler(socketChannel, selectors[next], next)); // 绑定Handler
if (++next == selectors.length) {
next = 0; //越界后重新分配
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
可以跟以前的Acceptor做个对比,做了如下改动:
①接受到连接后不再直接触发handler了
②初始化一堆SubReactor(从反应堆),每个交给一个线程处理,注册读事件后顺序分配给不同的SubReactor去处理自己的selector监听。
以上,就可以把读写处理+业务处理与接受连接的Reactor彻底分开了,接受连接的事件不再受任何读写、业务相关的影响,只负责接收,目前即便是业务线程池用光线程发生排队,也不会影响到连接的接收,很大程度上降低了服务端的接收能力遭遇瓶颈的风险。
step3.改写AsyncHandler的打印
这里就不po代码了,具体就是把SubReactor的序号传给handler,标记触发Handler的Reactor是哪个。
同样的,启动下服务端,再开启两个客户端(跟之前一样,每个客户端发10条消息终止连接),运行结果如下:
服务端:
1号SubReactor等待注册中...
3号SubReactor等待注册中...
0号SubReactor等待注册中...
2号SubReactor等待注册中...
收到来自 /127.0.0.1:60407 的连接
0号SubReactor等待注册中...
收到来自 /127.0.0.1:60410 的连接
1号SubReactor等待注册中...
1号SubReactor触发:收到来自客户端/127.0.0.1:60410的消息: 客户端发送的第1条消息
0号SubReactor触发:收到来自客户端/127.0.0.1:60407的消息: 客户端发送的第1条消息
0号SubReactor触发:收到来自客户端/127.0.0.1:60407的消息: 客户端发送的第2条消息
1号SubReactor触发:收到来自客户端/127.0.0.1:60410的消息: 客户端发送的第2条消息
0号SubReactor触发:收到来自客户端/127.0.0.1:60407的消息: 客户端发送的第3条消息
1号SubReactor触发:收到来自客户端/127.0.0.1:60410的消息: 客户端发送的第3条消息
1号SubReactor触发:收到来自客户端/127.0.0.1:60410的消息: 客户端发送的第4条消息
0号SubReactor触发:收到来自客户端/127.0.0.1:60407的消息: 客户端发送的第4条消息
1号SubReactor触发:收到来自客户端/127.0.0.1:60410的消息: 客户端发送的第5条消息
0号SubReactor触发:收到来自客户端/127.0.0.1:60407的消息: 客户端发送的第5条消息
1号SubReactor触发:收到来自客户端/127.0.0.1:60410的消息: 客户端发送的第6条消息
0号SubReactor触发:收到来自客户端/127.0.0.1:60407的消息: 客户端发送的第6条消息
1号SubReactor触发:收到来自客户端/127.0.0.1:60410的消息: 客户端发送的第7条消息
0号SubReactor触发:收到来自客户端/127.0.0.1:60407的消息: 客户端发送的第7条消息
0号SubReactor触发:收到来自客户端/127.0.0.1:60407的消息: 客户端发送的第8条消息
1号SubReactor触发:收到来自客户端/127.0.0.1:60410的消息: 客户端发送的第8条消息
0号SubReactor触发:收到来自客户端/127.0.0.1:60407的消息: 客户端发送的第9条消息
1号SubReactor触发:收到来自客户端/127.0.0.1:60410的消息: 客户端发送的第9条消息
0号SubReactor触发:收到来自客户端/127.0.0.1:60407的消息: 客户端发送的第10条消息
1号SubReactor触发:收到来自客户端/127.0.0.1:60410的消息: 客户端发送的第10条消息
0号SubReactor触发:read时-------连接关闭
1号SubReactor触发:read时-------连接关闭
客户端:
已完成 /127.0.0.1:2333 的连接
已完成 /127.0.0.1:2333 的连接
收到来自服务端的消息: 0号SubReactor触发:我收到来自/127.0.0.1:60407的信息辣:客户端发送的第1条消息, 200ok;
收到来自服务端的消息: 1号SubReactor触发:我收到来自/127.0.0.1:60410的信息辣:客户端发送的第1条消息, 200ok;
收到来自服务端的消息: 0号SubReactor触发:我收到来自/127.0.0.1:60407的信息辣:客户端发送的第2条消息, 200ok;
收到来自服务端的消息: 1号SubReactor触发:我收到来自/127.0.0.1:60410的信息辣:客户端发送的第2条消息, 200ok;
收到来自服务端的消息: 1号SubReactor触发:我收到来自/127.0.0.1:60410的信息辣:客户端发送的第3条消息, 200ok;
收到来自服务端的消息: 0号SubReactor触发:我收到来自/127.0.0.1:60407的信息辣:客户端发送的第3条消息, 200ok;
收到来自服务端的消息: 1号SubReactor触发:我收到来自/127.0.0.1:60410的信息辣:客户端发送的第4条消息, 200ok;
收到来自服务端的消息: 0号SubReactor触发:我收到来自/127.0.0.1:60407的信息辣:客户端发送的第4条消息, 200ok;
收到来自服务端的消息: 1号SubReactor触发:我收到来自/127.0.0.1:60410的信息辣:客户端发送的第5条消息, 200ok;
收到来自服务端的消息: 0号SubReactor触发:我收到来自/127.0.0.1:60407的信息辣:客户端发送的第5条消息, 200ok;
收到来自服务端的消息: 1号SubReactor触发:我收到来自/127.0.0.1:60410的信息辣:客户端发送的第6条消息, 200ok;
收到来自服务端的消息: 0号SubReactor触发:我收到来自/127.0.0.1:60407的信息辣:客户端发送的第6条消息, 200ok;
收到来自服务端的消息: 1号SubReactor触发:我收到来自/127.0.0.1:60410的信息辣:客户端发送的第7条消息, 200ok;
收到来自服务端的消息: 0号SubReactor触发:我收到来自/127.0.0.1:60407的信息辣:客户端发送的第7条消息, 200ok;
收到来自服务端的消息: 1号SubReactor触发:我收到来自/127.0.0.1:60410的信息辣:客户端发送的第8条消息, 200ok;
收到来自服务端的消息: 0号SubReactor触发:我收到来自/127.0.0.1:60407的信息辣:客户端发送的第8条消息, 200ok;
收到来自服务端的消息: 0号SubReactor触发:我收到来自/127.0.0.1:60407的信息辣:客户端发送的第9条消息, 200ok;
收到来自服务端的消息: 1号SubReactor触发:我收到来自/127.0.0.1:60410的信息辣:客户端发送的第9条消息, 200ok;
收到来自服务端的消息: 0号SubReactor触发:我收到来自/127.0.0.1:60407的信息辣:客户端发送的第10条消息, 200ok;
收到来自服务端的消息: 1号SubReactor触发:我收到来自/127.0.0.1:60410的信息辣:客户端发送的第10条消息, 200ok;
到这里,主从Reactor模型就被改写完成了,上面的例子只是简单演示了下这个模型,所有的例子都是从单线程Reactor模型一点点改写来的,客户端没变过,为的是更好的测试服务端在不同模型下的表现。主从Reactor模型应用的比较多,比如著名NIO框架Netty底层模型也是基于主从Reactor模型来实现的。
到这里java nio的东西已经差不多记录完了,后续会开始netty的学习记录,当然上述例子弱化了buffer的使用,而且例子中不存在粘包拆包的问题(因为都是请求+应答的方式进行),如果把上面的例子改成客户端在未收到响应时就连续发送几条信息,服务端这时再次由写模式切换到读模式,就会从Channel里连续拿到这几条消息,这就导致了粘包问题,那么如何解决类似的问题呢?通常是定义一种协议,来区分消息头和尾,中间的消息体是我们真正需要的数据,这种协议也就是我们常说的应用层协议,比如HTTP、FTP等,这里不做赘述,之后会通过一个例子来完成这部分的补充说明。
代码地址
单线程Reactor模型:https://github.com/exceting/DemoAll/tree/master/jdk/src/main/java/demo/jdk/reactor/simple
多线程Reactor模型:同上,Acceptor里的Handler改成AsyncHandler即可
主从多线程Reactor模型:https://github.com/exceting/DemoAll/tree/master/jdk/src/main/java/demo/jdk/reactor/mainsub