Netty基础及原理

为什么使用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等,为开发者节省了大量的精力。

此外该模块提供了抽象的编解码类ByteToMessageDecoderMessageToByteEncoder,通过继承这两个类可以轻松实现自定义的编解码逻辑。

拆包粘包

消息长度固定

每个数据报文都需要一个固定的长度。当接收方累计读取到固定长度的报文后,就认为已经获得一个完整的消息。当发送方的数据小于固定长度时,则需要空位补齐。消息定长法使用非常简单,但是缺点也非常明显,无法很好设定固定长度的值,如果长度太大会造成字节浪费,长度太小又会影响消息传输,所以在一般情况下消息定长法不会被采用。

特定分隔符

既然接收方无法区分消息的边界,那么我们可以在每次发送报文的尾部加上特定分隔符,接收方就可以根据特殊分隔符进行消息拆分。

消息长度 + 消息内容

消息长度 + 消息内容是项目开发中最常用的一种协议,如上展示了该协议的基本格式。消息头中存放消息的总长度,例如使用 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

没有容量限制,多段数组链接在一起,一段用完后在分配一段。

GitHub - JCTools/JCTools

FastThreadLocal

ThreadLocal使用hash,冲突时用线性探测,需要再次往后比较。

FastThreadLocal直接分配数组索引,而不是hash散列。

HashedTimeWheel

降低Timer的时间精确性,来换性能

  1. 使用一个时间轮数组,每个元素一个刻度,定时推进刻度
  2. 过期时间求余落到时间轮的刻度上,并带有轮次(所以一个刻度中的任务列表执行的轮次可能不一样)
  3. 当在一个刻度中有大量不同轮次的任务时,触发该刻度会遍历任务列表,选择出当前轮次的来执行(任务多时较耗时)

参考:

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值