一、Reactor模型
当前主流的网络框架几乎都采用了多路复用的方案,而Reactor模型担任其中的事件分发器,负责将读写事件分发给对应的处理者。
Reactor模型有三种:分别是单线程模型、多线程模型和主从多线程模型
1. 传统I/O模型
多个客户端连接服务端时,服务端开启多个线程,每个线程处理一个客户端服务。处理一个网络请求的流程如下:
read:从socket读取数据
decode:网络的数据传输都是以byte进行的,因此想要获取真正的请求,需要进行解码
compute:计算,即业务处理逻辑
encode:将数据进行编码,以byte进行网络数据传输
send:发送请求的结果
弊端:
- 需要开启大量的线程。
- 基于阻塞方式,请求未得到响应前会持续阻塞等待。
2. 单线程Reactor模型
(摘自 Lea D. Scalable IO in Java)
多个客户端连接服务端时,统一连接到Reactor上,由Reactor监听多个客户端的连接、读写事件。dispatch负责将不同的事件分发给不同的处理者,当监听到连接事件时,dispatch分发给Acceptor处理;当监听到有I/O读写事件时,dispatch分发给对应的Handler进行处理。
弊端:
- 由于是单线程,当处理一个客户端请求的时候,其他客户端请求只能等待。
3. 多线程Reactor模型
(摘自 Lea D. Scalable IO in Java)
与单线程类似,但是引入了线程池的处理方式,当Reactor监听到读写事件时,交由线程池进行处理,从而可以处理多个客户端请求。
弊端
- Reactor监听的事件过多,连接事件一个客户端只有一次,但是读写事件一个客户端可以发生很多次,对于Reactor负担过大。
4. 主从多线程Reactor模型
(摘自 Lea D. Scalable IO in Java)
引入两个Reactor,分别是mianReactor和subReactor。mainReactor仅负责监听客户端的连接事件,连接成功后,将新创建的连接对象注册到subReactor中。subReactor负责监听I/O读写事件,当有读写事件发生时,由dispatch分发给对应的Handler,同时利用线程池提高处理速度。
5. Reactor线程模型运行机制
Reactor线程模型的运行机制包括四个步骤:
- 连接注册:Channel建议后,注册至Selector选择器
- 事件轮询:轮询Selector选择器中已经注册的所有Channel中的I/O事件
- 事件分发:为准备就绪的I/O分配相应的处理线程
- 任务处理:Reactor线程还负责任务队列中的非I/O任务,每个Worker线程从各自维护的任务队列中取出任务异步执行
二、Nettty中的EventLoop
1. EventLoop的概念
EventLoop并非是Netty独有的概念,而是一种时间等待和处理的程序模型,可以解决多线程资源消耗高的问题。每当有事件发生,应用程序会将事件放入事件队列中,然后EventLoop轮询从队列中取出事件执行或者将事件分发给对应的事件监听者执行。时间执行的方式分为:立刻执行、延后执行和定期执行。2. Netty如何实现EventLoop?
基于Netty中的EventLoop源码进行分析,核心的run()方法如下:
protected void run() {
for (;;) {
try {
try {
switch (selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())) {
case SelectStrategy.CONTINUE:
continue;
case SelectStrategy.BUSY_WAIT:
case SelectStrategy.SELECT:
select(wakenUp.getAndSet(false)); // 轮询 I/O 事件
if (wakenUp.get()) {
selector.wakeup();
}
default:
}
} catch (IOException e) {
rebuildSelector0();
handleLoopException(e);
continue;
}
cancelledKeys = 0;
needsToSelectAgain = false;
final int ioRatio = this.ioRatio;
if (ioRatio == 100) {
try {
processSelectedKeys(); // 处理 I/O 事件
} finally {
runAllTasks(); // 处理所有任务
}
} else {
final long ioStartTime = System.nanoTime();
try {
processSelectedKeys(); // 处理 I/O 事件
} finally {
final long ioTime = System.nanoTime() - ioStartTime;
runAllTasks(ioTime * (100 - ioRatio) / ioRatio); // 处理完 I/O 事件,再处理异步任务队列
}
}
} catch (Throwable t) {
handleLoopException(t);
}
try {
if (isShuttingDown()) {
closeAll();
if (confirmShutdown()) {
return;
}
}
} catch (Throwable t) {
handleLoopException(t);
}
}
}
NioEventLoop每次循环的处理流程包含:时间轮询select、时间处理processSelectedKeys、任务处理runAllTasks,是典型的Reactor线程模型的运行机制。
Netty还提供了一个参数ioRatio,可以调整I/O事件处理和任务处理的时间比例。
3. I/O事件处理机制
EventLoop的事件流转图:
- BossEventLoopGroup和WorkerEventLoopGroup是两个线程池,分别包含一个或者多个NioEventLoop线程。BossEventLoopGroup负责监听客户端的Accept事件,当监听到该事件时,将事件注册到WorkerEventLoopGroup的一个NioEventLoop。每新建一个Channel,只会绑定一个NioEventLoop,Channel生命周期内的所有事件都是线程独立的,不通的NioEventLoop线程彼此之间没有任何交流
- NioEventLoop完成数据读取后,会绑定ChannelPipeLine进行事件传播,ChannelPipeLine也是线程安全的。数据会被传到第一个ChannelHandler中,数据处理完再将加工好的数据传到下一个ChannelHandler,整个过程是串行化执行
- Netty采用了无锁串行设计,利用每个NioEvevtLoop单线程执行一系列的ChannelHandler,避免了线程上下文切换。但是这种设计存在一个缺陷:当某个I/O事件发生阻塞,后续的I/O事件都无法执行,容易造成事件积压,因此需要对ChannelHandler的实现逻辑有风险意识
Netty如何解决Epoll空轮询
在JDK的epoll实现中,即使Selector轮询的事件为空,NIO线程一样可以被唤醒,导致CPU100%占用,这就是epoll的空轮训bug。Netty提供了自己的解决方案,用于避免epoll空轮询。
long time = System.nanoTime();
if (time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos) {
selectCnt = 1;
} else if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 &&
selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {
selector = selectRebuildSelector(selectCnt);
selectCnt = 1;
break;
}
- 每次select之前记录当前时间currentTimeNanos
- 判断条件:time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos。如果事件轮询的持续时间大于timeoutMillis,说明是正常的,否则表明阻塞时间并未达到预期,可能存在空轮询
- 引入计数变量selectCnt。当没有空轮询时,selectCnt会被重置为1,否则对selectCnt进行自增。当selectCnt达到 SELECTOR_AUTO_REBUILD_THRESHOLD(默认512) 阈值时,重建Selector对象,重建之后异常的Selector对象就可以废弃了
4. 任务处理机制
NioEventLoop除了处理I/O事件外,还需要兼顾处理任务队列中的任务。任务队列遵循先进先出的规则,任务类型分为三种
- 普通任务:通过execute()方法向任务队列taskQueue中添加任务。如:Netty在写数据时会封装WireAndFlushTask提高给taskQueue。taskQueue的实现类是多生产者单消费者队列MultiChunkedArrayQueue,在多线程并发添加任务时,可以保证线程安全
- 定时任务:通过schedule()方法向定时任务队列scheduleTaskQueue中添加一个定时任务,用于周期执行该任务。如:心跳消息发送等。scheduleTaskQueue采用PriorityQueue实现
- 尾部任务:tailTaskk相比于普通任务队列优先级较低,在每次执行完taskQueue中的任务后会去获取尾部队列中的任务执行。尾部任务主要用于做一些收尾工作,如:统计事件循环的执行时间、监控信息上报等。
源码逻辑:结合任务处理的runAllTasks()方法进行源码分析
protected boolean runAllTasks(long timeoutNanos) {
// 1. 合并定时任务到普通任务队列
fetchFromScheduledTaskQueue();
// 2. 从普通任务队列中取出任务
Runnable task = pollTask();
if (task == null) {
afterRunningAllTasks();
return false;
}
// 3. 计算任务处理的超时时间
final long deadline = ScheduledFutureTask.nanoTime() + timeoutNanos;
long runTasks = 0;
long lastExecutionTime;
for (;;) {
// 4. 安全执行任务
safeExecute(task);
runTasks ++;
// 5. 每执行 64 个任务检查一下是否超时
if ((runTasks & 0x3F) == 0) {
lastExecutionTime = ScheduledFutureTask.nanoTime();
if (lastExecutionTime >= deadline) {
break;
}
}
task = pollTask();
if (task == null) {
lastExecutionTime = ScheduledFutureTask.nanoTime();
break;
}
}
// 6. 收尾工作
afterRunningAllTasks();
this.lastExecutionTime = lastExecutionTime;
return true;
}
- fetchFromScheduleTaskQueue()方法将定时任务从scheduleTaskQueue中取出,放入普通任务队列taskQueue中,只有定时任务的截至时间小于当前时间才会被放入
- 从普通任务队列中taskQueue取出任务
- 计算任务执行的最大超时时间
- safeExecute()方法:安全执行任务,实际直接调用Runnable的run()方法
- 每执行64个任务进行超时时间的检查,如果执行时间大于超时时间,立刻停止执行任务,避免影响下一轮I/O事件处理
- 获取尾部队列中的任务执行