NIO与Netty概念

Posted by 皮皮潘 on 11-29,2021

0.基本概念

所有的IO都分为两个过程:1. 内核空间等待足够的、可用的数据到达 2. 将数据从内核空间移动到用户空间。对于NIO、多路复用而言,第二个过程都是阻塞的。

同步和异步:同步是指两个操作都在同一个线程中顺序执行,异步是指两个操作在不同线程中执行,一般表现形式为将第二个操作基于回调传给另外一个线程从而使得第二个线程来执行它

阻塞和非阻塞:非阻塞是指一个调用方法会立即返回而无需等待其逻辑执行完毕,反之需要等待调用方法的逻辑执行完毕的则是阻塞

AIO和多路复用IO/NIO的区别在于前者的操作系统已经把第一第二件事情都已经做完了,即数据已经到了用户态才通知用户,因此用户可以直接使用数据,实现了真正的非阻塞。而后者在第一件事做完即足够数据到达内核态就通知用户,因此需要用户代码再去阻塞地从内核读取数据到用户态才能使用

BIO(阻塞) -> NIO (非阻塞加无限循环加全部遍历)-> Selector(事件驱动 底层内核Epoll实现 基于网卡Interrupt) -> Reactor(一主多从Selector 主Selecotr负责建立连接并将Socket负载均衡地注册到从Selector上 每个从Selector本身是一个线程然后无限循环地对于事件驱动的Socket处理请求 每个Socket生存期间都只对应一个线程 也即其注册的Selector对应的线程)

Selector底层使用内核的Epoll相关函数,可以使用man命令查询内核三个主要函数:1. epoll_create 创建Epoll对象2. epoll_ctl 对于Epoll对象进行控制 如:注册,取消等3. epoll_wait 等待事件发生并获取对应的事件列表

Netty核心组件:1.Channel 2.ByteBuf 3.ChannelHandler和ChannelPipeline 4.EventLoop 5.Bootstrap

1.Channel

Channel的底层实现也即Java的Socket

Channel是线程安全的(基于Queue),也就是说多个线程可以并发地调用Channel的write操作,并保证write的内容不会互相错位,如一个线程写ab, 另外一个线程调用cd,最后不会输出acbd的情况

Server有两种类型的Channel:ServerSocketChannel以及SocketChannel,前者负责监听,后者负责通信,且后者相较于前者的配置需要多child前缀,如:childHandler,childOption等,因此ServerBootstrap需要两个EventLoopGroup(虽然可以是同一个实例)。Client则只有一种类型的Channel,即SocketChannel,且在配置时不需要child前缀,因此只需要一个EventLoopGroup

Channel的生命周期如下:ChannelUnregistered(Channel创建了但是还没有注册到EventLoop上 ) -> ChannelRegistered -> ChannelActive -> ChannelInactive

2.ByteBuf

网络的底层数据传输就是Byte数组,ByteBuf本质就是Byte的一个容器,Netty的ByteBuf重新实现了Java自带的ByteBuffer并提供了更加人性化的API(ByteBuffer的API过于反人类)

ByteBuf具有两个指针:1.ReaderIndex 2.WriterIndex 将数据分成了三个部分, ByteBuf的Read和Write操作会移动对应的读写指针,而Get和Set操作则不会

ByteBuf分为pooled和unpooled两大类,pooled类型基于PooledByteBuffAllocator进行分配,同时采用jemalloc算法进行内存管理,每类ByteBuf又主要有三种模式:1. Heap(backing array),其底层实现是byte数组 2. Direct(堆外内存),其底层实现是ByteBuffer 3. Composite(多个ByteBuf),如果要读取Direct模式,也即堆外内存中的数据,那么需要先通过getBytes方法将数据读取到JVM(会有一次数据拷贝),但是堆外内存在IO时不需要额外的数据拷贝

PooledByteBuf中的Pool是通过Netty声明一大块内存空间(PoolArena)并分配其中一小块给ByteBuf实现的,因此PooledByteBuf哪怕没有变量引用了,但没有显式地retain的话就不会被垃圾回收(这里的垃圾回收不是JVM的垃圾回收而是Netty的垃圾回收——把ByteBuf对应的内存返还给PoolArena),因此Netty的内存泄露检测就通过给对应的ByteBuf一个虚指针和ReferenceQueue并将它记录在一个Set中,当ByteBuf没有变量引用了,对应的虚引用就会被回收到ReferenceQueue中,这时候如果Set中没有对应的ByteBuf则说明它被正常地回收了(Retain回零的时候会将它移除),反之则说明没有被正常地回收(没有Retain回零)也即发生了内存泄露

3.ChannelPipeline、ChannelHandler和ChannelHandlerContext

一个Channel绑定一个ChannelPiepline,一个ChannelPipeline上会有多个ChannelHandler,一个ChannelHandler绑定一个ChannelHandlerContext,ChannelHandlerContext是Handler和Pipeline之间的桥梁,每个ChannelHandlerContext拥有其对应的ChannelHandler所在的ChannelPipeline的引用从而可以将事件传递下去

Netty通过ChannelPipeline将Handler从外部到内部地组合在一起,并通过ChannelHandlerContext激活数据的传递,由于InboundHandler以及OutboundHandler都实现了Handler所以它们都在同一个ChannelPipeline上面,因此在考虑Handler先后顺序的时候可以不考虑具体是Inbound还是Outbound,Handler简单地根据addLast的调用先后从外到内的排列在ChannelPipeline上,越先调用addLast的距离外部越近,数据的流动方向也可以想象成数据从外到内输入并在某个Handler调用输出操作后触底往外输出,其中如果调用的是context.write则会从当前Handler开始向外地调用OutboundHandler的write,如果调用的是channel.write则会从tail开始,从外到内时调用InboundHandler,从内到外时调用OutboundHandler。

Handler与Handler之间的数据的传递需要在上一个Handler内部调用ChannelHandlerContext.fireXXX来激活下一个InboundHandler(同步且从外往内的顺序)其中为了保证执行顺序一般都是在执行完当前Channel的业务后才激活下一个Handler,或者调用ChannelHandlerContext.write/flush等来激活下一个OutboundHandler(同步且从内往外的顺序),另外对于write操作而言,如果在某个OutboundHandler的writ或者flush中没有继续调用ctx.write或者ctx.flush,则对应的数据不会被传输下去,因此也不会输出到Peer端(因为数据被该OutboundHandler吃掉了),数据仅有在调用了ctx.write/flush且Pipeline上在当前Handler之后没有OutboundHandler的情况下才会写入Socket,需要注意的是:fireXXX和write对于Pipeline链上的Handler而言并不是一个异步调用而是同步调用一直到socket.write被调用才会立即返回(socket.write是非阻塞的)

由于Netty是基于NIO的,因此所有的I/O操作基本都会返回Future,对于Futrue可以通过await()或者sync()进行阻塞等待,两者的区别仅在于如果Fail了,后者会将Exception再次抛出,而前者仅仅会记录下来,另外一定不能在I/O线程(各种Handler)中对于Future进行阻塞因为对应的Listener的执行往往是在同一个线程上的,而这会导致死锁,除此之外如果想让在Handler中的Future生效,则需要在OutboundHandler.write中携带Promise并一直传到Pipeline头

数据如果只write而不flush会一直queued在Channel中一直到有操作调用了flush(详细会在源码阅读中介绍)

ChannelHandler可以动态且无需同步控制地修改ChannelPipeline(因为同一个Channel的ChannelHandler都运行在同一个线程上),所以ChannelInitializer继承了ChannelInboundHandlerAdapter并覆写了channelRegistered,在其中调用了抽象的initChannel方法去动态地给ChannelPipeline添加Handler

OutboundChannelHandler的错误处理通过在ChannelFuture上增加监听器来实现

read和channelRead是两个事件,前者是被OutboundHandler处理的,是去请求读取消息的,而后者是被InboundHandler处理的,是代表读取到消息的

4.EventLoop

一个EventLoopGroup拥有多个EventLoop,一个EventLoop绑定一个Thread,一个EventLoop可以注册多个Channel,一个Channel只能绑定在一个EventLoop上,因此发生在同一个Channel上的I/O处理(各种Handler),对应的回调方法以及在Handler中对Pipeline的修改都是在同一个线程上执行的,所以不需要同步干预

EventLoop和线程的一对一绑定的实现如下:当调用EventLoop任何方法时,都会先判断当前线程是不是EventLoop绑定的线程,如果是的话则执行,如果不是的话则把对应方法调用再封装成Runnable任务并放入等待队列(BlockedQueue)中,一直到EventLoop所绑定的线程被唤醒时才执行对应的任务,通过这种实现方式从根本上解决了ChannelHandler的并发的问题同时提高了性能

Netty对于EventLoop的核心使用方法:Reuse EventLoops wherever possible to reduce the cost of thread creation

5.Bootstrap

Bootstrap是Netty的入口,它负责Netty的所有初始化配置,包括:1.EventLoopGroup 2.Channel类型 3.ChannelHandler 4.InetAddress 5.TCP配置

Bootstrap是clonable的,但是其中的EventLoopGroup是shallow copy会被所有Clone的Bootstrap共享

通过调用group.shutdownGracefully(而不是bootstrap.shutdown)可以实现优雅关闭,它会释放所有资源,并且关闭所有的Channel