Loading

netty总结和记录

前言

几年前工作中使用过netty,而且当时看过netty源码,但是大都忘记了,最近遇到生产问题,又重新看了下dubbo transporter层的设计,netty在dubbo中的使用,理解更深刻了,而且最近做车联网平台,通信也使用netty,有必要总结下netty,以免遗忘了容易再捡起来。本篇文章针对netty做下记录。

netty编程的固定模式总结

以dubbo中使用netty为例

服务端

先看dubbo服务端启动netty server,代码如下图

com.alibaba.dubbo.remoting.transport.netty4.NettyServer#doOpen

image-20220121223555935

服务端创建说明

使用netty创建tcp服务端,模式很固定

step1:创建ServerBootstrap,即netty服务端的启动辅助类,用于设置服务端启动的相关参数。这里是个builder模式,构建的参数太多。

step2:创建boss线程池,即accept线程池。

step3:创建work线程池,即IO线程池。通常work线程池也成为reactor线程池。但是建议通常说IO线程池。

step4:ServerBootstrap构建设置参数,包括tcp参数以及重要的channelHandler。

step5:执行bind操作,启动netty服务监听tcp端口。

step6:最后获取服务端channel,即ServerSocketChannel,对应实现是NioServerSocketChannel。

每个netty服务端都是这样的代码,不同的是里面的addLast的ChannelHandler不同

代码解释:

NioEventLoopGroup是reactor线程池(叫线程集合更好),内部持有一组事件执行器EventExecutor,即我们认为的accept线程或IO线程,对应实际是NioEventLoop。

对于boss线程池来说,通常只有一个accept线程,用于接入连接。它的pipeline [HeadContext->ServerBootstrapAcceptor->TailContext],其中ServerBootstrapAcceptor只是个inbound事件,处理客户端连接,生成channel,把channel注册到IO线程绑定的selector上。然后接着自旋,只处理accept事件。

对于work线程池来说,它有多个IO线程,通常是cpu核数+1。

无论accept线程还是IO线程,他们对应的对象都是NioEventLoop(针对NIO来说)。

acceptor线程,NioEventLoop#run方法自旋,监听accept selector上的accept事件,有客户端连接,则生成channel,并把channel注册到IO线程绑定的selector上。然后接着自旋,只处理accept事件。具体注册到哪个IO线程关联的selector上呢,通过使用轮询算法从IO线程组内选择一个IO线程。

IO线程(多个):每个IO线程维护了一个taskQueue的队列,IO线程NioEventLoop#run方法自旋,处理各自selector上的read事件,同时从taskQueue队列取出task执行(响应消息)。

acceptor线程生成客户端channel时候,会自动为此channel创建好pipeline,创建的pipeline就是netty服务端启动时候channelHandler操作内通过addLast添加的ChannelHandler,按照添加顺序,最终IO线程的pipeline是[HeadContext->解码处理器->编码处理器->其它自定义处理器->TailContext]。netty的逻辑就是在这些ChannelHandler上。

服务端netty总结

EventExecutorGroup->EventLoopGroup->NioEventLoopGroup boss线程池 work线程池,EventLoopGroup成为reactor线程池
EventLoop->NioEventLoop accept线程 IO线程,run方法自旋,事件轮询处理read,从队列取出task执行write。NioEventLoop是个线程池,但是里面只有一个线程(分析代码可以看出,当然从继承SingleThreadEventLoop也可以看出)。

EventLoopGroup持有一组EventLoop

accept线程: 自旋,发现accept selector有accept事件,生成channel,触发accept线程pipeline channelRead,由ServerBootstrapAcceptor从work线程组选择一个IO线程,把此创建的channel注册到IO线程维护的selector上(AbstractChannel$AbstractUnsafe$1),接着accept线程继续自旋。
IO线程: 每个IO线程自旋,selector有read事件,执行processSelectedKeys(),触发IO 线程 pipeline channelRead,先进行解码操作,然后由用户自定义的channelHandler异步提交到业务线程池处理
业务线程处理完业务后,进行netty channel.write,比如是NioSocketChannel,然后触发pipeline,tailContex.writeAndFlush操作,然后封装为WriteTask,丢入到IO线程队列taskQueue。
同时由于IO线程自旋,执行runAllTasks(),从taskQueue取出WriteTask执行,这里就是IO线程了。最终在HeadContext内进行write和flush操作,最终由unsafe(即NioSocketChannel)对象写到网卡把数据发送出去。

netty进行数据发送,采用的是串行无锁化,比多线程要切换上下文,提高了性能。

客户端

以dubbo客户端为例

com.alibaba.dubbo.remoting.transport.netty4.NettyClient#doOpen

image-20220121225407982

step1:创建Bootstrap,即netty客户端的启动辅助类,用于设置客户端启动的相关参数。builder模式,构建的参数太多。

step2:设置netty客户端的启动辅助类Bootstrap的参数。

step3:设置Bootstrap的处理器。这里即设置IO线程的ChannelHandler chain。

既然创建了客户端,就需要有客户端连接服务端,接着看客户端连接服务端代码

com.alibaba.dubbo.remoting.transport.netty4.NettyClient#doConnect

image-20220121225938352

连接服务端只需要io.netty.bootstrap.Bootstrap#connect(java.net.SocketAddress)一行代码搞定,很简单。

netty客户端和服务端差不多,也都是创建Channel、pipeline,请求处理方式也基本相同。

netty启动&请求总结

下图以dubbo2.6.8为例(netty-all-4.0.35.Final.jar),包含了netty server&client的启动和连接,以及客户端请求服务端的流程。

蓝色是服务端启动&接入请求处理。

绿色是客户启动连接&客户端请求处理。

紫色虚线是客户端连接服务端的处理。

红色是客户端请求服务端&服务端响应客户端。

netty

针对netty dubbo一些疑问

疑问1:

dubbo客户端启动时候,设置的IO线程组是cpu+1(如下图),但是dubbo默认只生成一个socket连接,那么dubbo client端进行消息发送时候,到底使用的哪个IO线程对象(NioEventLoop)发送的呢?

image-20220123230359291

答疑:dubbo默认一个客户端只会和一个服务端建立socket连接,虽然在启动客户端的时候Reactor线程池(IO线程组,NioEventLoopGroup)默认是有cpu+1个IO对象(NioEventLoop),但是实际创建的这个socket连接即SocketChannel只默认绑定到NioEventLoopGroup的其中一个NioEventLoop上(默认是第一个),dubbo客户端的不同consumer进行rpc调用时候,都会执行SocketChannel.writeAndFlush(),然后在TailContext.writeAndFlush()操作内把发送操作(即write)封装为task(即WriteAndFlushTask)丢入到此Channel所绑定的taskQueue,这样就又客户端业务线程切换到了客户端IO线程,然后此IO对象自旋操作runAllTasks取出task,按照pipeline逆序执行,最终经由HeadContext调用unsafe操作把消息发送出去。这里就使用了串行无锁化,不使用多线程导致上下文切换带来的性能损耗。

因此dubbo 客户端默认一个连接情况下,发送都使用的同一个SocketChannel,都是由同一个IO线程进行发送出去,其它IO线程并没有使用到。

如果dubbo客户端启动多个连接,那么就是使用不同SocketChannel绑定的IO线程进行发送。

疑问2:

dubbo默认一个连接,客户端在高并发下发送消息,消息都被封装为WriteAndFlushTask丢入到同一个IO线程的taskQueue,那么会不会导致OOM呢?

解答:理论是有这种可能,如果客户端发送消息太快,客户端IO线程处理太慢,导致任务积压在taskQueue,对于这种情况,可以多开几个socket连接,dubbo提供connections参数配置建立多个socket连接。但是实际上,使用dubbo这些年并没有出现过netty 消息发送任务积压导致的OOM,为什么呢?dubbo是如何避免这种情况的呢?首先dubbo使用的netty模型是IO线程和业务线程分开,IO线程只做消息的编解码,没有任何阻塞的操作,解码后丢入到业务线程池处理,这样IO线程就可以一直高效率的工作,因此实际中并没有发生。

疑问3:

为什么实际使用中使用dubbo并没有出现netty 消息发送任务积压taskQueue导致的OOM?

netty发送任务积压taskQueue导致的OOM产生有以下几种因素

1.网络瓶颈导致积压,当发送速度超过网络链接处理能力,会导致发送队列积压。

2.当对端读取速度小于乙方发送速度,导致自身TCP发送缓冲区满,频繁发生write 0字节时,待发送消息会在Netty发送队列中排队。

针对dubbo来说,用于内部服务调用,在一个局域网内,因此网络瓶颈基本不存在。

先说客户端发送队列满:dubbo默认使用的是IO线程和业务处理分离,IO线程只处理编解码,不会有阻塞的情况,耗时的操作(比如操作数据库)由业务线程处理,针对客户端发送来说,发送是由IO线程进行编码后通过网络发送出去,网络瓶颈不存在,服务端通过IO线程接收请求,然后把解码结果丢入到业务线程池处理,至此客户端的发送动作就结束了,这些动作都在双方的IO线程上操作,没有阻塞,因此很难产生客户端发送队列满的问题。

再说服务端发送(响应)队列满:dubbo服务端业务处理完成后(业务线程),把响应数据丢入到IO线程队列taskQueue,如果taskQueue发生OOM,那么dubbo服务端的业务线程向taskQueue添加任务的速度要大于IO线程处理任务速度,这个实际是不可能出现的,因为业务线程都有耗时操作,要阻塞,而IO线程没有阻塞。还有dubbo服务端在处理请求时候,如果业务线程池满的情况,会报错threadpool is exhausted线程池打满错误返回客户端,因此也从接收源头上杜绝了netty taskQueue OOM的可能。

实际中,dubbo中使用netty并没有对netty的水位线进行设置,自定义的ChannelHandler也没有重写channelWritabilityChanged。dubbo主要使用的IO线程和业务线程分离避免了netty发送队列。使用这种方式,按理来说,很难出现netty 消息发送任务积压的OOM。

netty顶层接口设计

从前面分析可以看出,netty有几个重要接口/类我们开发必须要了解

EventLoopGroup:即reactor线程池,继承了jdk的线程池,持有一组EventLoop,即持有一组reactor线程。

EventLoop:reactor线程,在不同场景也有不同叫法,比如服务端处理连接accept线程,处理read/write事件是在IO线程。也继承了jdk线程池,但是它比较特殊只有一个线程,这个线程自旋处理selector上的read事件&从队列taskQueue取出task执行来发送消息。主要实现是NioEventLoop/EpollEventLoop,分别是针对selector/epoll的实现。常用的是NioEventLoop,持有java.nio.channels.Selector。

Channel:netty针对socket的抽象,叫网络通道,具体监听、连接、read、write、close等能力。每个channel被创建后,1.都会注册到java.nio.channels.Selector,因此可以通过channel获取到reactor线程;2.持有并创建对应的ChannelPipeline。

ChannelHandler:channel处理器,处理channel的。由pipeline持有ChannelHandler chain,通过责任链处理Channel。

ChannelPipeline:随着Channel的创建被创建,与Channel互相持有,实际上就是个ChannleHandler chain对象,实现是DefaultChannelPipeline,pipeline持有HeadContext、TailContext,两个ChannelHandler通过链表连接,中间被加入其它ChannelHandler。

顶层接口设计如下图:

netty接口设计

对象之前的关系
NioEventGroup下包含多个NioEventLoop
每个NioEventLoop中包含有一个Selector,一个taskQueue
每个NioEventLoop的selector上可以注册监听多个NioChannel
每个NioChannel只会绑定在唯一的NioEventLoop上
每个NioChannel都绑定有一个自己的Channel与Pipeline

ChannelHandler详解

ChannelHandler的设计

ChannelHandler是我们开发者必须要接触且需要自定义开发的处理器,在Channel被创建时候,同时创建ChannelPipeline,持有ChannelHandler chain,ChannelHandler作为通道处理器,责任链设计,每个handler职责分别处理不同数据,比如编解码、解码结果提交到业务线程池处理。接口设计如下

netty channelhandler

ChannelHandler分为inboud&outbound,请求接入(入栈),pipeline触发ChannelHandler chain执行inboud channelRead,响应请求(出栈),pipeline触发ChannelHandler chain执行outbound write&flush操作。

针对上图中的三个蓝色类,是我们开发过程中经常需要进行扩展。

ByteToMessageDecoder:解码器,功能是把二进制流解码为业务理解的POJO,netty抽象的工具解码器类,其decode()需要用户自行实现。另外此类并没有考虑tcp粘包和拆包场景,用户需要在实现的encode方法中解决粘包和拆包问题。

MessageToMessageDecoder:netty提供的二次解码器,功能是把ByteToMessageDecoder解码后的POJO再次解码为POJO。比如http+xml的协议,第一次经过ByteToMessageDecoder解码为HttpReuqest,接着由二次解码器MessageToMessageDecoder把xml解码为POJO对象。

MessageToByteEncoder:编码器,负责把POJO编码为二进制流保存到缓冲区ByteBuf,用户自定义编码器要重写encode方法。

MessageToMessageEncoder:二次编码器,把一个POJO对象编码为另一个对象;比如http+xml的协议,先把POJO编码为xml,再把xml解码为POJO对象。

ChannelDuplexHandler:是个inboud&outbound,自定义的channelHandler如果即想实现inboud&outboud,则继承这个方法,比如记录日志功能、比如dubbo的NettyServerHandler。

ChannelHandler的生命周期

  1. ChannelHandler 有两个子接口,分别是 ChannelInboundHandler 和 ChannelOutboundHandler,分别是inboud(入站-处理接入请求)和outbound(出站-处理响应请求)的接口类。

  2. 如果我们自定义的业务 Handler 直接实现 ChannelInboundHandler 或者 ChannelOutboundHandler,那么我们需要实现的接口非常的多,增加了开发的难度。Netty 已经帮我们封装好了两个实现类,分别是 ChannelInboundHandlerAdapter 和 ChannelOutboundHandlerAdapter,这样可以大大简化了开发工作。

ChannelInboundHandler

ChannelInboundHandler 的接口有许多方法,这些方法的功能是什么?在什么时候调用呢?

服务端定义一个业务ChannelInboundHandler 即 MyChannelHandler,通过channel.pipeline().addLast(new MyChannelHandler()),把此MyChannelHandler加到pipeline的最前面,每个方法内执行打印System.err.println(方法名);通过测试结论如下:

客户端连接,触发
handlerAdded
channelRegistered
channelActive
客户端发送数据,触发
channelRead
channelReadComplete
心跳断开,服务端主动断开,触发
channelInactive
channelUnregistered
handlerRemoved
客户端主动断开,触发
channelReadComplete
channelInactive
channelUnregistered
handlerRemoved

ChannelOutboundHandler

MyChannelHandler同时实现inboud&outbound接口,测试结果如下:

客户端连接,触发
handlerAdded
channelRegistered
channelActive
read

客户端发送数据,触发
channelRead
channelReadComplete
write
flush
read

心跳断开,服务端主动断开,触发
close
channelInactive
channelUnregistered
handlerRemoved

客户端主动断开,触发
channelReadComplete
read
channelInactive
channelUnregistered
handlerRemoved

发现在连接和发送的场景,都会调用outbound的read操作,这个一直没有使用过,ChannelOutboundHandler本应该只关注outbound事件,但是它却声明了一个read方法,那这个操作是做什么的呢?经过网上查询stackoverflow,outbound的read操作是拦截ChannelHandlerContext.read(),也就是说,ChannelOutboundHandler可以通过read()方法在必要的时候阻止向inbound读取更多数据的操作。这个设计在处理协议的握手时非常有用。个人理解是针对ack的设计。对我们平时开发来说,其实不需要关注这个,不懂也没关系,知道就行。

ChannelHandler方法总结

接口 方法 方法功能 触发条件&说明
ChannelHandler handlerAdded 向pipeline加入此ChannelHandler 连接时候触发,即Handler 被加入 Pipeline 时触发(仅仅触发一次)。当然也可以在运行时动态添加,比如ctx.pipeline().addLast(xxx);
handlerRemoved 从pipeline移除此ChannelHandler 连接断开时触发,handler 被从 Pipeline 移除时触发。当然,也可以在pipeline内动态移除handler,比如ctx.pipeline().remove(this);
ChannelInboundHandler channelRegistered channel注册到selector,即注册到reactor线程EventLoop,即channel绑定到EventLoop 连接时候触发(仅仅触发一次),channe注册到Selector,即绑定到reactor线程EventLoop
channelUnregistered channel从selector取消注册,即从绑定的EventLoop移除, channel 取消注册时触发,连接断开时触发(仅仅触发一次)
channelActive channel激活,即tcp连接建立,此时状态是ESTABLISH channel 连接就绪时触发(仅仅触发一次)
channelInactive channel关闭,即tcp连接失效,此时状态不再是ESTABLISH channel 断开时触发(仅仅触发一次)
channelRead 读取消息 channel 有数据可读时触发,即接收消息触发(触发多次)
channelReadComplete 消息读取完毕 channel 有数据可读,并且读完时触发,即消息接收完毕触发(触发多次)
userEventTriggered 用户事件触发 比如netty的IdleStateHandler在未检测到心跳,触发ctx.fireUserEventTriggered(evt);当然我们自定义handler也可以指定触发
channelWritabilityChanged channel的write能力改变,变为不可写/可写 netty水位线相关,write操作时候超过netty默认水位线触发,防止taskQueue过大导致oom
ChannelOutboundHandler bind 服务端绑定监听端口 服务端服务启动阶段
connect 客户端连接服务端 客户端启动连接阶段
disconnect 连接断开
close 连接关闭
deregister channel从EventLoop取消注册
read 拦截ChannelHandlerContext.read()
write 发送消息,即把消息写到缓冲区,供flush发送
flush 发送消息,把消息从缓冲区刷新到网卡

我们重点关注的的是channelRead、write,其次是channelActive、channelInactive、userEventTriggered

有几个混乱地方总结下:

问题 1:channelRegistered 注册指的是什么呢?

Channel 在创建时,需要绑定 ChannelPipeline 和 EventLoop 等操作,完成这些操作时会触发 channelRegistered () 方法,就是channel注册到Selector上,即注册到NioEventLoop。

问题 2:channelRead 和 channelReadComplete 的区别?

当 Channel 有数据可读时,会触发 channelRead 事件,eventLoop 被唤醒并且调用 channelRead () 处理数据;eventLoop 唤醒后读取数据包装成 msg,然后将 msg 作为参数调用 channelRead (),期间做了个判断,读取到 0 字节或者读取到的字节数小于 buffer 的容量,满足以上条件就会调用 channelReadComplete ()。

ChannelHandler 性能优化

ChannelHandler 的优化主要在两点:

1.在执行过程中动态的删除无用的 Handler, 缩短 Handler 的传播距离;(热插拔)

2.避免每个客户端的连接进来时都重复创建 Handler;

动态删除无用的handler,比如pipeline包含登录handler,但是登录一个channel只需要一次,登录后就可以把登录Handler进行移除了,比如ctx.pipeline().remove(this)。

每个用户连接进来,都会生成channel,以及channel关联的pipeline,那么针对自定义的handler,是否可以设计为单例,那么自定义的handler就不会每个客户端连接进来都要创建自定义的handler了,有两种思路,一是采用netty的@Shareable,二是通过spring容器把此自定义handler定为bean即可。通常采用的@Shareable较多,不与spring进行耦合。

需要注意的是,对于单例handler需要注意线程安全问题,因此不要包含全局变量、全局静态变量,否则就会出现线程安全问题。

netty编解码

网络请求传输是二进制流(在java中就是byte[]),那么请求接入(inbound)需要把二进制流转换为业务数据,即解码,业务处理完成,需要把业务数据响应客户端,需要把业务数据转换为二进制流,即编码。

解码->二进制流转换为业务数据

编码->业务数据转换为二进制流

netty提供了byte->message,message->byte,分别是ByteToMessageDecoderMessageToByteEncoder,从名称上很容易理解。

我们开发的解码通常需要实现ByteToMessageDecoder并重写decode方法,编码需要实现MessageToByteEncoder并重写encode方法。

但是tcp由于是流,是没有界限的一串数据。TCP底层并不了解上层业务数据的具体含义,它会根据TCP缓冲区的实际情况进行包的划分,所以在业务上认为,一个完整的包可能会被TCP拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的TCP粘包和拆包问题。

粘包、拆包发生的原因主要有4种情况:

1.要发送的数据大于TCP发送缓冲区剩余空间大小,将会发生拆包。

2.待发送数据大于MSS(最大报文长度),TCP在传输前将进行拆包。

3.要发送的数据小于TCP发送缓冲区的大小,TCP将多次写入缓冲区的数据一次发送出去,将会发生粘包。

4.接收数据端的应用层没有及时读取接收缓冲区中的数据,将发生粘包。

由于tcp无法理解我们的业务数据,所以在tcp层是无法解决数据包不被拆分和重组的,因此这个问题只能通过我们定义的协议来解决,所谓协议就是一种双方约定的数据格式。

解决粘包和拆包,通常在业务上为传输数据定义结构,比如定长、变长、指定分隔符等,这些在netty中都有对应的解码实现。

netty常用粘包和拆包解码器 解释
LineBasedFrameDecoder 换行符作为结束标识,换行符\n或者\r\n
FixedLengthFrameDecoder 定长解码器,报文长度是固定的
LengthFieldBasedFrameDecoder 变长解码器,带前置长度的解码器(包含前置长度本身),比如发送12345总共5字节,那么编码结果是0x00 0x07 12345,解码过程是先解码前2字节,转换为int长度7,然后再从缓冲区读取7-2=5个长度作为body
DelimiterBasedFrameDecoder 指定分隔解码器,用户指定分隔符,比如jt808协议是0x7e作为分隔符
HttpObjectDecoder http解码器,包括HttpRequestDecoder和HttpResponseDecoder,读取一个完整的http请求和http响应报文

这些解码器并不是把二进制流就直接解析为了我们需要的业务数据,只是从缓冲区读取了一个完整的报文而已,还需要把报文数据解码为我们业务需要数据。

比如定长协议1234,总计4字节,接收到的网络数据是 0x01 0x02 0x03 0x04,那么先根据FixedLengthFrameDecoder读取4字节,获取一个完整的报文,然后根据用户指定的编码方式把前2个字节解析为产品id(12),最后2字节解析为部门id(34)。

粘包和拆包只是在解析过程需要,编码是不需要的,因为编码的时候我们知道要编码为什么格式的数据。

ByteBuf

1.ByteBuf说明

ByteBuf是netty实现的缓冲区,实际就是对jdk NIO的ByteBuffer的封装,对其功能进行了增强。不直接使用JDK NIO ByteBuffer的原因的使用复杂(只有一个指针)、长度固定不能动态的扩展和收缩。

netty ByteBuf主要原理是由两个指针readerIndex、writerIndex,分别表示读和写的指针位置,readerIndex和writerIndex的开始位置都是0,当从channel读取到数据写到了缓冲区ByteBuf的时候,writerIndex增加,当从ByteBuf读取时,readerIndex会增加,但是不会超过writerIndex,那么writerIndex-readerIndex就是可读的数据;在读取后0~readerIndex的位置认为是discard的,调用discardReadBytes方法可以释放这部分空间。写操作只修改writerIndex,读操作只修改readerIndex,,不需要JDK NIO ByteBuffer那样总是flip操作总是容易让人遗忘,这种设计很简单明了,简化了缓冲区的操作。同时写操作会进行自动扩容,我们无需关注。

下图是ByteBuf的操作过程图,很容易理解。

netty ByteBuf

2.netty ByteBuf提供的API

readXXX():从缓冲区读的操作,从readIndex位置开始读,每次读取一个字节,readIndex加1。

writeXXX():向缓冲区写操作,从writeIndex位置开始写,每次写取一个字节,writeIndex加1。比如int writeBytes(ScatteringByteChannel in, int length),是NIO通道把数据写入到缓冲区

从缓冲区查找:indexOf、bytesBefore

复制缓冲区:duplicate

复制缓冲区的方法 说明
duplicate 返回当前ByteBuf的复制对象,复制后的ByteBuf与复制前的共享同一段内存,但是读写索引是分开的,修改其中一个ByteBuf的数据,对另一个可见
copy 复制一个新的ByteBuf,它的内容和索引都是分开的
slice 返回当前ByteBuf的可读子缓冲区,即返回的是原ByteBuf的writeIndex-readIndex之间的缓冲区,共享一段内存,但是读写是分开的

与jdk NIO ByteBuffer的转换

nioBuffer:把netty ByteBuf转换为jdk NIO ByteBuffer,两者共享同一个内容,修改对彼此可见。

ByteBuf getBytes(int index, ByteBuffer dst):把jdk NIO ByteBuffer转换为netty ByteBuf

与byte[]的转换

array():netty ByteBuf转换为byte[]

ByteBuf getBytes(int index, byte[] dst):把byte[]转换为netty ByteBuf

3.ByteBuf的创建

分为非池化堆内存UnpooledHeapByteBuf、非池化直接内存UnpooledDirectByteBuf、池化堆内存PooledHeapByteBuf、池化直接内存PooledDirectByteBuf

使用工具类ByteBufAllocator进行创建ByteBuf,ByteBufAllocator有池化PooledByteBufAllocator和非池化UnpooledByteBufAllocator,通常IO线程使用的是池化的堆外内存,这样减少了数据从内核copy到用户内存,提升效率;同时池化技术预先分配好内存池,可以循环利用创建的ByteBuf,提升内存使用效率,降低由于高负载导致的频繁GC。

Unpooled 工具创建非池化缓冲区(堆内存、堆外内存)

PooledByteBufAllocator.DEFAULT.buffer() //返回一个池化的堆外内存缓冲区

4.netty缓冲区的高性能

netty允许把多个缓冲区组合为一个缓冲区,即CompositeByteBuf,它只是逻辑上的组合,实际底层每个缓冲区并不是连续内存,避免了各个ByteBuf之间的拷贝,这也是netty的零拷贝。

高性能:使用池化技术,避免了缓冲区反复创建;使用堆外内存,避免了内核到用户的拷贝;使用组合缓冲区,避免各个ByteBuf之间的拷贝。

Unsafe

netty的Unsafe接口官方注释写的很清楚,永远不要从用户代码调用,实际上我们也不需要怎么关注,只是这个是接口和HeadContext进行交互,用于读取和响应数据,因此稍微记录下。

首先Channel持有unsafe,而unsafe作为AbstractNioChannel的内部类,可以调用AbstractNioChannel的read操作,从channel读取数据,从而触发pipeline。这样设计的原因是Channel有不同的实现比如NIO、epoll,那么通过unsafe的不同实现作为不同方式的适配。

读取(接收)

在reactor线程(IO线程)处理注册到selector就绪事件processSelectedKey中,从Channel获取unsafe,

image-20220129012441853

unsafe读取数据,即channel读取数据,写入到缓冲区ByteBuf,接着触发fireChannelRead,从HeadContext开始链式执行,读取完毕后触发fireChannelReadComplete。

响应(发送)

由TailContext触发writeAndFlush操作,按照pipeline的链式逆序执行,最后由HeadContext调用unsafe的write&flush发送(响应)数据。因为

channel被创建的时候,同时也创建和持有unsafe,同时也创建了与channel对应的pipeline,因此HeacContext也被创建,且持有Unsafe,那么在发送的时候就可以调用unsafe的write&flush,即还是调用channel进行发送。

消息的发送通常有两种方式

方式1:io.netty.channel.Channel#writeAndFlush(java.lang.Object)

方式2:ctx.pipeline().writeAndFlush(Object msg); 其中ctx是ChannelHandlerContext,由ChannelHandler触发。

ChannelFuture & ChannelPromise

ChannelFuture

Future主要是用于获取异步执行结果,netty里面的Future也是继承了jdk Future,由于netty IO是异步执行,意味着任何IO调用都会立刻返回。异步操作带来一个问题,调用者如何获取异步调用的执行结果呢?ChannelFuture 就是为了获取netty io异步操作的执行结果而设计。

ChannelFuture有两种状态,uncompleted和completed。当开始一个IO操作时,一个新的ChannelFuture被创建,此时处于uncompleted状态,在IO操作完成后,ChannelFuture会被设置为completed。它的状态迁移如下:

                                        +---------------------------+
                                        | Completed successfully    |
                                        +---------------------------+
                                   +---->      isDone() = true      |
   +--------------------------+    |    |   isSuccess() = true      |
   |        Uncompleted       |    |    +===========================+
   +--------------------------+    |    | Completed with failure    |
   |      isDone() = false    |    |    +---------------------------+
   |   isSuccess() = false    |----+---->      isDone() = true      |
   | isCancelled() = false    |    |    |       cause() = non-null  |
   |       cause() = null     |    |    +===========================+
   +--------------------------+    |    | Completed by cancellation |
                                   |    +---------------------------+
                                   +---->      isDone() = true      |
                                        | isCancelled() = true      |
                                        +---------------------------+
本图来源ChannelFuture的注释

Netty强烈建议通过添加监听器获取异步执行结果(不要使用await操作),或者进行后续的相关操作。

比如netty服务端在发送完数据关闭连接io.netty.channel.Channel#writeAndFlush(java.lang.Object).addListener(ChannelFutureListener.CLOSE, ChannelFutureListener.CLOSE_ON_FAILURE),当IO操作完成后,IO线程会回调ChannelFuture中io.netty.util.concurrent.GenericFutureListener#operationComplete操作,并把ChannelFuture作为入参。此外如果我们需要做上下文相关的操作,需要将上下文信息保存到ChannelFuture中。

ChannelPromise

Promise中文是承诺,从名称上看不出是做什么的。看它方法,提供了set功能,设置结果为成功、失败、取消等,参考别人介绍,promise是可写的Future,可以对执行结果进行设置。netty通过Promise对Future进行扩展,用于设置IO操作的结果。ChannelPromise extends ChannelFuture。

Netty发起IO操作时,会创建一个新的ChannelPromise (在write操作TailContext.writeAndFlush(Object msg)),当IO操作完成、异常时,设置ChannelPromise的执行结果(即HeadContext#write调用unsafe可以设置Promise的结果)。

同ChannelFuture,获取异步执行结果通过添加监听器来实现。

netty线程模型

netty线程模型来源于reactor线程模型,reactor线程模型分为三种:

单 Reactor 单线程:由一个线程处理接入、IO操作、业务操作,当然这种单线程在业务操作阻塞情况下会导致整个阻塞。
单 Reactor 多线程:由一个线程处理接入、IO操作等非阻塞操作,业务操作由线程池处理,这种基本可以满足大多数应用,但是在高并发场景下,一个线程既用于处理accept,又用于处理IO操作,这个线程会成为系统瓶颈。那么把处理accept和IO处理分开不就可以提高性能了吗,因此产生了主从reactor。
多 Reactor 多线程:分为主从reactor,主reactor用于accept,从reactor用于IO操作,IO操作后提交到业务线程池处理。这种模式是我们使用netty编程的常用模式。比如dubbo就是使用的这种模式。

reactor的理解:reactor的中文是反应堆,根据字面意思不好理解,reactor使用的是事件驱动模式(通过回调实现),我们可以理解为当有一个事件开始驱动的时候,就会陆续驱动多个事件,最后就像核反应堆一样,产生巨大的能量,在网络里就是高效地处理并发。它的名称来源于Patterns for Distributed Real-time and Embedded Systems

在netty中,reactor线程是EventLoop。

为什么jdk NIO是同步非阻塞

按照POSIX标准来划分只分为两类:同步IO和异步IO。

如何区分呢?

首先一个IO操作(read/write系统调用)其实分成了两个步骤:1.发起IO请求 2.实际的IO读写(内核态与用户态的数据拷贝)

阻塞IO和非阻塞IO的区别在于第一步,发起IO请求的进程是否会被阻塞,如果阻塞直到IO操作完成才返回那么就是传统的阻塞IO,如果不阻塞,那么就是非阻塞IO。NIO使用的selector,读不到数据立刻返回,不会导致阻塞。

同步IO和异步IO的区别就在于第二步,实际的IO读写(内核态与用户态的数据拷贝)是否需要进程参与,如果需要进程参与则是同步IO,如果不需要进程参与就是异步IO。NIO使用的selector还会同步从内核copy数据到用户进程,因此是同步的。

如果实际的IO读写需要请求进程参与,那么就是同步IO。因此阻塞IO、非阻塞IO、IO多路复用、信号驱动IO都是同步IO,

jdk selector使用的是IO多路复用技术,所谓IO多路复用,即一个IO线程可以管理多个channel,多个channel复用同一个IO的读写(一个IO线程读写可以管理多个channel)(即selector)

后续待办

写个netty实战教程,包含同步长连接、同步短连接、异步长连接单工。

同步长连接参考dubbo通信。

同步短连接:消息返回后要主动关闭连接,通过对ChannelFuture添加监听器实现,大概如下

netty短连接

添加IdleStateHandler 多长时间没有可读则关闭channel

服务端:在业务线程处理完毕后发送数据, io.netty.channel.Channel#writeAndFlush(java.lang.Object).addListener(ChannelFutureListener.CLOSE, ChannelFutureListener.CLOSE_ON_FAILURE),发送完毕后/发送失败调用close关闭连接

客户端:在IO线程channeReadCompelete读取完毕后,进行io.netty.channel.Channel.close()动作。

异步长连接单工:客户端和服务端都开启端口监听,且都去连接对方的监听端口,一个连接联络只允许请求,另外一个只允许响应。参考以前做银行时候的做法。

posted @ 2022-02-02 19:37  不晓得侬  阅读(138)  评论(0编辑  收藏  举报