Netty中的理论与实践
Netty线程模型理论
- 一个EventLoopGroup当中会包含一个或多个EventLoop
- 一个EventLoop在它的整个生命周期当中都只会与唯一一个Thread进行绑定
- 所有由EventLoop所处理的各种I/O事件都将在它所关联的那个Thread上进行处理
- 一个Channel在它的整个生命周期中只会注册在一个EventLoop上
- 一个EventLoop在运行过程当中,会被分配一个或多个Channel
当你去执行EventLoop或者是执行Channel的任何操作的时候,Netty一定会做一个判断,它会去判断当前你调用这个方法的线程是不是这个Channel所关联的EventLoop中维护/封装的那个thread,如果是直接执行,如果不是则会以一个任务的形式将你要执行的操作逻辑以任务的形式提交给这个EventLoop让它去执行(EventLoop执行就是通过它里面维护的thread执行)
通过2与4这两个条件的前提,就非常好的确保了一个Channel以及之上的Pipeline里面维护的若干个context对应的handler里面的若干个回调方法,永远都是由同一个线程去执行的
- 因此我们不会在handler里面做任何的并发的控制,因为单线程不需要做并发控制
- 正式因为我们的handler里面的业务方法都是由这个I/O线程(EventLoop里面所包含的thread)去执行的,因此一定一定一定不能让任何耗时的操作来阻塞这个I/O线程。因为这一个I/O线程可能服务于成百上千上万个Channel对象,如果其中的一个Channel里面的一个handler方法把它给阻塞掉了,那么其它的所有Channel里面的所有handler的所有回调方法都不会及时的得到调用。(性能损失直线下降)
所有属于同一个channel的操作,它们的任务的提交顺序与任务最后的执行顺序一定是完全一样的(任务队列的方式,一个一个的执行,先放进去的先执行)
重要结论
在Netty中,Channel的实现一定是线程安全的,基于此我们可以存储一个Channel的引用,并且在需要向远程端点发送数据时,通过这个引用来调用Channel相应的方法,即便当时有很多线程都在使用它也不会出现多线程问题,而且消息一定会按照顺序发送出去
例如:我们调用Channel的writeAndFlush()这个方法的线程并不是Channel所对应的EventLoop里面所关联的thread线程,Netty则会将这种调用以任务的形式提交给Channel所关联的EventLoop里面的thread来去执行(先提交的一定先执行,后提交的一定后执行)
重要结论
我们在业务开发中,不要将长时间执行的耗时任务放入到EventLoop的执行队列当中(不要放到自定义的Handler当中),因为它将会一直阻塞该线程所对应的所有Channel上的其它执行任务,如果我们需要进行阻塞调用或是耗时的操作(实际开发中很常见),那么我们就需要使用一个专门的EventExecutor(业务线程池)。
通常会有两种实现方式:
- 在ChannelHandler的回调方法中,使用自定义的业务线程池(另开线程执行耗时操作),这样就可以实现异步调用。(可以使用Executors)(这种实现方式与Netty无关,更简单)
- 借助于Netty提供的,向ChannelPipeline添加ChannelHandler时调用的addLast()方法来传递EventExecutor。
第二种方式说明:默认情况下(调用addLast(ChannelHandler handler)),ChannelHandler中的回调方法都是由I/O线程所执行,如果调用了ChannelPipeline addLast(EventExecutorGroup group, ChannelHandler... handlers);方法,那么ChannelHandler中的回调方法就是由参数中的group线程组来执行的。
JDK所提供的Future只能通过手工方式检查执行结果,而这个操作是会阻塞的。Netty则对ChannelFuture进行了增强,通过ChannelFutureListener以回调的方式来获取执行结果,去除了手工检查阻塞的操作。值得注意的是ChannelFutureListener的operationComplete方法是由I/O线程执行的,因此要注意的是不要在这里执行耗时的操作,否则需要通过另外的线程或线程池来执行。
Netty中有两种发送消息的方式
- 可以直接写到Channel中
- 也可以写到与ChannelHandler所关联的那个ChannelHandlerContext中。对于前一种方式来说消息会从ChannelPipeline的末尾开始流动。对于后一种方式来说,消息将从ChannelPipeline中的下一个ChannelHandler开始流动。
结论:
- ChannelHandlerContext与ChannelHandler之间的关联绑定关系是永远都不会发生改变的,因此对其进行缓存是没有任何问题的
- 对于与Channel的同名方法来说,ChannelHandlerContext的方法将会产生更短的事件流,所以我们应该在可能的情况下利用这个特性来提升应用性能。
服务器端也做客户端
服务器作为客户端连接另一个服务器:
A客户端向B服务端发起连接并发送数据,B服务器端可能会调用其它的服务器进行处理例如C服务端,那么对于C服务器端来说B是一个客户端。
形式就是:A客户端 > B服务端 > C服务端 ( > 表示连接)
一个服务端既是服务端又是客户端,它可能是另一个服务器的客户端
最佳实践
让B服务器端,基于A客户端的角色与基于C服务器端的角色共用一个EventLoop(让同一个EventLoop处理建立AC的两个连接的Channel)
伪代码示例:
//当channel激活的时候 我们创建一个Bootstrap
public void channelActive(ChannelHandlerContext ctx){
Bootstrap bootstrap = ....
bootstrap.channel(NioSocketChannel.class).handler(new newHandler())
//关键代码 ctx.channel().eventloop() 共用一个eventloop
bootstrap.group(ctx.channel().eventloop())
bootstrap.connect()......
}
使用NIO进行文件读取所涉及的步骤
- 从FileInputStream对象获取到Channel对象
- 创建Buffer
- 将数据从Channel中读取到Buffer对象中
0 <= mark <= position <= limit <= capacity
flip()方法
- 将limit值设为position
- 将position值设为0
clear()方法
- 将limit值设为capacity
- 将position值设为0
compact()方法
- 将所有未读的数据复制到buffer的起始位置处
- 将position设为最后一个未读元素的后面
- 将limit设为capacity
- 现在buffer就准备好了,但是不会覆盖未读的数据
Netty处理器重要概念
- Netty的处理器可以分为两类:入站处理器与出站处理器
- 入站处理器的顶层是ChannelInboundHandler,出站处理器的最顶层是ChannelOutboundHandler。它们两个的共同父类是ChannelHandler
- 数据处理时常用的各种编解码器本质上都是处理器
- 编解码器:无论我们向网络中写入的数据是什么类型(int char String 二进制等),数据在网络传递时,其都是以字节流的形式呈现的。将数据由原本的形式转换为字节流的操作称为编码(encode),
将数据由字节码转换为它原本的格式或是其它格式的操作称为解码(decode),编码器统一称为codec。 - 编码:编码的方向应该是从程序到网络,从处理器的角度来看从程序到网络的操作称为出站操作。所以本质上是一种出站处理器,因此,编码一定是一种ChannelOutboundHandler
- 解码:本质上是一种入站处理器,因此,解码一定是一种ChannelInboundHandler
- 在Netty中,编码器通常以XXXEncoder命名,解码器通常以XXXDecoder命名。但是这不是绝对的
关于Netty编解码器的重要结论
- 无论是编码器还是解码器,其所接收的消息类型必须要与待处理的参数类型一致,否则该编码器或解码器并不会被执行
- 在解码器进行数据解码时,一定要记得判断缓冲(ByteBuf)中的数据是否足够,否则将会产生一些问题。
例如:索引下标越界(io.netty.handler.codec.DecoderException: java.lang.IndexOutOfBoundsException)