深入理解Netty

I/O多路复用
在I/O编程过程中,当需要同时处理多个客户端接入请求时,可以利用多线路或I/O多路复用技术进行处理。I/O多路复用技术通过把多个I/O的阻塞复用到同一个select的阻塞后,从而使得系统在单线程的情况下可以同时处理多个客户端请求。I/O多路复用的最大优势是系统开销小,系统不需要创建新的额外进程或线程,也不需要维护进程和线程的运行,降低了系统的维护工作量,节省了系统资源,I/O多路复用的主要场景如下:
1、服务器需要同时处理多个处于监听状态或多个连接状态的套接字
2、服务器需要同时处理多种网络协议的套接字
I/O多路复用的系统调用选择epoll,总结如下:
1、支持一个进程打开的socket描述不受限制(仅受限于操作系统的最大文件句柄数)
epoll支持的FD上限是操作系统的最大文件句柄数,这个数字远远大于1024。例如,在1GB内存的机器上大约是10万个句柄左右,具体可以通过cat/proc/sys/fs/file_max查看
2、I/O效率不会随着FD数目的增加而线性下降
epoll根据每个fd上面的callback函数实现
3、使用mmap加速内核与用户空间的消息传递
epoll是通过内核和用户空间mmap同一块内存来实现的
4、epoll的API更加简单
包括创建一个epoll描述符,添加监听事件、阻塞等待所监听的事件发送、关闭epoll描述符等
传统的BIO编程
网络编程的基本模型是Client/Server模型,即两个进程之间进行相互通信,其中服务端提供位置信息(绑定的IP地址和监听端口),客户端通过连接操作向服务端监听的地址发送连接请求,通过三次握手建立连接,如果连接建立成功,双方就可以通过网络套接字进行通信
BIO通信模型图
采用BIO通信模型的服务端,通常由一个独立的Accept线程负责监听客户端的连接,接受客户端连接之后为每个客户端创建一个新的线程进行链路处理,处理完成之后,通过输出流返回应答给客户端,线程销毁
当客户端并发访问量增加之后,服务端的线程个数和客户端并发数量1:1的正比关系,由于线程是JVM宝贵的系统资源,当线程数膨胀之后,系统的性能将急剧下降,随着并发访问量的继续增大,系统会发送线程堆溢出、创建新线程失败等问题,并最终导致进程宕机或僵死,不能对外提供服务
启动Timeserver,通过JVM打印出线程堆栈,可以发现主程序阻塞在accept操作上
伪异步I/O编程
采用线程池和任务队列可以实现一种叫伪异步的I/O通信框架,模型如图,当有新的客户端接入时,将客户端的Socket封装成一个Task(该任务实现java.lang.Runnable接口)投递到后端的线程池中进行处理,JDK的线程池维护一个消息队列和几个活跃的线程,对消息队列中的任务进行处理。由于线程池可以设置消息队列的大小和最大线程数,因此,它的资源占用是可控的,无论多少个客户并发访问,都不会导致资源的耗尽和当即
NIO编程
NIO弥补可原来同步阻塞I/O的不足,在标准Java代码中提供可高速的、面向块的I/Oz。通过定义包含数据的类,以及通过以块的形式处理这些数据,NIO不使用本机代码就可以利用低级优化,以下是NIO概念和功能介绍
缓冲区Buffer
Buffer是一个对象,包含一些要写入或要读出的数据,在面向流的I/O中,可以将数据直接写入或直接读到Stream对象中
在NIO库中,所有数据都是用缓冲区处理的,在读取数据时,直接读到缓冲区中,在写入数据时,写入到缓冲区中,任何时候访问NIO中的数据,都是通过缓冲区进行操作
缓冲区实质上是一个数组,通常是字节数组,也可以使用其他种类的数组,还提供了对数据的结构化访问以及维护读写位置等信息
每一种Java基本类型(除Boolean类型)都对应有一种缓冲区,具体如下
缓冲区的 类继续关系如图
网络数据通过Channel读取和写入,通道与流的不同之处在于通道是双向的,流只是在一个方向上移动,通道可以用于读、写或二者同时进行
因为Channel是全双工的,所以它可以比流更好地映射底层操作系统的API。特别是在UNIX网络编程模型中,底层操作系统的通道都是全双工的,同时支持读写操作
Channel的类图继承关系如图
自顶向下看,前三层主要是Channel接口,用于定义功能,后面是一些具体的功能类
多路复用Selector
Selector会不断地轮询注册在其上的Channel,如果某个Channel上面发生读或写事件,这个Channel就处于就绪状态,会被Selector轮询出来,然后通过SelectionKey可以获取就绪Channel的集合,进行后续的I/O操作
一个多路复用器Selector可以同时轮询多个Channel,由于JDK使用了epoll,代替传统的select实现,所以并没有最大连接句柄1024/2048的限制,即只需要一个线程负责Selector的轮询,就可以接入成千上万的客户端
以下是对NIO服务端的主要创建过程进行讲解和说明
1、打开ServerSocketChannel,用于监听客户端的连接,是所有客户端连接的父管道
2、绑定监听端口,设置连接为非阻塞模式
3、创建Reactor线程,创建多路复用器并启动线程
4、将ServerSocketChannel注册到Reactor线程的多路复用器Selector上,监听ACCEPT事件
5、多路复用器在线程run方法的无限循环体内轮询准备就绪的key
6、多路复用器监听到有新的客户端接入,处理新的接入请求,完成TCP三次握手,建立物理链路
7、设置客户端链路为非阻塞模式
8、将新接入的客户端连接注册到Reactor线程的多路复用器上,监听读操作,读取客户端发送的网络消息
9、异步读取客户端请求消息到缓冲区
10、对ByteBuffer进行编解码,如果有半包消息指针reset,继续读取后续的报文,将解码成功的消息封装成Task,投递到业务线程池中,进行业务逻辑编程
11、将POJO对象encode成ByteBuffer,调用SocketChannel的异步write接口,将消息异步发出给客户端
注:如果发送区TCP缓冲区满,会导致写半包,需要注册监听写操作位,循环写,直到整包消息写入TCP缓冲区
NIO客户端序列图
NIO编程的优点总结如下:
1、客户端发起的连接操作是异步的,可以通过多路复用器注册OP_CONNECT等待后续结果,不需要像之前的客户端那样被同步阻塞
2、SocketChannel的读写操作都是异步的,如果没有可读写的数据就不会同步等待,直接返回这样I/O通信线程就可以处理其他的链路,不需要同步等待这个链路可用
3、线程模型的优化:由于JDK的Selector在Linux等主流操作系统上通过epoll实现,没有连接句柄数的限制(只受限于操作系统的最大句柄数或对单个进程的句柄限制),意味着一个Selector线程可以同时处理成千上万个客户端连接,而且性能不会随着客户端的增加而线性下降,因此,非常适合做高性能、高负载的网络服务器
AIO编程
NIO 2.0引入了新的异步通道的概念,并提供了异步文件通道和异步套接字通道的实现。异步通道提供以下两种方式获取操作结果
1、通过java.util.concurrent.Future类来表示异步操作的结果
2、在执行异步操作时传入一个java.nio.channel
CompletionHandler接口的实现类作为操作完成的回调
NIO 2.0的异步套接字通道是真正的异步非阻塞I/O,对应于UNIX网络编程的事件驱动I/O(AIO),不需要通过多路复用器对注册的通道进行轮询即可会实现异步读写,从而简化了NIO的编程模型
不同I/O模型对比
Netty优点
TCP粘包/拆包
TCP是没有界限的一串数据,TCP底层并不了解上层业务数据的具体含义,会根据TCP缓冲区的实际情况进行包的拆分,在业务上认为,一个完整的包可能会被TCP拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据报发送,即所谓的TCP粘包和拆包
TCP粘包/拆包问题说明
可以通过图解对TCP拆包和粘包问题进行说明,如图
假设客户端分别发送了两个数据包D1和D2给服务器,由于服务端一次读取到的字节数是不确定的,故可能存在以下4种情况
1、服务端分两次读取到了两个独立的数据包,分别是D1和D2,没有粘包和拆包
2、服务端一次接收到了两个数据包,D1和D2粘合在一起,被称作TCP粘包
3、服务端分两次读取到了两个数据包,第一次读取到了完整的D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,被称作TCP拆包
4、服务端分两次读取到了两个数据包,第一次读取到了D1包的部分内容D1_1.第二次读取到了D1包的剩余内容D1_2和D2包的整包
如果此时服务端TCP接收滑窗非常小,而数据包D1和D2比较大,有可能会发送第5种可能,即服务端分多次才能将D1和D2包接收完全,期间发生多次拆包
TCP粘包/拆包发生的原因
问题产生的原因有三个
1、应用程序write写入的字节大小大于套接字发送缓冲区大小
2、进行MSS大小的TCP分段
3、以太网帧的payload大于MTU进行IP分析
粘包问题的解决策略
由于底层的TCP无法理解上层的业务数据,所以在底层是无法保证数据包不被拆分和重组的,只能通过上层的应用协议栈设计来解决,根据业界的伫列协议的解决方案,归纳如下
1、消息定长,例如每个报文的大小为固定长度200字节,如果不够,空位补空格
2、在包尾增加回车换行符进行分割,例如FTP协议
3、将消息分为消息头和消息体,消息头包含表示消息总长度的字段,通常设计思路为消息头的第一个字段使用int32来表示消息的总长度
4、更复杂的应用层协议
未考虑TCP粘包导致功能异常案例
TimeServer的改造
每读到一条消息后,就计一个数,然后发送消息给客户端。按照设计,服务端接收到的消息总数应该跟客户端发送的消息总数相同,而且请求消息删除回车换行符后应该为"QUERY TIME ORDER"
TimeClient改造
主要的修改点是代码第33~38行,客户端跟服务端链路建立成功之后,循环发送100条消息,每发送一条就刷新一次,保证每条消息都会被写入Channel中,服务端应该接收到100条查询时间指令的请求信息
第48~49行,客户端每接收到服务端一条应答消息之后,就打印一次计数器,客户端应该打印100次服务端的系统时间
运行结果
服务端运行结果表明只接收两条消息,第一条包含了57条 "QUERY TIME ORDER"指令,第二条包含了43条 "QUERY TIME ORDER"指数,总数正好100条 "QUERY TIME ORDER"指令,说明发送了TCP粘包
利用LineBasedFrameDecoder解决TCP粘包问题
支持TCP粘包的TimeServer
       
45~47:在原来的TimeServerHandler之前新增了两个解码器:LineBasedFrameDecoder和StringDecoder
Netty时间服务器服务端TimeServerHandler
19~21行:可以发现接收到的msg是删除回车换行符后的请求消息,不需要额外考虑处理读半包问题,也不需要对请求消息进行编码,代码简洁
支持TCP粘包的TimeClient
31~34:直接在TimeClientHandler之前增加 LineBasedFrameDecoder和StringDecoder解码器
       
44~46拿到的msg已是解码成字符串之后的应答消息
LineBasedFrameDecoder的工作原理是依次遍历ByteBuf中的可读字节,判断是否有"\n"或"\r\n",若有,就此位置为结束位置,从可读索引到结束位置区间的字节就组成了一行,以换行符为结束标志的解码器,支持携带结束符或不携带结束符两种方式,同时支持配置单行的最大长度
StringDecoder的功能简单,将接收到的对象转换成字符串,然后继续调用后面的Handler
分隔符和定长解码器的应用
TCP以流的方式进行数据传输,上层的应用协议为了对消息进行区分,采用如下4种方式
1、消息长度固定,累计读取到长度总和为定长LEN的报文后,认为读取到了一个完整的消息,将计数器置位,重新开始读取下一个数据包
2、将回车换行符作为消息结束符,例如FTP协议,这种方式在文本协议种应用比较广泛
3、将特殊的分隔符作为消息的结束标志,回车换行符是一种特殊的结束分隔符
4、通过在消息头种定义长度字段来标识消息的总长度
DelimiterBasedFrameDecoder服务端开发
37~41:首先创建分隔符缓冲对象ByteBuf。40行:创建 DelimiterBasedFrameDecoder对象,将其加入到ChannelPipeline种
DelimiterBasedFrameDecoder客户端开发
分别将 DelimiterBasedFrameDecoder和StringDecoder添加到客户端ChannelPipeline中,最后添加客户端I/O事件处理类EchoClientHandler
25~26行在TCP链路建立成功之后循环发送请求消息给服务端,第32~33行打印接收列的服务器应答消息同时进行计数
运行 DelimiterBasedFrameDecoder服务端和客户端
服务端成功接收到客户端发送的10条"Hi,Lilinfery.Welcome to Netty."请求信息,客户端成功接收到了服务端返回的10条 "Hi,Lilinfery.Welcome to Netty."应答消息。测试结果表明使用 DelimiterBasedFrameDecoder可以自动对采用分隔符做码流结束标识的消息进行解码
FixedLengthFrameDecoder应用开发
是固定长度解码器,能够按照指定的长度对消息进行自动解码
服务端开发
在服务端的ChannelPipeline中新增 FixedLengthFrameDecoder,长度设置20,再依次增加字符串解码器和EchoServerHandler
利用telnet命令测试EchoServer服务端
测试场景:打开CMD命令执行窗口,通过telnet命令行连接服务端,在控制台输入如下内容:Linfy welcome to Netty at Nanjing
详细测试步骤
1、打开命令窗口
2、在命令行输入"telnet localhost 8080",通过telnet连接服务端
3、通过set localecho命令打开本地回显功能,输入命令行内容
4、EchoServer服务端运行结果如图
私有协议栈开发
私有协议没有标准的定义,只要能够用于跨进程、跨主机数据交换的非标准协议,都可以称为私有协议
Netty协议栈功能设计
Netty协议栈用于内部各模块之间的通信,基于TCP/IP协议栈,是一个类HTTP协议的应用层协议栈,相比传统协议栈,更加轻巧、灵活、实用
网络拓补图
在分布式组网环境下,每个Netty节点之间建立长连接,使用Netty协议进行通信。Netty节点并没有服务端和客户端的区分,谁首先发起连接,谁就作为客户端,另一方自然就成为服务端。一个Netty节点既可以作为客户端连接另外的Nett有节点,也可以作为Netty服务端被其他Netty节点连接
协议栈功能描述
1、基于Netty的NIO通信框架,提供高性能的异步通信能力
2、提供消息的编解码框架,可以实现POJO的序列化和反序列化
3、提供基于IP地址的白名单接入认证机制
4、链路的有效性校验机制
5、链路的断连重连机制
通信模型
1、Netty协议栈客户端发送握手请求消息,携带节点ID等有效身份认证信息
2、Netty协议栈服务端对握手请求消息进行合法性校验,包括节点ID有效性校验、节点重复登录校验和IP地址合法性校验,校验之后,返回登录成功的握手应答消息
3、链路成功之后,客户端发送业务消息
4、链路成功之后,服务端发送心跳消息
5、链路建立成功之后,客户端发送心跳消息
6、链路建立成功之后,服务端发送业务消息
7、服务端退出时,服务端关闭连接,客户端感知对方关闭连接后,被动关闭客户端连接
消息定义
Netty协议栈消息定义包括两部分:消息头、消息体
Netty协议支持的字段模类型
Netty协议的编码
Netty协议的解码
链路的建立
链路建立需要通过基于IP地址或号段的黑白名单安全认证机制,作为样例,本协议使用基于IP地址的安全认证,如果有多个IP,通过逗号进行分割
链路的关闭
由于采用长连接通信,在正常的业务运行期间,双方通过心跳和业务消息维持链路,任何一方都不需要主动关闭连接
在以下情况下,客户端和服务端需要关闭连接
1、当对方宕机或重启时,会主动关闭链路,另一方读取到操作系统的通知信号,得知对方RSET链路,需要关闭连接,释放自身的句柄等资源。由于采用TCP全双工通信,通信双方都需关闭连接,释放资源
2、消息读写过程中,发生了I/O异常,需要关闭主动连接
3、心跳消息读写过程中发生了I/O异常, 需要关闭主动连接
4、心跳超时, 需要关闭主动连接
5、发生编码异常等不可恢复错误时, 需要关闭主动连接
运行协议栈
正常场景
启动服务端,待服务端启动成功之后启动客户端,检查链路是否建立成功,是否每隔5s互发依次心跳请求和应答
客户端运行结果如图
客户端和服务端握手成功,双方可以互发心跳,链路正常
异常场景
假设服务端宕机一段时间重启,检测如下功能是否正常
1、客户端是否能够正常发起重连
2、重连成功之后,不再重连
3、断链期间,心跳定时器停止工作,不再发生心跳请求消息
4、服务端重启成功之后,允许客户端重新登录
5、服务端重启成功之后,客户端能够重连和握手成功
6、重连成功之后,双方的心跳能够正常互发
7、性能指标:重连期间,客户端资源得到了正常回收,不会导致句柄等资源泄漏
服务端重启之前的客户端资源占用
线程占用如图
服务端宕机之后,重启之前,客户端周期性重连失败
重连期间线程资源占用正常
服务端重启成功,握手成功,连理重新恢复
通过netstat命令查看TCP连接状态
异用场景:客户端宕机重启
客户端宕机重启之后,服务端需要能够消除缓存信息,允许客户端重新登录
运行结束表明客户端重启之后可以重新登录成功,说明服务端功能正常
服务端的创建
创建源码解析
时序图
1、创建ServerBoostrap实例。ServerBoostrap是Netty服务端的启动辅助类,提供了一系列的方法用于设置服务端启动相关的参数
2、设置并绑定Reactor线程池,Netty的Reactor线程池是EventLoopGroup,实际是EventLoop数组,职责是处理所有注册到本线程多路复用器Selector上的Channel,Selector的轮询操作由绑定的EventLoop线程run方法驱动,在一个循环体内循环执行
3、设置并绑定服务端Channel,作为NIO服务端,需要创建ServerSocketChannel,Netty对原生的NIO类库进行了封装,对应实现是NioServerSocketChannel
4、链路建立时创建并初始化ChannelPipeline,其本质是一个负责处理网路时间的职责链,负责管理和执行ChannelHandler。网络事件以事件流的形式在ChannelPipeline中流转,由ChannelPipeline根据ChannelHandler的执行策略调度ChannelHandler的执行,典型的网络事件如下
5、初始化ChannelPipeline完成之后,添加并设置ChannelHandler。ChannelHandler是Netty提供给用户定制和扩展的关键接口,比较实用的系统ChannelHandler总结如下
6、绑定并启动监听端口。在绑定监听端口之前系统会做一系列的初始化和检测工作,完成之后,会启动监听端口,并将ServerSocketChannel注册到Selector上监听客户端连接,相关代码如下
7、Selector轮询由Reactor线程NioEventLoop负责调度和执行Selector轮询操作,选择准备就绪的Channel集合,代码如下
8、当轮询到准备就绪的Channel之后,由Reactor线程NioEventLoop执行ChannelPipeline的相应方法,最终调度并执行ChannelHandler
9、执行Netty系统ChannelHandler和用户添加定制的ChannelHandler。ChannelPipeline根据网络事件的模型,调度并执行ChannelHandler,代码如下
Netty客户端创建流程
Netty客户端创建时序图
创建流程分析
1、用户创建Bootstrap实例,通过API设置创建客户端相关的参数,异步发起客户端连接
2、创建处理客户端连接,I/O读写的Reactor线程组NioEventLoopGroup。可以通过构造函数指定I/O线程的个数,默认为CPU内核数的2倍
3、通过Bootstrap的ChannelFactory和用户指定的Channel类型创建用于客户端连接的NioSocketCahnnel,功能类似于JDK NIO类库提供的SocketChannel
4、创建默认的ChannelHandlerPipeline,用于调度和执行网络事件
5、异步发起TCP连接,判断连接是否成功,若成功,直接将NIoSocketChannel注册到多路复用器上,监听读操作位,用于数据报读取的消息发生;若没有立即连接,则注册连接监听位到多路复用器,等待连接结果
6、注册对应的网络监听状态位到多路复用器
7、由多路复用器在I/O现场中轮询各Channel,处理连接结果
8、若连接成功,设置Future结果,发送连接成功事件,触发ChannelPipeline执行
9、由ChannelPipeline调度执行系统和用户ChannelHandler,执行业务逻辑
Netty的线程模型
Reactor单线程模型
所有的I/O操作都在同一个NIO线程上面完成,职责如下
在一些小容量应用场景下,可以使用单线程模型,但对于高负载、大并发的应用场景却不合适,主要原因如下:
1、一个NIO线程同时处理成百上千的链路,性能上无法支持,即便NIO线程的CPU符合达到100%,也无法满足海量的编码,、解码、读取和发送
2、当NIO线程负载过重之后,处理速度将变慢,会导致大量客户端连接超时,超时之后往往会进行重发,更加重了NIO线程的负载,最终会导致大量消息积压和处理超时,成为系统的性能瓶颈
3、可靠性问题:一旦NIO线程以外跑飞,或进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部消息,造成节点故障
Reactor多线程模式
多线程和单线程最大的区别是有一组NIO线程来处理I/O操作
多线程模型特点如下
1、有专门一个线程——Acceptor线程用于监听服务端,接收客户端的TCP连接请求
2、网络I/O操作——读、写等由一个NIO线程负责,线程池可以采用标准的JDK线程池实现,包含一个任务队列和N个可用的线程,由NIO线程负责消息的读取、解码、编码和发送
3、一个NIO线程可以同时处理N条链路,但一个链路只对应一个NIO线程,防止发生并发操作问题
主从Reactor多线程模型
特点:服务端用于接收客户端连接的不再是一个单独的NIO线程,而是一个独立的NIO线程池,Acceptor接收到客户端TCP连接请求并处理完成后将新创建的SocketChannel注册到I/O线程池的某个I/O线程上,负责SocketChannel的读写和编码工作。Acceptor线程池仅仅用于客户端的登录,握手和安全认证,一旦链路建立成功,将链路注册到后端subReactor线程池的I/O线程上,由I/O线程负责后续的I/O操作
利用主从NIO线程模型,可以解决一个服务端监听线程无法有效处理所有客户端连接的性能不足问题,因此,在Netty的官方Demo中,推荐使用该线程模型
Netty的线程模型
Netty的线程模型不是一成不变的,实际取决于用户的启动参数配置。通过设置不同的启动参数,Netty可以同时支持Reactor单线程、多线程模型和主从Reactor多线程模式
可以通过下图的Netty服务端启动代码来了解线程模型
服务端启动时,创建两个NioEventLoopGroup,实际是两个独立的Reactor线程池。一个用于接收客户端的TCP连接,另一个用于处理I/O相关的读写操作或执行系统Task、定时任务Task等
Netty用于接收客户端请求的线程池职责如下
1、接收客户端TCP连接,初始化Channel参数
2、将链路状态变更事件通知给ChannelPipeline
Netty处理I/O操作的Reactor线程池职责如下:
1、异步读取通信对端的数据报,发送读事件到ChannelPipeline
2、异步发送消息到通信对端,调用ChannelPipeline的消息发送接口
3、执行系统调到Task
4、执行定时任务Task,例如链路空闲状态检测定时任务
Netty的多线程编程最佳实践如下
1、创建两个NioEventLoopGroup,用于逻辑隔离NIO Acceptor和NIO I/O线程
2、尽量不要在ChannelHandler中启动用户线程
3、解码要放在NIO线程调用的解码Handler中进行,不要切换用户线程中完成消息的解码
4、如果业务逻辑操作非常简单,没有复杂的业务逻辑,没有可能会导致线程被阻塞的磁盘操作、数据库操作、网络操作等,可以直接在NIO线程上完成业务逻辑编排,不需要切换到用户线程
5、如果业务逻辑处理复杂,不要在NIO线程中完成,建议将解码后的POJO消息封装成Task,派发到业务线程池中由业务线程执行,以保证NIO线程尽快被释放,处理其他的I/O操作
推荐的线程计算公式有以下两种
1、线程数量 =(线程总时间/瓶颈资源事件)* 瓶颈资源的线程并行数
2、QPS = 1000/线程总时间 * 线程数
Netty逻辑架构
Netty采用了典型的三层网格架构进行设计和开发
Reactor通信调度层
由一系列辅助类完成,包括Reactor线程NioEventLoop及其父类,NioSocketChannel/NioServerSocketChannel及其父类,ByteBuffer以及由其衍生出来的各种Buffer、Unsafe以及其衍生的各种内部类等。该层的主要职责是监听网络的读写和连接操作,负责将网路层的数据读取到内存缓冲区中,然后触发各种网络事件,例如连接创建,连接激活、读事件、写事件等,将这些事件触发到Pipeline中,由Pipeline管理的职责链来进行后续的处理
职责链ChannelPipeline
负责事件在职责链中的有序传播,同时负责动态地编排职责链,职责链可以选择监听和处理自己关心的事件,可以拦截处理和向前/向后传播事件。不同应用的Handler节点的功能也不同,通常情况下,往往会开发编解码Handler用于消息的编解码,可以将外部的协议消息转换成内部的POJO对象,这样上层业务则只需要关心处理业务逻辑即可
业务逻辑编排层
通常有两类:1、纯碎的业务逻辑编排    2、其他的应用层协议插件
关键架构质量属性
高性能
Netty的架构设计如何实现高性能
1、采用异步非阻塞的I/O类库,基于Reactor模式实现,解决了传统的同步阻塞 I/O模式下一个服务端无法平滑地处理线性增长的客户端的问题
2、TCP接收和发送缓冲区使用直接内存代替堆内存,避免了内存复制,提升了 I/O读取和写入的性能
3、支持通过内存池的方式循环利用ByteBuf,避免了频繁创建和销毁ByteBuf带来的性能消耗问题
4、可配置的 I/O线程数,TCP参数等,为不同的用户场景提供定制化的调优参数,满足不同的性能场景
5、采用环形数据缓冲区实现无锁化并发编程,代替传统的线程安全容器或锁
6、合理地使用线程安全容器、原子类等,提升系统的并发处理能力
7、关键资源的处理使用单线程串行化的方式,避免多线程并发访问带来的锁竞争和额外的CPU资源消耗问题
8、通过引用计数器及时申请释放不再被引用的对象,细粒度的内存管理降低了GC的频率,减少了频繁GC带来的延时增大和CPU损耗
Netty在各个NIO框架中综合性能是最高的
可靠性
Netty提供如下两种链路空闲检测机制
1、读空闲超时机制:当连续周期T没有消息可读时,触发超时Handler,用户可以基于读空闲超时发送心跳消息,进行链路检测;如果连续N个周期仍没有读取到心跳消息,可以主动关闭链路
2、写空闲超时机制:当连续周期T没有消息要发送时,触发超时Handler,用户可以基于写空闲超时发送心跳消息,进行链路检测;如果连续N个周期仍没有接收到对方的心跳消息,可以主动关闭链路
内存保护机制
Netty提供多种机制对内存进行保护,包括以下几个方面
1、通过对象引用计数器对Netty的ByteBuf等内置对象进行细粒度的内存申请和释放,对非法的对象引用进行检测和保护
2、通过内存池来重用ByteBuf,节省内存
3、可设置内存容量上限,包括ByteBuf、线程池线程数等
AbstractReferenceCountedByteBuf的内存管理方法如图
优雅停机
指的是当系统退出时,JVM通过注册的Shutdown Hook拦截到退出信号量,然后执行退出操作,释放相关模块的资源占用,将缓冲区的消息处理完成或清空,将待刷新的数据持久化列磁盘或数据库中,等到资源回收和缓冲区消息处理完成之后,再退出
优雅停机往往需要设置个最大超时时间T,如果达到T后系统仍没有退出,则通过kill-9 pid强杀当前的进程,相关接口如下表
可定制性
1、负责链模式:ChannelPipeline基于责任链模式开发,便于业务逻辑的拦截、定制和扩展
2、基于接口的开发:关键的类库都是提供了接口或抽象类,如果Netty自身的实现无法满足用户的需求,可以由用户自定义实现相关接口
3、提供了大量的工厂类,通过重载工厂类可以按需创建出用户实现的对象
4、提供了大量的系统参数提供用户按需设置,增强系统的场景定制性
可扩展性
基于Netty的基础NIO框架,可以方便地进行应用层协议定制,例如HTTP协议栈、Thrift协议栈、FTP协议栈等,这些扩展不需修改Netty的源码,直接基于Netty的二进制库即可实现协议的定制和扩展
Java多线程编程在Netty中的应用
Java内存模型
JVM规范定义了Java内存模型来屏蔽掉各种操作系统、虚拟机实现厂商和硬件的内存访问差异,以确保了Java程序在所有操作系统和平台上能够实现一次编写到处运行的效果
Java内存模型的定制既要严谨,保证语意无歧义,也要尽量制定的宽松一些,允许各硬件和JVM实现厂商有足够的灵活性来充分利用硬件的特性提升Java的内存访问性能
工作内存和主内存
Java内存模型规定所有的变量都存储在主内存中,每个线程有自己独立的工作内存,保存了被该线程使用的变量的主内存复制。线程对这些变量的操作都在自己的工作内存中进行,不能直接操作主内存和其他内存中存储的变量或变量副本。线程间的变量访问需要通过主内存来完成,如图
Java内存交互协议
Java内存模型定义了8种操作来完成主内存工作和工作内存的变量访问,具体如下:
Java的线程
主流的操作系统提供了线程实现,目前实现线程的方式主要有三种
1、内核线程实现,这种线程由内存来完成线程切换,内核通过线程调度器对线程进行调度,并负责将线程任务映射到不同的处理器上
2、用户线程实现,通常情况下,用户线程指的是完全建立在用户空间线程库上的线程,用户线程的创建、启动、运行、销毁和切换完全在用户态种完成,不需要内核的帮助,因此执行性能更高
3、混合实现,将内核线程和用户线程混合在一起使用的方式
Netty的并发编程实践
关键字synchronized可以保证在同一时刻,只有一个线程可以执行某一个方法或代码块。同步的作用不仅仅是互斥,另一个作用是共享可变性,当某个线程修改了可变数据并释放锁后,其他线程可以获取被修改变量的最新值
以ServerBootstrap为例进行分析,首先看option方法,如图
作用是设置ServerBootstrap的ServerSocketChannel的Socket属性,属性集定义如下:
由于是非线程安全的LinkedHashMap,所以当多线程创建、访问和修改LinkedHashMap时,必须在外部进行必要的同步,LinkedHashMap的API DOC对于线程安全的说明如图
正确使用锁
首先通过循环检测的方式对状态变量status进行判断,当状态大于等于0时,执行wait(),阻塞当前的调度过程,直到status小于0,唤醒所有被阻塞的线程,继续执行
volatile的正确使用
关键字 volatile是Java提供的最轻量级的同步机制,Java内存模型对 volatile专门定义了一些特殊的访问规则
当一个变量被 volatile后,将具备以下两种特性:
1、线程可见性,当一个线程修改了被 volatile修饰的变量后,无论是否加锁,其他线程都可以立即看到最新的修改,而普通的变量做不到
2、禁止指令重排序优化,普通的变量仅仅保证在该方法的执行过程种所有依赖赋值结果的地方都能获取正确的结果,不能保证变量赋值操作的顺序与程序代码的执行顺序一致
会一直运行,原因是Java对代码进行了指令重排序和优化,解决方法是将stop前增加 volatile修饰符即可
使用 volatile解决了以下两个问题
1、main线程对stop的修改在 volatile线程中可见
2、禁止重排序,防止因为重排序导致的并发访问逻辑混乱
CAS指令和原子类
CAS操作由sun.misc.Unsafe类里的compareAndSwapInt()和compareAndSwapLong()等方法包装提供,通常情况下 sun.misc.Unsafe类对于开发者不可见
结合Netty源码,对原子类的正确使用进行详细说明
打开ChannelOutboundBuffer的代码
首先定义了一个volatile变量,可以保证某个线程对于totalPendingSize的修改可以被其他线程立即访问,但无法保证多线程并发修改的安全性。又定义了一个AtomicIntegerFieldUpdater类型的变量WTOTAL_PENDING_SIZE_UPDATER,实现totalPendingSize的原子更新,保证 totalPendingSize的多线程修改并发安全性。当执行write操作的既可以是I/O线程,也可以是业务的线程,还可能由业务线程池多个工作线程同时执行发送任务,因此,统计操作是多线程并发的
线程安全类的应用
新的并发编程包中的工具可以分为以下4类:
1、线程池Executor Framework以及定时任务相关的类库,包括Timer等
2、并发集合,包括List、Queue、Map、Set等
3、新的同步器,例如AtomicInteger等
例:线程池在Netty中的应用,打开SingleThreadExecutor看它是如何定义和使用线程池的
首先定义了一个标准的线程池用于执行任务,代码如下
接着对它赋值并进行初始化操作
执行任务代码
实际上执行任务是先把任务加入到任务队列中,然后判断线程是否已经启动循环执行,如果不是则启动线程
读写锁的应用
打开HashedWhellTime代码
当新增一个定时任务使用了读锁,用于感知wheel的变化。由于读锁是共享锁,所以当有多个线程同时遇到newTimeout时,并不会互斥,提升了并发读的性能
获取并删除所有过期的任务时,由于要从迭代器中删除任务,使用写锁
高性能之道
I/O通信性能三原则
1、传输:用什么样的通道将数据发送给对方。可以选择BIO、NIO或AIO,I/O模型在很大程度上决定了通信的性能
2、协议:采用什么样的通信协议,HTTP等公有协议或内部私有协议。协议的选择不同,性能也不同。相比于公有协议,内部私有协议的性能通常可以被设计得更优
3、线程:数据报如何读取,读取之后的编解码在哪个线程进行,编解码后的消息如何派发,Reactor线程模型不同,对性能影响也非常大
无锁化的串行设计
可以通过串行化设计,即消息的处理尽可能在同一个线程内完成,期间不进行线程切换,避免了多线程竞争和同步数,工作原理如图
Netty的NioEventLoop读取到消息之后,直接调用ChannelPipeline的fireChannelRead(Object msg),只要用户不主动切换线程,一直会由NioEventLoop调用到用户的Handler,期间不进行线程切换
高性能的序列化框架
影响序列化性能的关键因素总结如下
1、序列化后的码流大小(网络带宽的占用)
2、序列化&反序列化的性能(CPU资源占用)
3、是否支持跨语言(异构系统的对接和开发语言切换)
Netty默认提供Google Protobuf的支持,通过扩展Netty的编解码接口,用户可以实现其他的高性能序列化框架,例如Thrift的压缩二进制编解码框架
不同序列化&反序列化框架性能对比
零拷贝
Netty接收和发送ByteBuffer采用DIRECT BUFFERS,使用堆外直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝,相较于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝,代码如下
零拷贝的实现CompositeByteBuf,对于将多个ByteBuf封装成一个ByteBuf,对外提供统一封装后的ByteBuf接口,类定义如下
通过继承关系可以看出,CompositeByteBuf实际是个 ByteBuf的装饰器,将多个 ByteBuf组合成一个集合,然后对外提供统一的 ByteBuf接口,相关定义如下
添加 ByteBuf,不需做内存拷贝,相关代码如下
Netty文件传输类DefaultFileRegion通过transferTo方法将文件发送到目标Channel中
很多操作系统直接将文件缓冲区的内容发送到目标Channel中,不需要通过循环拷贝的方式,提升了传输性能,降低了CPU和内存占用,实现了文件传输的零拷贝
内存池
位尽量使用重用缓冲区,Netty提供了基于内存池的缓冲区重用机制
Netty提供了多种内存管理策略,通过在启动辅助类中配置相关参数,可以实现差异化的定制
测试场景一:使用内存池分配器创建直接内存缓冲区,代码如下
测试场景二:使用非堆内存分配器创建的直接内存缓冲区
各执行300万次,性能对比结果如下
性能测试表明,采用内存池的 ByteBuf相较于朝生夕灭的 ByteBuf性能高23倍左右
灵活的TCP参数配置能力
SO_RCVBUF和SO_SNDBUF:通常建议值为128KB或256KB
SO_TCPODELAY:NAGLE算法通过将缓冲区内的小封包自动相连,组成较大的封包,阻止大量小封包的发送阻塞网络,从而提高网络应用效率
软中断:开启RPS后开始实现软中断,提升网络吞吐量,RPS根据数据包的源地址,目的地址以及目的和源端口,计算出一个hash值,然后根据这个hash值来选择软中断运行的CPU
可靠性
Netty可靠性需求
Netty的主要应用场景如下
1、RPC框架的基础网络通信框架:主要用于分布式节点之间的通信和数据交换,在各个业务领域均有典型的应用,例如阿里的分布式服务框架Dubbo、消息队列RocketMQ、大数据处理Hadoop的基础通信和序列化框架Avro
2、私有协议的基础通信框架:例如Thrift协议、Dubbo协议等
3、公有协议的基础通信框架:例如HTTP协议、SMPP协议等
Netty高可靠性设计
客户端连接超时
首先,在创建NIO客户端时,可以配置连接超时的参数
设置完连接超时之后,Netty在发起连接时,会根据超时时间创建ScheduledFuture挂载在Reactor线程上,用于定时监测是否发送连接超时
如果在超时期间内处理完成连接操作,则取消连接超时定时任务,代码如下
通信对端强制关闭连接
在NIO编程过程中,经常会发现由于句柄没有被及时关闭导致的功能和可靠性问题,原因如下:
1、IO的读写等操作并非仅仅集中在Reactor线程内部,用户上层的一些定制行为可能会导致IO操作的外逸,例如业务自定义心跳机制
2、由于外部环境诱因导致程序进入分支,引起故障
首先启动Netty服务端和客户端,TCP链路建立成功之后,双方维持该链路,查看链路状态,结果如图
强制关闭客户端,模拟客户端宕机,服务端控制台打印如图的异常
从堆栈信息可以判断,服务端已经监控到客户端强制关闭了连接,下面看服务端是否已经释放了连接句柄,再次执行netstat命令,执行结果如图
从执行结果可以看出,服务端已经关闭了和客户端的TCP连接,句柄资源正常释放。由此可以得出结论,Netty底层已经自动对该故障进行了处理
链路关闭
测试结果:改造下Netty客户端,双发链路建立成功之后,等待120S,客户端正常关闭链路,看服务端是否能够感知并释放句柄资源
首先启动Netty客户端和服务端,双方TCP链路连接正常
120S之后,客户端关闭连接,进程退出,为能够看到整个处理过程,在服务端的Reactor线程处设置断点,先不做处理,此时链路状态如图
此时服务端并没有关闭Socket连接,链路处于CLOSE_WAIT状态,放开代码让服务器执行完
当连接被对方合法关闭后,被关闭的SocketChannel会处于就绪状态,SocketChannel的read操作返回值为-1,说明已经被关闭,代码片段如下
如果SocketChannel被设置为非阻塞,则read操作可能返回三个值:
1、大于0:表示读取到了字节数
2、等于0:没有读取到消息,可能处于keep-Alive状态,接收到的是TCP握手消息
3、-1:连接已经被对方关闭
Netty通过判断Channel read操作的返回值进行不同的逻辑处理,如果返回-1,说明链路已经关闭,则调用closeOnRead方法关闭句柄,释放资源,代码如下
链路的有效性检测
要解决链路的可靠性问题,必须周期性的对链路进行有效性检测,目前通用做法是心跳检测。心跳检测目的是确认当前链路可用,对方或者并且能够正常接收和发送消息
心跳检测原理图如图
Ping-Pong型心跳:由通信一方定时发送Ping消息,对方接收到Ping消息之后,立即返回Pong应答消息给对方,属于请求响应型心跳
Ping-Ping型心跳:不区分心跳请求和应答,由通信双方按照约定定时向对方发送心跳Ping消息,属于双向心跳
心跳检测策略如下
1、连续N次心跳检测都没有收到对方的Pong应答消息或Ping请求消息,则认为链路已经发送逻辑失效,称作心跳超时
2、读取和发送心跳消息时直接发生了异常,说明链路已经失效,成为心跳失败
无论发生心跳超时还是心跳失败,都需要关闭链路,由客户端发起重连操作,保证链路能够恢复正常
Netty的心跳检测实际上利用了链路空闲检测机制实现
Netty的默认读写空闲机制是发生超时异常,关闭连接,可以定制超时实现机制,以便支持不同的用户场景
Reactor线程的保护
在一些特殊场景下会发生非IO异常,如果仅仅捕获IO异常可能会导致Reactor线程跑飞。为了防止这种意外,在循环体内一定要捕获Throwable
Netty的NioEventLoop代码如下:
捕获Throwable之后,即便发生意外未知对异常,线程也不会跑飞,休眠1s,防止死循环导致的异常连接,然后继续恢复执行,核心理念:1、某个消息的异常不应该导致整条链路不可用
2、某条链路不可用不应该导致其他链路不可用
3、某个进程不可用不应该导致其他集群节点不可用
内存保护
主要集中保护以下几点
1、链路总数的控制:每条链路都包含接收和发生缓冲区,链路个数太多容易导致内存溢出
2、单个缓冲区的上限控制:防止非法长度或消息过大导致内存溢出
3、缓冲区内存释放:防止非法长度或消息过大导致内存溢出
4、NIO消息发送队列的长度上限控制
为了防止因为用户遗漏导致内存泄漏,Netty在Pipeline的尾Handler中自动对内存进行释放,TailHandler的内存回收代码如下
对于内存池,实际是将缓冲区重新放到内存池中循环使用,PooledByteBuf的内存回收代码如下
缓冲区溢出保护
首先,在内存分配时指定缓冲区长度上限
其次,在对缓冲区进行写入操作时,如果缓冲区容量不足需要扩展,首先对最大容量进行判断,如果扩展后的容量超过上限,则拒绝扩展
在消息解码时,对消息长度进行判断,如果超过最大容量上限,则抛出解码异常,拒绝分配内存,以LengthFieldBasedFrameDecoder的decode方法为例进行说明 
流量整形
一种主动调整流量输出速率的措施,一个典型的应用是基于下游网络节点的TP指标来控制本地流量的输出
两个作用:
1、防止由于上下游网元性能不均衡导致下流网元被压垮,业务流程中断
2、防止由于通信模块消息过快,后端业务线程处理不及时导致的"撑死"问题
全局流量整形
作用范围是进程即的,作用域针对所有的Channel
用户可以通过参数设置:报文的接收速率、报文的发送速率、整形周期
GlobalTrafficShapingHandler的接口定义如下
原理:对每次读取到的 ByteBuf可写字节数进行计算,获取当前的报文流量,然后与流量整形阈值对比,如果已经到达或超过了阈值,则计算等待时间delay,将当前的 ByteBuf放到定时任务Task中缓存,继续处理该 ByteBuf,代码如下
如果达到整形阈值,则对新接收的 ByteBuf进行缓存,放入线程池的消息队列中,代码如下
定时任务的延时时间根据检测周期T和流量整形阈值计算得来,代码如下
流量整形的阈值/imit越大,流量整形的精度越高,流量整形功能是可靠性的一种保障,无法做到100%精确
优雅停机接口
通过注册JDK的ShutDownHook实现,当系统接收到退出指令后,首先标记系统处于退出状态,不再接收新的消息,然后将积压的消息处理完,最后调用资源回收接口将资源销毁,最后各线程退出执行
退出优雅退出由时间限制,例如30s,如果到达执行时间仍未完成退出前的操作,则由监控脚本直接kill-9 pid,强制退出
Reactor线程和线程组提供了优雅退出接口,EventExcutorGroup的接口定义下图
ChannelPipeline关闭接口如图
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值