本文是Netty文集中“Netty in action”系列的文章。主要是对Norman Maurer and Marvin Allen Wolfthal 的 《Netty in action》一书简要翻译,同时对重要点加上一些自己补充和扩展。
概要
- 线程模式概述
- 事件循环概念和实现
- 定时任务
- 实现细节
线程模型概述
一个基于线程池的模式可以描述为:
- 从池的空闲队列中选择一个线程,并将该线程分配以运行一个提交上来的任务( 任务实现了Runnable接口 )。
- 当任务完成,线程返回给队列,并可用于重复使用。
EventLoop 接口
执行一个任务用于处理一个连接生命周期期间遇到的事件,这是任何一个网络框架的基本功能。相应的网络结构经常会引用一个事件循环( event loop ),Netty采用 io.netty.channel.EventLoop 接口。
一个事件循环的基本思想通过👇的例子来展示:Netty的EventLoop是一个协作设计的一部分,该协作设计使用了两个基本APIs:并发 和 网络。第一个是,io.netty.util.concurrent包,该包依赖于JDK java.util.concurrent包,用于提供线程执行器。第二个的类在包io.netty.channel中,扩展该类为了与Channel事件对接。
一个EventLoop由一个永远不会改变的线程所驱动,并且任务( Rannable 或 Callable )能被直接提交给EventLoop实现立即或定时的执行。
Event/Task 执行的顺序:事件和任务根据FIFO( 先进先出 )的顺序被执行。这消除了数据损坏的可能性,因此保证了以正确的顺序处理字节内容。
依赖系统配置和有效核心,可以创建多个EventLoops以使资源使用最优化,并且一个EventLoop可能被分配与服务多个Channels。
Netty4 中的I/O和事件处理
I/O操作触发一个事件,该事件流经含有一个或多个ChannelHandlers实例的ChannelPipeline。传播这些事件的方法调用能被ChannelHandler拦截,并根据需要处理事件。
一个事件的本质通常决定了它该被如何处理;它可能转换数据从网络栈到你的应用中,或执行相反操作,或执行完全不同的操作。但事件处理逻辑必须是通用的并且足够灵活去处理所有情况。因此,在Netty4中所有I/O操作和事件处理都在EventLoop所在的线程上执行。
这与Netty3是不同的。
Netty3 中的I/O操作
在早前的版本的线程模式中,仅保证所有的入站事件会在I/O线程(相当于Netty 4 中的EventLoop)上执行。所有的出站事件将通过调用线程来处理,该线程可能是I/O线程也可能是其他线程。起初这看似是一个好主意,但是很快这被认为是会有问题的,因为我们需要小心出站事件在ChannelHandler的同步性 ( 👈 比如,你再不同的线程中同时调用了同一个Channel的Channel.write()方法。因此出站的ChannelHandler可以被多个线程同时方法,这就存在了同步性问题 )。总而言之,多个线程不会尝试同时访问一个出站事件,这是无法保证的。这是可能发生的,比如,你再不同的线程中同时调用了同一个Channel的Channel.write()方法,以此触发了相同的下游事件。
另一个消极的副作用发生在,当一个入站事件作为一个出站事件的结果被触发。当Channel.write()导致了一个异常,你需要产生并触发一个exceptionCaught事件。但在Netty3模式中,因为exceptionCaught是一个入站事件,你将在调用线程上去挂起执行代码的线程(即,挂起执行Channel.write的线程),然后到I/O线程上执行该异常事件,这产生了额外的线程上下文切换。
Netty4所采用的线程模式解决了该问题,通过处理所有的事情在一个给定EventLoop上的同一个线程。这提供了一个简单的执行架构并消除了ChannelHandler的同步必要性( 除了可在多个Channels共享的ChannelHandler )。
定时任务
偶尔你需要延迟处理一个任务或定时周期性处理一个任务。比如,你可能想要注册一个事件用于解除一个客户端连接,当该客户端已经连接5分钟后。一个常见的使用场景是发送一个心跳包消息到远端去检查连接是否还活着。如果没后收到回复,你就知道你能够关闭这个Channel。
JDK的定时API
尽管ScheduledExecutorService API非常的简单,但在大负载下可能会导致性能损耗。下一节,我们将看到Netty如何使用更好的性能来提供相同的功能
使用EventLoop的定时任务
ScheduledExecutorService的实现是有限制性的,比如额外的线程被创建作为池管理的一部分。如果许多任务被积极安排,这可能会遇到瓶颈。
使用JDK的定时任务线程池ScheduledExecutorService的局限性包括:
① 在IO线程中聚合了一个独立的定时任务线程池,这样在处理过程中会存在线程上下文切换问题,这就打破了Netty的串行化设计理念;
② 存在多线程并发操作问题,因为定时任务Task和IO线程NioEventLoop可能同时访问并修改同一份数据;
③ JDK的ScheduledExecutorService从性能角度看,存在性能优化空间。
Netty的EventLoop继承了ScheduledExecutorService,所以它通过对JDK的实现提供所有方法的可用性,包括schedule()和scheduleAtFixedRate()。
为了取消或检查一个执行的状态,可以使用ScheduledFuture,每个异步操作都将返回ScheduledFuture。
实现细节
线程管理
Netty线程模式的优越性取决于确定当前执行线程的一致性;也就是,它是否一个分配给当前Channel以及EventLoop的线程。(EventLoop负责处理一个Channel的所有事件在这个Channel的生命周期期间)
如果调用线程就是EventLoop所在线程的话,那么执行该代码块。否则,EventLoop安排一个任务用于随后执行并将该任务放到一个内部队列中。当EventLoop下一次处理它的事件时,EventLoop将执行队列中的任务。这解释了为什么多个线程能够通过Channel直接交互而不用在ChannelHandler中进行同步操作。
注意,每个EventLoop都有它自己的任务队列,是独立于其他EventLoop的。
我们早前提到过很重要的一点:不要阻塞当前的I/0线程。我们将从另一个方式说明这点:不要将一个耗时的任务放到执行队列中,因为这将阻塞同一线程其他任务。如果你必须使用一个阻塞调用或者执行一个耗时任务,我们建议使用一个专门的EventExecutor。 这里我们根据Netty源码来更加明确的阐述上面的观点,这里我用NioEventLoop的源码进行描述:
① 当执行注册Channel的操作在EventLoop所在线程时,则执行执行该操作
// NioEventLoop的run()方法
protected void run() {
for (;;) {
try {
switch (selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())) {
case SelectStrategy.CONTINUE:
continue;
case SelectStrategy.SELECT:
select(wakenUp.getAndSet(false));
if (wakenUp.get()) {
selector.wakeup();
}
// fall through
default:
}
cancelledKeys = 0;
needsToSelectAgain = false;
final int ioRatio = this.ioRatio;
if (ioRatio == 100) {
try {
processSelectedKeys();
} finally {
// Ensure we always run tasks.
runAllTasks();
}
} else {
final long ioStartTime = System.nanoTime();
try {
processSelectedKeys();
} finally {
// Ensure we always run tasks.
final long ioTime = System.nanoTime() - ioStartTime;
runAllTasks(ioTime * (100 - ioRatio) / ioRatio);
}
}
} catch (Throwable t) {
handleLoopException(t);
}
// Always handle shutdown even if the loop processing threw an exception.
try {
if (isShuttingDown()) {
closeAll();
if (confirmShutdown()) {
return;
}
}
} catch (Throwable t) {
handleLoopException(t);
}
}
}
该方法通过Java NIO Selector的多路复用来实现对多个Channel的监控,该方法还对epoll CPU空轮询bug进行了解决,并且在处理完Selector返回的可执行事件后,会处理taskQueue中的任务。
EventLoop/thread 分配
服务I/O和Channels事件的EventLoops包含在一个EventLoopGroup里。EventLoops被创建和分配的方式是根据传输实现而有所不同。
-
异步传输
异步实现只使用少量的EventLoops(以及和它们关联的线程),在并发模式它们能被多个Channels共享。这允许通过尽可能最少的线程数服务大量Channels,而不是每个Channel分配一个线程。
EventLoopGroup负责去分配一个EventLoop到每一个新创建的Channel。在当前的实现中,使用一个轮询方式以得到一个均衡分布,并且同一个EventLoop可能被分配给多个Channels。
从EventLoopGroup中以轮询的方式获取EventLoop:
另外,请注意EventLoop分配对ThreadLocal使用的影响。因为你EventLoop通常为多个Channels所使用,所以该EventLoop下所有的Channels将对应同一个ThreadLocal。这使得通过ThreadLocal实现一个状态功能将是一个糟糕的选择。然而,在无状态的环境下,在多通道间共享巨大或昂贵的对象甚至是事件仍然是有用的。 -
阻塞传输
但是就像以前一样,它保证每个Channel的I/O事件只会在一个线程上执行——该线程为Channel的EventLoop提供支持。这是Netty设计一致性的另一个例子,并且为Netty可靠性和易用性提供了强大的贡献。
在NIO模式下,EventLoop在EventLoopGroup创建的时候就分配好了,EventLoop的个数是固定的了;而OIO模式下EventLoop是在创建Channel的时候才会同时创建一个EventLoop并分配给这个新创建的Channel,EventLoop的个数随着Channel的增加而增加。可以见在NIO模式下,EventLoop和Channel是一对多的关系;而在OIO模式下,EventLoop和Channel是一对一的关系。并且在NIO模式下,EventLoop的个数是可知的;而在OIO模式下EventLoop的个数是不可知的,它随着Channel的增加而增加。
后记
本文主要对Netty的事件循环和线程模式进行了介绍,其中事件循环是Netty中非常重要的一部分,也涉及到了很多的知识点,也是Netty设计一致性的例子之一。在以后的文章中,还会对EventLoop中涉及到的重要知识点进行详细的分析。
若文章有任何错误,望大家不吝指教:)