Reactor 线程模型的演进和局部无锁化
Netty 的线程模型是经典的Reactor
线程模型。
底层的线程模型,才是最大程度上决定系统的性能、吞吐量,决定了整个系统的瓶颈。
前面介绍的从 BIO 到 NIO 中间的一个过度阶段:伪 NIO 中,就没有对底层线程模型进行修改,所以他并不是真正意义上的 NIO。
本篇介绍一下 Netty 的 Reactor 线程模型的演进,从单线程模型再到多线程模型,再到主从线程模型,各个阶段面临的问题而做出的改变。
Reactor 线程模型演进
好的架构都不是一蹴而就的
“好的架构是进化来的,不是设计来的”----《淘宝技术这十年》
在《程序员修炼之道》书中也有提到过,设计一款够好即可的软件。够好即可这四个字用得非常好。
够好即可不是代表程序没有追求,得过且过。
够好即可指的是:你并不需要把所有的事情都做好了,所有的考虑到的,考虑不到的问题都解决了,最后再推出版本。
只要做到你 “内心能平静” ,用户能满意,就是够好即可。与之相反的就是过度设计。
过度设计是非常令人讨厌的东西,就要是把控够好即可和过度设计之间的平衡点。
对于中间件而言,更多的是:能解决大多数业务场景问题,那么我的这款中间件就是一款优秀的中间件。
业务是推动技术发展的主要驱动力。
Reactor 线程模型有 3 个阶段:
Reactor 单线程
Reactor 多线程
Reactor 主从模型
为了更好地凸显出 Reactor 线程模型的优势,这里就要跟之前传统的 BIO 线程模型进行对比啦
这里稍微做一点前情回顾
传统 BIO 线程模型
Acceptor
线程负责监听客户端的连接。当接收到来自客户端的请求后,会为他分配一个线程负责整个链路处理(客户端请求和线程是**
1:1
**的关系)
因为这里线程是吃内存的所以会出现,当内存不够用的时候,无法处理来自客户端的请求,还会有 OOM 的风险。
方便阅读,这里我们需要引入一点前提知识:
Acceptor 线程
主要是负责处理客户端的连接。
接受客户端 TCP 连接(握手、安全认证),初始化
Channel
参数将链路状态变更事件通知给
ChannelPipeline
Reactor 线程 / IO 线程
Reactor 线程,干活的线程,主要是因为他是负责 IO 事件的处理,所以也叫 IO 线程。
异步读取通信对端的数据包,发送读事件到
ChannelPipeline
异步发送消息到通信对端,调用 ChannelPipeline 的消息发送接口
系统 Task
定时任务 Task
Reactor 单线程模型
所有的 IO 操作都在同一个 NIO 线程上完成
由于Reactor
模式使用的异步非阻塞 IO,所以即便是单线程处理所有操作,也不会出现阻塞的情况。
通过
Acceptor
类接受客户端的 TCP 连接请求消息通过三次握手成功建立链路后,
Dispatch
将对应的 ByteBuffer 转发到对应的 Handler 上,再进行消息编解码。
Reactor 单线程的缺点:
性能问题:只有一个 NIO 线程处理所有的连接,这是他的性能上的瓶颈。
消息堆积:当 NIO 线程负载过重时,处理速度变慢,会导致大量的客户端请求超时,超时要么丢弃,要么重试。重试也会消耗性能,这会更加导致消息请求堆积现象。
单点故障问题:只有一个 NIO 线程,万一某个任务死循环,那么其他的任务将会出现饿死现象。
为了解决这些问题,演进出了
Reactor
多线程模型
Reactor 多线程模型
最大的区别就是:有一组 NIO 线程来处理 IO 操作
之前单线程模型下,建立连接这种脏活累活都是归 NIO 线程干,现在专门用一个Acceptor
线程来处理这些,NIO 线程就专门去处理 IO 操作。
并且还给他找了些小弟,是以 NIO 线程池来处理 IO 操作。
单线程下:指责不分明、人手不够
现在就是:专业事情给专业人干,还多加人手
一个 NIO 线程可以同时处理 N 条链路,但是一个链路只对应一个 NIO 线程,为的是防止发生并发操作问题
大多数场景下,Reactor 多线程模型已经是满足很多的性能需求,但是在极个别苛刻的场景下,一个 NIO 线程负责建立 TCP 连接还是会存在性能压力,比如:
并发百万客户端连接
服务端需要对客户端握手进行安全认证(
SSL
),很耗性能
现在Acceptor
线程成了整个模型的短板了。
为了解决这个问题,又演进了第三种模型——主从 Reactor 多线程模型。
主从 Reactor 多线程模型
增强
Acceptor
线程的性能,给他配了个 Acceptor 线程池耗时的处理:安全验证那些丢给 Acceptor 线程池来做,Acceptor 线程还是处理建立连接。
服务端用于接受客户端连接不再是一个独立的 NIO 线程,而是一个独立的 NIO 线程池,之前 Acceptor 为这个家杠下了所有。
Acceptor 接受到客户端 TCP 连接请求并处理完了之后,这里是包含安全认证那些,将新创建的Socketchannel
注册到 IO 线程池(sub reactor 线程池)的某个 IO 线程上,由它负责 SocketChannel 的读写和编解码工作。
Acceptor 线程池只是用来建立连接,三次握手,安全认证,这些都做完了之后,就将链路注册到 subreactor 线程池上,由 IO 线程负责后续操作
实际生产环境中,可以通过配置启动参数,来支持同时支持 Reactor 单线程、多线程、主从模型多层模型。
局部无锁化设计
Netty 采用了多个任务并行化来解决锁资源竞争的问题
解决并发竞争锁问题的手段主要有:
乐观锁(
cas
)串行化
Netty 在很多地方进行无锁化的设计。在 IO 线程内部进行串行操作,避免多线程竞争导致的性能下降问题。
第一印象,串行化(同步)很浪费 CPU 时间片,CPU 的利用率不高。
但是通过调整 NIO 线程池的线程参数,可以同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程相比一个队列性能更优。
Netty 的NioEventLoop
读到消息之后,会去调用ChannelPipeline
的#fireChannelRead
,去把广播这个事件。
只要用户不主动切换线程(包括创建线程),那么就一直是由 NioEventLoop 来调用用户的 Handler。串行化处理方法避免了多线程操作导致的锁的竞争。
AbstractUnsafe
我们的老朋友:AbstractUnsafe,里面就有无锁化的实现
AbstractUnsafe#register 方法
我们可以看到,处理注册事件的时候,会先判断是否是 IO 线程:
如果是就不需要切换,直接串行化处理。(大部分情况)
如果不是,则需要丢到用户线程去执行。
SingleThreadEventExecutor#execute
具体在用户线程里面的判断,也是非常清晰的逻辑。
先把任务添加到消息队列里#addTask
,然后启动线程去执行任务#startThread
。
我们看看这个 startThread
这里使用的是CAS
对状态进行修改,并没有用锁去直接修改,所以说 Netty 追求性能,在很多地方都精心设计了。
总结
Reactor
线程模型的演进,从单线程 NIO 线程处理,再到 NIO 线程池处理,单线程 Acceptor 处理连接请求。再到 Acceptor 线程池处理连接后的验证信息。
从单线程 Reactor 模型到多线程 Reactor 模型再到主从 Reactor 模型。可以看到架构在一步一步地进化而来。
此外 Netty 的局部无锁化设计,采用串行化执行任务、CAS 的方式来避免资源的竞争,提高 Netty 的性能。
作者:Ashleejy
链接:https://juejin.cn/post/7218092077170114620
来源:稀土掘金
评论