持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第10天,点击查看活动详情
BIO
如果需要彻底了解Reactor模式,还需要从原始的OIO编程(同步阻塞模型)开始讲起.
在java的oio编程中,最初和最原始的服务器程序,使用一个while循环,不断地监听端口是否有新的连接,如果有,那么就调用一个处理函数来处理,示例如下:
while(true){
socket = accept() ; // 阻塞接受连接
handle(socket); // 读取数据,业务处理,写入结果
}
这种方法最大的问题是:如果前一个网络连接的handle(socket)没有处理完,那么后面的连接请求没法被接收,于是后面的请求通通会被阻塞住,服务器的吞吐量太低.
为了解决这个严重的连接阻塞问题,出现了一个极为经典的模式: connect per thread 即一个线程处理一个连接模式,对于每一个新的网络连接都会分配一个线程,每个线程都独自处理自己负责的输入和输出,当然服务器的监听线程也是独立的任何的socket连接的输入和输出处理,不会阻塞后面新的socket连接的监听和建立,早期的tomcat服务器,就是这样实现的.
BIO线程模型
connect per thread 模式的优点是: 解决了前面的新连接被严重阻塞的问题,在一定程度上极大地提高了服务器的吞吐量.
connect per thread 模式的缺点是: 对应于大量的连接,需要耗费大量的线程资源,对线程资源要求太高.在系统中,线程是比较昂贵的资源,如果线程太多,系统无法承受,而且,线程的反复创建,销毁,切换也需要代价,因此高并发的的场景下,多线程OIO的缺陷是致命的. 一个线程大约占用1M内存.
还有一个缺点就是所有的客户端连接并不一定都是活跃的连接.会导致线程资源的浪费.
Reactor模型
首先我们先来看下什么是事件驱动,在 java AWT 包中广泛的得到了使用。用户在点击一个 button 按钮的时候就会触发一个事件,然后会使用观察者模式来触发 Listener 中的处理事件。
Reactor 设计模式是基于事件驱动的一种实现方式,处理多个客户端并发的向服务端请求服务的场景。每种服务在服务端可能由多个方法组成。reactor 会解耦并发请求的服务并分发给对应的事件处理器来处理。目前,许多流行的开源框架都用到了。类似 AWT 中的 Thread。
Handlers 执行非阻塞操作的具体类,类似 AWT 中的 ActionListeners。
1.单Reactor单线程
2.单Reactor多线程
3.主从Reactor多线程
4.多Reator单线程(没有使用)
Doug Lea 大神
JUC包都出自此大神,如: ReentrantLock、AQS
学习Doug Lea 大神写的——Scalable IO in Java
http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf
单Reactor单线程
在单线程模型中Reactor和Acceptor,以及执行任务的线程都在一个线程,当线程在执行耗时的业务处理时,这时的连接请求或者其他的业务不能及时的处理,当请求并发量比较低的时候还是可以抗住的,一旦高并发,将不堪重负,而且单线程也没有充分利用多核cpu的特点,浪费了资源
所有操作都在同一个NIO线程处理,在这个单线程中要负责接收请求,处理IO,编解码所有操作,相当于一个饭馆只有一个人,同时负责前台和后台服务,效率低。
那么单线程Reactor适用于什么情况呢?适用于那种程序复杂度很低的系统,例如redis,其大部分操作都是非常高效的,很多命令的时间复杂度直接为O(1),这种情况下适合这种简单的Reactor模型实现服务端。
注意,Reactor的单线程模式的单线程主要是针对于I/O操作而言,也就是所以的I/O的accept()、read()、write()以及connect()操作都在一个线程上完成的。
但在目前的单线程Reactor模式中,不仅I/O操作在该Reactor线程上,连非I/O的业务操作也在该线程上进行处理了,这可能会大大延迟I/O请求的响应。所以我们应该将非I/O的业务逻辑操作从Reactor线程上卸载,以此来加速Reactor线程对I/O请求的响应。
单Reactor多线程
在单Reactor多线程模型下,连接请求及acceptor仍然在一个线程,io的read和send操作也是在Reactor线程,但是将业务逻辑处理交给线程池(多线程),此时不管业务操作多耗时,线程里有多个线程来处理任务,任务之间不会有大的延迟处理的影响,但如果并发太高,Reactor线程也来不及连接请求和读写数据,于是主从Reactor多线程模型产生
多线程的优点在于有单独的一个线程去处理请求,另外有一个线程池创建多个NIO线程去处理IO。相当于一个饭馆有一个前台负责接待,有很多服务员去做后面的工作,这样效率就比单线程模型提高很多
注意,在此改进的版本中,所有的I/O操作依旧由一个Reactor来完成,包括I/O的accept()、read()、write()以及connect()操作。
对于一些小容量应用场景,可以使用单线程模型。但是对于高负载、大并发或大数据量的应用场景却不合适,主要原因如下:
① 一个NIO线程同时处理成百上千的链路,性能上无法支撑,即便NIO线程的CPU负荷达到100%,也无法满足海量消息的读取和发送;
② 当NIO线程负载过重之后,处理速度将变慢,这会导致大量客户端连接超时,超时之后往往会进行重发,这更加重了NIO线程的负载,最终会导致大量消息积压和处理超时,成为系统的性能瓶颈;
主从Reactor多线程模型
在这个模型中,主要有一个mainReactor,多个subReactor,以及每个subReactor都附带一个线程池,这个模型大大的分解了各项处理,mainReactor主要负责监听来自客户端的请求(即selector.select()),当有连接请求到达,将会调用Acceptor函数(位于mainReactor线程中),并将产生的socketChannel注册到一个subReactor的selector中,而且绑定该socketChannel感兴趣的事件(interestOps),当有该socketChannel感兴趣的时间发生时,该subReactor就会调用selector.select()方法,并将需要完成的任务扔到绑定该subReactor的线程池来执行任务,任务结果返回subReactor,然后由subReactor回复客户端
多线程模型的缺点在于并发量很高的情况下,只有一个Reactor单线程去处理是来不及的,就像饭馆只有一个前台接待很多客人也是不够的。为此需要使用主从线程模型。
主从线程模型:一组线程池接收请求,一组线程池处理IO。
注意: 所有的I/O操作(包括,I/O的accept()、read()、write()以及connect()操作)依旧还是在Reactor线程(mainReactor线程 或 subReactor线程)中完成的。Thread Pool(线程池)仅用来处理非I/O操作的逻辑。
主从Reactor线程模式将“接受客户端的连接请求”和“与该客户端的通信”分在了两个Reactor线程来完成。mainReactor完成接收客户端连接请求的操作,它不负责与客户端的通信,而是将建立好的连接转交给subReactor线程来完成与客户端的通信,这样一来就不会因为read()数据量太大而导致后面的客户端连接请求得不到即时处理的情况。并且多Reactor线程模式在海量的客户端并发请求的情况下,还可以通过实现subReactor线程池来将海量的连接分发给多个subReactor线程,在多核的操作系统中这能大大提升应用的负载和吞吐量。
Netty
Netty服务端使用了“主从Reactor多线程模式”
mainReactor —bossGroup(NioEventLoopGroup) 中的某个NioEventLoop
subReactor — workerGroup(NioEventLoopGroup) 中的某个NioEventLoop
acceptor —ServerBootstrapAcceptor
channelRead()方法中将channel注册到workGroup中
ThreadPool —用户自定义线程池
Selector -- 每个 NioEventLoop中都持有一个Selector对象
流程:
① 当服务器程序启动时,会配置ChannelPipeline,ChannelPipeline中是一个ChannelHandler链,
所有的事件发生时都会触发Channelhandler中的某个方法,这个事件会在ChannelPipeline中的ChannelHandler链里传播。
然后,从bossGroup事件循环池中获取一个NioEventLoop来现实服务端程序绑定本地端口的操作,
将对应的ServerSocketChannel注册到该NioEventLoop中的Selector上,
并注册ACCEPT事件为ServerSocketChannel所感兴趣的事件。
② NioEventLoop事件循环启动,此时开始监听客户端的连接请求。
③ 当有客户端向服务器端发起连接请求时,NioEventLoop的事件循环监听到该ACCEPT事件,
Netty底层会接收这个连接,通过accept()方法得到与这个客户端的连接(SocketChannel),
然后触发ChannelRead事件(即,ChannelHandler中的channelRead方法会得到回调),
该事件会在ChannelPipeline中的ChannelHandler链中执行、传播。
④ ServerBootstrapAcceptor的channelRead方法会该SocketChannel(客户端的连接)
注册到workerGroup(NioEventLoopGroup) 中的某个NioEventLoop的Selector上,
并注册READ事件为SocketChannel所感兴趣的事件。启动SocketChannel所在NioEventLoop的事件循环,
接下来就可以开始客户端和服务器端的通信了。
Netty如何封装NIO
初始化EventLoopGroup时会创建好各个NioEventLoop即selector
一个EventLoopGroup中有一组NioEventLoop
一个NioEventLoop中对应一个selector
BossEventLoopGroup只负责处理连接请求
WorkEventLoopGroup负责处理有效连接的读写事件(IO处理)
ChannelFuture future = sb.bind(port).sync()
AbstractBootstrap.doBind() ==> AbstractBootstrap.initAndRegister()
channelFactory.newChannel()==> 通过反射构造NioServerSocketChannel对象时设置了非阻塞
NioServerSocketChannel.newSocket() ==>
provider.openServerSocketChannel() 返回的是nio中对应的ServerSocketChannel对象
config().group().register(channel);// 将ServerSocketChannel注册到bossGroup中
AbstractNioChannel.doRegister() NioEventLoop.run() ==> select() ==>
timeoutMillis <= 0 ? selector.selectNow() : selector.select(timeoutMillis) ==> processSelectedKeys()
unsafe.read(); //unsafe有两个实现类NioMessageUnsafe 负责处理连接事件 , NioByteUnsafe负责处理读写事件
NioMessageUnsafe.read() ==> NioServerSocketChannel.doReadMessages() ==>
SocketChannel ch = SocketUtils.accept(javaChannel()); 对应 serverSocketChannel.accept()
NioByteUnsafe.read().doReadBytes(byteBuf).pipeline.fireChannelRead(byteBuf) // 将数据读取的bytebuf并回调pipeline中的channelHandler中的channelRead()方法
Netty启动流程
对于EventLoopGroup bossGroup = new NioEventLoopGroup();
发生了一下操作:
创建线程执行器ThreadPerTaskExecutor
创建NioEventLoop数组
初始化NioEventLoop数组
初始化线程选择器
Netty中的默认NIO线程都是由DefaultThreadFactory创建,并且对线程进行了一层封装及一些属性设置,这些参数是在DefaultThreadFactory的构造方法中被初始化的
也就是说每一个NioEventLoop都与一个Selector绑定
这里Netty没有使用传统的线程创建方式来执行run方法,而是通过一个线程执行器executor来执行,其实是因为executor底层对线程做了一层优化,此处的executor就是上文中介绍到的ThreadPerTaskExecutor,它在每次执行execute方法的时候都会通过DefaultThreadFactory创建一个FastThreadLocalThread线程,而这个线程就是Netty中的Reactor线程实体。
Netty处理请求流程图
Netty线程池
- Handler中自定义业务线程池
2. Context中添加线程池
Netty线程模型总结
(1) 无锁化
netty 的线程模型并不是一成不变的,它实际取决于用户的启动参数配置。通过设置不同的启动参数,Netty 可以同时支持 Reactor 单线程模型、多线程模型。
为了尽可能地提升性能,Netty 在很多地方进行了无锁化的设计,例如在 I/O 线程内部进行串行操作,避免多线程竞争导致的性能下降问题。表面上看,串行化设计似乎 CPU 利用率不高,并发程度不够。但是,通过调整 NIO 线程池的线程参数,可以同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比一个队列多个工作线程的模型性能更优。
(2) 时间可控的简单业务直接在 I/O 线程上处理
时间可控的简单业务直接在 I/O 线程上处理,如果业务非常简单,执行时间非常短,不需要与外部网络交互、访问数据库和磁盘,不需要等待其它资源,则建议直接在业务 ChannelHandler 中执行,不需要再启业务的线程或者线程池。避免线程上下文切换,也不存在线程并发问题。
(3) 复杂和时间不可控业务建议投递到后端业务线程池统一处理
复杂度较高或者时间不可控业务建议投递到后端业务线程池统一处理,对于此类业务,不建议直接在业务 ChannelHandler 中启动线程或者线程池处理,建议将不同的业务统一封装成 Task,统一投递到后端的业务线程池中进行处理。过多的业务ChannelHandler 会带来开发效率和可维护性问题,不要把 Netty 当作业务容器,对于大多数复杂的业务产品,仍然需要集成或者开发自己的业务容器,做好和Netty 的架构分层。可以将复杂的业务封装成Task放入队列,异步解耦处理队列中的任务
(4) 业务线程避免直接操作 ChannelHandler
业务线程避免直接操作 ChannelHandler,对于 ChannelHandler,IO 线程和业务线程都可能会操作,因为业务通常是多线程模型,这样就会存在多线程操作ChannelHandler。为了尽量避免多线程并发问题,建议按照 Netty 自身的做法,通过将操作封装成独立的 Task 由 NioEventLoop 统一执行,而不是业务线程直接操作
如果你确认并发访问的数据或者并发操作是安全的,则无需多此一举,这个需要根据具体的业务场景进行判断,灵活处理。