为什么使用Netty,不直接用nio
做得更多
1.支持常用的应用层协议,如http、websocket
2.解决了黏包半包问题、编解码
3.支持流量控制等定制化功能(流量控制、黑名单等)
4.具有完善的异常处理功能(网络闪断、拥塞等)
做得更好
1.更优秀更强大的工具和api
ByteBuf - ByteBuffer (单个postion到双执行,不用每次flip,各种零拷贝)
FastThreadLocal - ThreadLocal(使用index直接分配索引,避免hash冲突多次比较影响性能)
HashedTimeWheel - Timer(降低时间精度,提升大量定时任务时的性能)
2.隔离了变化,抽象更加通用的接口
如果从nio切换到java1.7的aio,基于jdk要编写大量代码,而netty屏蔽了这些细节
3.nio还有很多小问题,在github上可以看到,自己编写容易遇到坑
比如在 JDK NIO中存在Epoll 漏洞,在linux2.4上,多路选择器selector会被异常唤醒,而实际上没有事件发生,这会导致程序空转,cpu利用率到达100%。当这个问题暴露出来jdk开发者认为这是linux2.4的问题所以选择置之不理,即时后面linux平台后续版本尝试解决但也只是降低了问题发生的概率。
针对这个问题,Netty开发者则是积极的提供了解决方案:
- 每次执行 select 操作之前记录当前时间 currentTimeNanos
- time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos,如果事件轮询的持续时间大于等于 timeoutMillis,那么说明是正常的,否则表明阻塞时间并未达到预期,可能触发了空轮询的 Bug
- Netty 引入了计数变量 selectCnt。在正常情况下,selectCnt 会重置,否则会对 selectCnt 自增计数。当 selectCnt 达到 SELECTOR_AUTO_REBUILD_THRESHOLD(默认512) 阈值时,会触发重建 Selector 对象
Netty 采用这种方法巧妙地规避了 JDK Bug。异常的 Selector 中所有的 SelectionKey 会重新注册到新建的 Selector 上,重建完成之后异常的 Selector 就可以废弃了。
4.维护性,netty2004年发布一直维护到现在,使用的项目也多,得到了很多项目的验证(cassandra、dubbo、hadoop、lettuce、webflux)
Netty流程
EventLoopGroup、EventLoop原理
EventLoopGroup是Netty的核心处理引擎,主要负责接收I/O请求,并分配线程执行处理请求,本质是一个线程池,每个线程负责处理多个Channel,而同一个Channel只会对应一个线程。
EventLoop 可以理解为 Reactor 线程模型的事件处理引擎,每个 EventLoop 线程都维护一个 Selector 选择器和任务队列 taskQueue。它主要负责处理 I/O 事件、普通任务和定时任务。
连接管理:每个channel绑定一个EventLoop,并创建自己的channelPipeline(包含多个channelHandler和对应的channelHandlerContext)
事件处理:EventLoop循环select自己管理的多个channel,然后处理IO事件read/write
任务处理:R/W完后将channel放入任务队列,任务队列遵循 FIFO 规则,可以保证任务执行的公平性。然后执行channel的pipeline
任务类型:普通任务,定时任务,尾部任务
执行过程:
- fetchFromScheduledTaskQueue函数:将定时任务从 scheduledTaskQueue 中取出,聚合放入普通任务队列 taskQueue 中,只有定时任务的截止时间小于当前时间才可以被合并
- 从普通任务队列taskQueue中取出任务
- 计算任务执行的最大超时时间
- safeExecute函数:安全执行任务,实际直接调用的 Runnable 的 run() 方法
- 每执行 64 个任务进行超时时间的检查:如果执行时间大于最大超时时间,则立即停止执行任务,避免影响下一轮的 I/O 事件的处理
- 最后获取尾部队列中的任务执行
EventLoop最佳实践
在日常开发中用好 EventLoop 至关重要,这里结合实际工作中的经验给出一些 EventLoop 的最佳实践方案:
- 网络连接建立过程中三次握手、安全认证的过程会消耗不少时间。这里建议采用 Boss 和 Worker 两个 EventLoopGroup,有助于分担 Reactor 线程的压力
- 由于 Reactor 线程模式适合处理耗时短的任务场景,对于耗时较长的 ChannelHandler 可以考虑维护一个业务线程池,将编解码后的数据封装成 Task 进行异步处理,避免 ChannelHandler 阻塞而造成 EventLoop 不可用
- 如果业务逻辑执行时间较短,建议直接在 ChannelHandler 中执行。例如编解码操作,这样可以避免过度设计而造成架构的复杂性
- 不宜设计过多的 ChannelHandler。对于系统性能和可维护性都会存在问题,在设计业务架构的时候,需要明确业务分层和 Netty 分层之间的界限。不要一味地将业务逻辑都添加到 ChannelHandler 中
ChannelPipeline
ChannelPipeline 可以看作是 ChannelHandler 的容器载体,它是由一组 ChannelHandler 实例组成的,内部通过双向链表将不同的 ChannelHandler 链接在一起。当有 I/O 读写事件触发时,ChannelPipeline 会依次调用 ChannelHandler 列表对 Channel 的数据进行拦截和处理。
ChannelPipeline事件传播机制
ChannelPipeline可分为入栈 ChannelInboundHandler 和出栈 ChannelOutboundHandler 两种处理器,与此对应传输的事件类型可以分为Inbound 事件和Outbound 事件。
- Inbound事件:传播方向为Head->Tail,即按照添加的顺序进行正向传播(A→B→C)
- Outbound事件:传播方向为Tail->Head,即按照添加的顺序进行反向传播(C→B→A)
ChannelPipeline异常传播机制
ChannelPipeline 事件传播的实现采用了经典的责任链模式,调用链路环环相扣。如果有一个节点处理逻辑异常,ctx.fireExceptionCaugh 会将异常按顺序从 Head 节点传播到 Tail 节点。如果用户没有对异常进行拦截处理,最后将由 Tail 节点统一处理。
编解码协议
netty-codec模块主要负责编解码工作,通过编解码实现原始字节数据与业务实体对象之间的相互转化。Netty支持大多数业界主流协议的编解码器,如HTTP、HTTP2、Redis、XML等,为开发者节省了大量的精力。
此外该模块提供了抽象的编解码类ByteToMessageDecoder和MessageToByteEncoder,通过继承这两个类可以轻松实现自定义的编解码逻辑。
拆包粘包
消息长度固定
每个数据报文都需要一个固定的长度。当接收方累计读取到固定长度的报文后,就认为已经获得一个完整的消息。当发送方的数据小于固定长度时,则需要空位补齐。消息定长法使用非常简单,但是缺点也非常明显,无法很好设定固定长度的值,如果长度太大会造成字节浪费,长度太小又会影响消息传输,所以在一般情况下消息定长法不会被采用。
特定分隔符
既然接收方无法区分消息的边界,那么我们可以在每次发送报文的尾部加上特定分隔符,接收方就可以根据特殊分隔符进行消息拆分。
消息长度 + 消息内容
消息长度 + 消息内容是项目开发中最常用的一种协议,如上展示了该协议的基本格式。消息头中存放消息的总长度,例如使用 4 字节的 int 值记录消息的长度,消息体实际的二进制的字节数据。接收方在解析数据时,首先读取消息头的长度字段 Len,然后紧接着读取长度为 Len 的字节数据,该数据即判定为一个完整的数据报文。
Netty中的结构优化
ByteBuf
ByteBuf维护两个不同的索引:读索引(readerIndex) 和 写索引(writerIndex) ,而JDK的ByteBuffer只有一个索引,所以JDK需要调用flip()方法在读模式和写模式之间切换。
- 当 readerIndex > writerIndex 时,则抛出 IndexOutOfBoundsException
- ByteBuf容量 = writerIndex
- ByteBuf 可读容量 = writerIndex - readerIndex
- readXXX() 和 writeXXX() 方法将会推进其对应的索引,自动推进
- getXXX() 和 setXXX() 方法将对 writeIndex 和 readerIndex 无影响
使用模式
- 堆缓冲区模式(Heap Buffer):将数据存放在JVM的堆空间,通过将数据存储在数组中实现,通过将数据存储在数组中实现,提供了数组直接快速访问的方法。每次数据与I/O进行传输时,都需要将数据拷贝到直接缓冲区。
- 直接缓冲区模式(Direct Buffer):堆外分配的直接内存,不会占用堆的容量。适用于涉及大量I/O的数据读写,避免了数据从内部缓冲区拷贝到直接缓冲区的过程,性能较好。
- 复合缓冲区模式(Composite Buffer):是一个组合视图,它提供一种访问方式让使用者自由组合多个ByteBuf,避免了拷贝和分配新的缓冲区。
ByteBuf分配
- 按序分配(ByteBufAllocator):ByteBufAllocator.DEFAULT在java服务器默认使用PooledByteBufAllocator(池化),安卓默认使用UnpooledByteBufAllocator(非池化)。ByteBufAllocator.DEFAULT.buffer()返回一个基于堆或者直接内存存储的Bytebuf,默认是堆内存。(内存池管理chuckList,分配大小:tiny、small、normal、huge)
- Unpooled缓冲区:提供静态的辅助方法来创建未池化的ByteBuf。
- ByteBufUtil类:ByteBufUtil类提供了用于操作ByteBuf的静态的辅助方法: hexdump()和equals(hexdump()以十六进制的表示形式打印ByteBuf的内容;equals()判断两个ByteBuf实例的相等性)
引用计数
- 一般来说,是由最后访问(引用计数)对象的那一方来负责将它释放
zero-copy
- 通过CompositeByteBuf实现零拷贝:Netty提供了CompositeByteBuf 类,可以将多个ByteBuf合并为一个逻辑上的ByteBuf,避免了各个ByteBuf之间的拷贝
- 通过wrap操作实现零拷贝:通过wrap操作,可以将byte[]、ByteBuf、ByteBuffer等包装成一个Netty ByteBuf对象,进而避免了拷贝操作
- 通过slice操作实现零拷贝:ByteBuf支持slice操作,可以将ByteBuf分解为多个共享同一个存储区域的ByteBuf,避免了内存的拷贝
- 通过FileRegion实现零拷贝:通过 FileRegion 包装的FileChannel.tranferTo 实现文件传输,可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环write方式导致的内存拷贝问题
无锁队列mpscQueue
Multiple producer Single comsumer queue
没有容量限制,多段数组链接在一起,一段用完后在分配一段。
FastThreadLocal
ThreadLocal使用hash,冲突时用线性探测,需要再次往后比较。
FastThreadLocal直接分配数组索引,而不是hash散列。
HashedTimeWheel
降低Timer的时间精确性,来换性能
- 使用一个时间轮数组,每个元素一个刻度,定时推进刻度
- 过期时间求余落到时间轮的刻度上,并带有轮次(所以一个刻度中的任务列表执行的轮次可能不一样)
- 当在一个刻度中有大量不同轮次的任务时,触发该刻度会遍历任务列表,选择出当前轮次的来执行(任务多时较耗时)
参考: