前面看完了Netty一次请求的处理流程以及相关的类,我们知道了Netty中核心的类或者说分层是:EventLoop,Unsafe,ChannelPipeline,ChannelHandler。其中的EventLoop用于启动一个线程,实例化一个Selector对象,然后不断的select出Channel交给Unsafe处理,接下来仔细分析一下EventLoop的行为。
一 Netty线程模型
Netty可以根据用户的配置,使用以上三种不同的线程模型:
1.1 单线程模型
只启动一个线程,这个线程即负责所有的功能,包括以下两个:
- 不断的从Channel中select事件
- 处理select事件,包括建立连接,读/写数据
这种模型在高并发的情况下显然是不行的,它存在以下几个问题:
- 虽然NIO可以让一个线程同时监控非常多个Channel,但是这Channel非常多的情况下性能不行,每个Channel的处理是串行的,必需要处理完一个才能处理下一个,这种情况下可能导致后面处理的Channel超时。
- 单点问题,如果这个单一的线程意外跑飞,则整个系统的通信模块就会全部不可用。
多了解决这些问题,演进出了多线程模型。
1.2 多线程模型
在这种模型下会有一个专门的线程(acceptor线程)去处理客户端的连接,其他所有的IO操作都交给IO线程池去执行。
acceptor线程建立连接完成后会把客户端对应的Channel交给某一个线程处理,以后这个客户端的所有操作都会由这一个线程完成(这样可以避免并发问题)。
这种线程模型可以解决绝大多数的问题,但是如果并发量大高,单个线程所承担所有的客户端建立连接的操作可能会存在性能不足的问题,为了解决这个问题,就引出了主从多线程模型。
1.3 主从多线程模型
这在种线程模型下,会有多个Acceptor线程去负责与客户端建立连接,然后把收到的SocketChannel注册后IO线程池中,接下来的所有操作都交给IO线程处理。
1.4 Netty配置线模型的方式
在Netty中使用的线程模型是第三种,其实第三种模型可以认为是前面两种的一般形式,所以也可以把Netty的线程模型配置成第一,二种,下面分别说一下:
在创建ServerBootstrap对象启动服务器时,需要指定Acceptor线程池和IO线程池。如果我们分别给两个线程池分配多个线程,那么使用的就是第三种主从多线程模型:
NioEventLoopGroup acceptGroup = new NioEventLoopGroup(2);
NioEventLoopGroup workerGroup = new NioEventLoopGroup(4);
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(acceptGroup, workerGroup);
serverBootstrap.bind(8080);
serverBootstrap.bind(8081);
serverBootstrap.bind(8082);
serverBootstrap.bind(8083);
注意这里一定要绑定4个端口,Netty中每个端口只会启动一个Acceptor线程去监听。
如果把Acceptor的线程池定为1,或者虽然定为了多个但是只绑定了一个端口,那么使用的就是第二种,单个Acceptor的多线程模型:
NioEventLoopGroup acceptGroup = new NioEventLoopGroup(1);
NioEventLoopGroup workerGroup = new NioEventLoopGroup(4);
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(acceptGroup, workerGroup);
serverBootstrap.bind(8080);
如果Acceptor线程池和IO线程池都只使用一个线程,并且共用一个线程,那么就是第一种的单线程模型:
NioEventLoopGroup commonGroup = new NioEventLoopGroup(1);
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(commonGroup, commonGroup)
1.5 为业务逻辑单独创建线程
理论上,在Netty的IO线程中就可以处理业务逻辑可,但是如果业务逻辑比较复杂的话,不建意这样做,可以创建一个业务线程池,把IO线程处理完成后得到的消息体交给业务线程处理。
例如,假设我们的业务逻辑耗时需要1s,改下前面的demo模拟这种情况:
我们使用了两个线程去接收连接,4个线程去处理事件,然后在获取到请求之后返回数据之前sleep一秒。
然后客户端启动20个线程,每个线程都去请求一次,看看结果:
Now is : Tue Jul 23 11:00:14 CST 2019, use time is: 1134
Now is : Tue Jul 23 11:00:14 CST 2019, use time is: 1121
Now is : Tue Jul 23 11:00:14 CST 2019, use time is: 1117
Now is : Tue Jul 23 11:00:14 CST 2019, use time is: 1112
Now is : Tue Jul 23 11:00:15 CST 2019, use time is: 2110
Now is : Tue Jul 23 11:00:15 CST 2019, use time is: 2117
Now is : Tue Jul 23 11:00:15 CST 2019, use time is: 2116
Now is : Tue Jul 23 11:00:15 CST 2019, use time is: 2106
Now is : Tue Jul 23 11:00:16 CST 2019, use time is: 3113
Now is : Tue Jul 23 11:00:16 CST 2019, use time is: 3143
Now is : Tue Jul 23 11:00:16 CST 2019, use time is: 3149
Now is : Tue Jul 23 11:00:16 CST 2019, use time is: 3131
Now is : Tue Jul 23 11:00:17 CST 2019, use time is: 4149
Now is : Tue Jul 23 11:00:17 CST 2019, use time is: 4159
Now is : Tue Jul 23 11:00:17 CST 2019, use time is: 4158
Now is : Tue Jul 23 11:00:17 CST 2019, use time is: 4156
Now is : Tue Jul 23 11:00:18 CST 2019, use time is: 5157
Now is : Tue Jul 23 11:00:18 CST 2019, use time is: 5165
Now is : Tue Jul 23 11:00:18 CST 2019, use time is: 5127
Now is : Tue Jul 23 11:00:18 CST 2019, use time is: 5166
日志中打印了客户端统计到的响应时间,前面4个请求是正常的,在1.1s左右,但是后续的请求响应时间会特别慢。
我们把服务端改造一下,用一个专门的业务线程池去处理业务逻辑:
这时候再测一下:
Now is : Tue Jul 23 11:28:28 CST 2019, use time is: 1115
Now is : Tue Jul 23 11:28:28 CST 2019, use time is: 1115
Now is : Tue Jul 23 11:28:28 CST 2019, use time is: 1106
Now is : Tue Jul 23 11:28:28 CST 2019, use time is: 1097
Now is : Tue Jul 23 11:28:28 CST 2019, use time is: 1113
Now is : Tue Jul 23 11:28:28 CST 2019, use time is: 1089
Now is : Tue Jul 23 11:28:28 CST 2019, use time is: 1113
Now is : Tue Jul 23 11:28:28 CST 2019, use time is: 1116
Now is : Tue Jul 23 11:28:28 CST 2019, use time is: 1088
Now is : Tue Jul 23 11:28:28 CST 2019, use time is: 1112
Now is : Tue Jul 23 11:28:28 CST 2019, use time is: 1107
Now is : Tue Jul 23 11:28:28 CST 2019, use time is: 1089
Now is : Tue Jul 23 11:28:28 CST 2019, use time is: 1119
Now is : Tue Jul 23 11:28:28 CST 2019, use time is: 1105
Now is : Tue Jul 23 11:28:28 CST 2019, use time is: 1108
Now is : Tue Jul 23 11:28:28 CST 2019, use time is: 1089
Now is : Tue Jul 23 11:28:28 CST 2019, use time is: 1123
Now is : Tue Jul 23 11:28:28 CST 2019, use time is: 1119
Now is : Tue Jul 23 11:28:28 CST 2019, use time is: 1097
Now is : Tue Jul 23 11:28:28 CST 2019, use time is: 1127
业务逻辑1s,整体的响应时间在1.1s左右,正常范围。
二 EventLoop和EventLoopGroup
上面分析了Netty中的线程模型,而Netty中的线程模型安全是EventLoop这一层来处理的,这一节来看看EventLoop这一层的两个核心类:EventLoop和EventLoopGroup。主要是分析了以下几个方面:
- EventLoopGroup内的数据结构以及它是如何维护EventLoop的
- EventLoopGroup在初始化时做了什么事
- EventLoop在初始化时做了什么事
2.1 EventLoopGroup 组织EventLoop的方式
它的接口定义如下:
public interface EventLoopGroup extends EventExecutorGroup {
EventLoop next();
ChannelFuture register(Channel channel);
ChannelFuture register(Channel channel, ChannelPromise promise);
}
next方法用于获取下一个EventLoop对象,两个register方法用于向它内部的某个EventLoop对象注册Channel。
所以问题就来了:假如外部调用了它的register方法注册Channel,那么它会注册到哪个EventLoop中呢?
这就涉及到EventLoopGroup组织维护EventLoop的方式:
在EventLoopGroup内部,使用的是线性表(数组)来维护EventLoop的信息:
private final EventExecutor[] children;
这个数组虽然是EventExecutor类型的,但是实际上保存的是NioEventLoop。NioEventLoop实现了EventExecutor接口。
当外部调用register方法时,NioEventLoop会调用EventExecutorChooser对象来选择一个EventLoop对象。而EventExecutorChooser选取的方法很简单:轮询。
这里有两个EventExecutorChooser的实现类,第一个只会在children数组(也就是EventLoop数组)长度为2的整数次幂时才会使用,其实两个都是使用了轮询的方式。
private final class PowerOfTwoEventExecutorChooser implements EventExecutorChooser {
@Override
public EventExecutor next() {
return children[childIndex.getAndIncrement() & children.length - 1];
}
}
private final class GenericEventExecutorChooser implements EventExecutorChooser {
@Override
public EventExecutor next() {
return children[Math.abs(childIndex.getAndIncrement() % children.length)];
}
}
2.2 EventLoopGroup的初始化过程
EventLoopGroup的初始化后做了两件事:
2.2.1 计算EventLoop数组的长度:
- 如果创建EventLoopGroup对象时传了参数,则会直接使用这个长度。
- 如果没有传参,则会去判断启动参数中是否有io.netty.eventLoopThreads,有则使用用启动参数的值
- 若没有参数则会取当前CPU核心数的两倍
//这种方式会使用4个EventLoop
NioEventLoopGroup acceptGroup = new NioEventLoopGroup(4);
//这种方式会先判断启动参数中是否有io.netty.eventLoopThreads,我没有设置这个参数,所以会取CPU核心数的两倍
NioEventLoopGroup workerGroup = new NioEventLoopGroup();
2.2.2 初始化所有的EventLoop对象
具体逻辑在MultithreadEventExecutorGroup类的构造方法中,这个类是NioEventLoopGroup的父类。这里就不贴代码了(在文章中贴太多代码只会让人看不下去)
总结:EventLoopGroup这个类整个上看只做了三件事:
- 使用线性表维护了EventLoop对象。
- 初始化时计算并初始化了所有的EventLoop对象。
- 外部register时,把register请求交给了内部的某一个EventLoop对象。
2.3 EventLoop的初始化过程
EventLoop初始化时,主要做了以下几件事:
- 创建一个Selector对象
- 根据用户的设置,使用反射优化JDK中的SelectionKey
- 初始化任务队列和用于执行队列的线程池
此外,在第一次向EventLoop中提交任务时,会启动一个线程不断的从Selector中select出事件,就只有这些,没了。
EventLoop还继承了Executor对象,也就是说,它还是一个线程池,为什么要这样设计?
因为Netty为了保证线程安全,同一个客户端对应Channel的事件所有操作都会交给同一个EventLoop来执行,然后EventLoop会使用同一个线程去执行这个Channel的所有事件。
因此EventLoop就必需可以接受其他EventLoop传入的事件,例如acceptor线程池要向IO线程池注册Channel,方式就是向IO线程池提交一个任务。
至于启动线程不断select事件的后续操作,上一篇文章已经简单说了,是根据事件的不同类型调用Unsafe层的一些方法。
到了这里,EventLoop这一层大概的逻辑还是弄清楚了,但是还有几个问题没解决:
- JDK中的SelectionKey有什么缺点?为什么要优化?
- JDK中还有一个epool的bug,这个bug是什么,EventLoop是如何解决这个bug的
这些内容,还是以后再补充吧。
网友评论