Netty笔记
https://www.bilibili.com/video/BV1py4y1E7oA
Java NIO
三大组件
Channel
两个进程共享的部分,是一个双向通道
常见的channel:
-
FileChannel:只能工作在阻塞模式下
-
DatagramChannel:用于UDP
-
SocketChannel:用于TCP客户端
-
ServerSocketChannel:用于TCP服务端
SocketChannel在进行网络通信时,会调用两个阻塞方法:
-
ServerSocketChannel的accept方法,用于获取新的socket连接,如果没有连接进来则会发生阻塞,它会返回一个SocketChannel对象(认为是一个socket对象,可以将其放入一个list中,后续循环调用其read方法读取数据)
但是,ServerSocketChannel可以手动设置为非阻塞模式,如此一来accept如果没有获取到连接则立即返回null -
SocketChannel的read(buffer)方法,从channel中读取数据到buffer中,如果channel中没有数据则会发生阻塞
同上,SocketChannel也可以手动设置为非阻塞模式,如果read没有获取到数据,则返回0(正常返回读取到了多少字节数据)
Buffer
向channel读写的数据可以放在buffer中,常用的是 ByteBuffer
channel就像一个水井,buffer就像是桶,读写数据时就可以通过桶来一桶一桶地读写
ByteBuffer本质是一个byte数组,它有三个属性:
-
position:当前读写指针位置,默认为0
-
limit:当前最大读取位置
-
capacity:buffer容量
buffer有读和写两种模式,两种模式必须手动切换才可使用。切换方法为 buffer.flip()
当buffer为写模式时,limit指针不起作用,数据会从position位置一直写到capacity位置
当切换到读模式时,会将limit指针指向position位置,position指针移动到0位置,读取时,从position位置开始到limit结束,position会跟着移动
Selector
下图中,每个channel都可以看作是一个网络连接,每个网络连接可能有多个状态,例如发起连接、准备发送数据、关闭连接等,这些动作就需要线程来处理。
selector的作用就是监听这些channel的动作,如果某channel有什么事件就会告知selector,selector就会从线程池中找到一个线程来处理该事件请求,该过程称为多路复用,这种思想称为事件驱动
IO模型
阻塞/非阻塞
用户程序调用read方法发起了一次读取操作(读取磁盘或网卡),会调用内核空间的读取程序(用户态到内核态的切换),而内核态读取数据分为两个阶段:
-
等待数据:网卡中还没有数据,需要等待客户端传输,一般阻塞耗时主要在这
-
复制数据:将网卡中的数据复制到内存(socket缓冲区)中,相对来说很快
然后数据再由内核空间复制到用户空间
阻塞和非阻塞的区别在于:
-
阻塞:用户程序的read方法会一直阻塞知道数据复制回用户空间
-
非阻塞:在内核态等待数据的阶段,用户程序会不断调用read方法进行查询网卡是否有数据,在复制数据阶段仍然处于阻塞状态
多路复用
用户程序调用selector的select方法,该方法会阻塞在内核态的等待数据过程,一旦有数据发送过来,selector就会告知用户程序,再由用户程序发起read方法读取数据,此时就可以直接复制网卡中的数据了,这一过程也是阻塞的,但是复制数据的耗时一般远远小于等待数据的耗时
异步io
selector接收到读写事件后,如果是直接在当前线程完成读写操作就是同步io,如果是使用其他线程完成读写就是异步io
零拷贝
将一个文件内容发生到网络需要执行4次文件复制,读取网络数据同理
java中NIO的channel可以调用transferTo/transferFrom两个方法实现优化,它们在操作系统层面会调用内核的sendFile的方法,该方法可以将内核缓冲区和用户缓冲区统一(或者说不需要用户缓冲区),直接将文件从内核缓冲区复制到socket缓冲区,减少了两次文件的拷贝
到linux2.4版本后,sendFile方法实现了进一步优化,连socket缓冲区都不需要了,直接从内核缓冲区复制到网卡中。
上述这两种方法都是零拷贝,所谓零拷贝,其实说的是在用户态的零拷贝
阻塞/非阻塞 具体实现
以下为阻塞/非阻塞伪代码:
# 单线程实现非阻塞网络io
ServerSocketChannel ssc = new ServerSocketChannel(ip, port)
ssc.configureBlocking(false) # 配置为阻塞/非阻塞模式
List channels = new ArrayList()
while true:
SocketChannel sc = ssc.accept()
sc.configureBlocking(false)
# 这里也可以使用多线程处理,让每一个channel都单独创建一个线程处理
if sc != null:
channels.append(sc)
for channel in channels:
int readCount = channel.read(buffer)
if readCount > 0:
print('data: ' + buffer.data)
上述代码虽然可以使用单线程或多线程实现并发请求处理,但仍存在一个缺陷,如果长时间没有连接请求,而整个代码仍然会不停执行while true循环代码,对cpu来说是一种浪费,一个好的解决方法是如果没有请求或数据写入,则线程阻塞,如果有请求或数据写入则自动唤醒线程,由此可以使用selector
selector可以注册四种事件:
-
accept:有新的连接请求时触发
-
connect:客户端连接建立后触发
-
read:有数据可读时触发
-
write:可写时触发
创建了SockerChannel或ServerSocketChannel对象后,就可以将他们注册进selector对象中,然后调用selector的一个阻塞方法select(),该方法在接受到上述四个事件中任意一个或多个事件后就会将这些事件放入一个set集合中,并唤醒线程,后续就能单独对该set集合中的事件进行处理了,以下为伪代码:
Selector selector = Selector.open()
ServerSocketChannel ssc = new ServerSocketChannel()
ssc.configureBlocking(false) # selector注册的channel必须是非阻塞的
# 向selector中注册channel(其实就是向selector内部的一个集合中创建一个SelectionKey对象),并告诉selector这个channel需要响应哪些事件
ssc.register(selector, SelectionKey.OP_ACCEPT)
while true:
# 阻塞方法,直到上述四个事件任意一个或多个发生
selector.select()
# 所有发生是事件都会放到这个集合中,处理完集合中的事件后应手动从set中删除该事件,否则会报错
for k in selector.selectedKeys():
SelectableChannel channel = k.channel()
if k.isAcceptable():
SocketChannel sc = ((ServerSocketChannel)channel).accept()
# 将新创建的channel注册到selector上
sc.configureBlocking(false)
SelectionKey sk = sc.register(selector, selectionKey.OP_READ)
selector.selectedKeys().remove(k) # 一定要手动移除
if k.isReadable():
# 读取channel中的数据
int readCount = channel.read(buffer)
if readCount == -1:
# -1表示读取完毕,所以该channel任务就完成了,无用了,如果一个SocketChannel处理完成或出现异常,必须要手动向selector注销
k.cancel()
...
注:SocketChannel向Selector注册事件必须先于Selector的select方法。否则selector收到注册的事件并不会放行select。如果出现这种情况,可以先使用wakeup方法放行一次select只会就好
AIO
即异步IO,核心是回调函数
AIO的channel调用read方法后会立即返回,read函数接收一个回调函数作为参数,如果内核读取到数据后就会使用其他线程调用该回调函数
Netty
基于NIO,为什么不直接使用NIO API?
NIO只是对IO操作的封装,并不是对应用层协议的封装,其本质上仍然是直接操作字节流,这和直接操作socket复杂度是一样的,其实际上仍然是运输层编程,而Netty实际上是应用层软件,它已经封装好了HTTP、websocket等协议,因此直接用netty开发网络程序要快得多,不用再考虑tcp连接的一些问题例如粘包拆包等。并且java原生NIO对linux下的epoll存在bug,本来selector.select()在接收事件前是阻塞的,但linux下可能不会阻塞出现空轮询。最后就是netty对很多原生NIO api做了增强,更强大易用
Netty和NIO的不同?
异步是Netty的核心,而netty的异步和单纯的BIO及NIO有一些区别:
BIO也会用多线程来优化并发量,来一个连接创建一个线程处理该连接
NIO则是以事件驱动,触发一个事件就调用一个线程来处理
而Netty则不同于这两者,一个网络请求实际上分为多步(NIO的思想来源,每一步都是一个事件),在NIO中,这些步骤实际上是无差别的,例如不管是连接创建还是事件发送,都可以由同一个selector响应,然后在线程池中随便找一个线程进行处理,但实际上,这些任务是有一定顺序关系的,Netty的核心思想就是,将这些任务分类,每一类别都创建一个单独的线程池进行处理,例如:创建连接线程池处理完创建连接请求后,交给接受数据线程池并立即返回,接受数据线程池接收完数据后将其交给关闭连接线程池(如果程序是接收完数据后就关闭)进行处理,可以看到每一步都是异步的并且有专门的线程池进行处理。这么做的目的是可以增大系统的吞吐量,因为对于单个小任务的处理肯定比但个线程处理完所有的事件(BIO)要快,对比NIO,如果当前线程池中所有的线程都处于忙碌状态,此时有新的连接请求建立,则该连接就会阻塞,而Netty由于有专门负责创建连接的线程池,则它不管后面那些读写线程是否忙碌,我先把连接建立了再说,这样就增大了系统的吞吐量
netty基本使用:
注:
-
group() 方法可以接收一个或两个 EventLoopGroup 对象,如果只传了一个,则表示该EventLoopGroup处理所有四种事件,如果传入两个,则第一个只响应accept事件(boss),其他事件由第二个响应(worker)
-
channel()方法用于指定boss EventLoopGroup类型
-
childHandler()方法用于指定当有新的连接请求时如何处理
什么是EventLoop?
-
Netty中的 EventLoop 类可以直接理解为一个线程,每个EventLoop 都维护一个任务队列,可以向任务队列中添加任务。
同时,EventLoop中也会封装一个selector
故,每个EventLoop就是一个可以响应一类selector事件的处理器 -
EventLoopGroup 就是一个线程池,实际上它确实继承了java的线程池对象,对线程池的任何操作都可以对它操作,例如提交任务等
-
NioEventLoopGroup也就是可以处理NIO事件的线程池。它内部会维护一个selector
-
每个channel(相当于一个连接,可以等同任务是NIO中的Channel)都会与NioEventGroup中的一个EventLoop相互绑定,当该channel中有新的读写事件发生时,总是由同一个EventLoop进行处理,这样做是为了防止并发带来的问题。
jdk中的Future vs Netty中的Future vs Netty中的Promise?
Netty的Future是对jdk中Future的一个增强,它实际上是继承关系,例如jdk的future的isDone用于判断子线程任务是否执行结束,但无法判断是正常结束还是异常结束,但netty的future就提供了isSuccess()方法判断是否为正常结束
Promise实际上继承自Netty的Future,但它本身并不执行异步任务,它只是作为子线程和主线程的一个通信官,例如可以在主线程创建一个Promise对象,并开启一个子线程,然后在子线程中调用promise的setSuccess方法,主线程也可以调用promise的get方法获取子线程的结果,实际上,就可以看成是一个子线程和主线程直接通道的管道,只不过这个管道提供了更多更方便的方法。
Netty中的Pipline和Handler?
channel会有各种连接或读写事件,而不同的事件就会调用不同的Pipline进行处理。Netty主要的编码工作就是这些Pipline
一个Pipline就好比一条流水线,而真正处理消息请求的是流水线上的各种机器设备,即Handler,一个Pipline可以添加多个Handler,Pipline编码的主要工作就是写这些Handler
Handler又分为InBound和OutBound Handler,其中InBound是处理读取数据的处理器,数据被Netty接收后,对接收的数据如何进行处理就是它的任务,例如处理TCP粘包的问题,OutBound则是处理写的任务,我要向网卡发生数据,这个数据在发生前可能经过各种各样的修改,怎么改,这是它的任务。这两者可以放在同一个Pipline中,例如我需要在接收数据后返回数据,则可以先在pipeline中先添加inBoundhandler再添加outboundhandler。
注:Handler的执行顺序较为复杂,例如添加的OutBoundHandler执行顺序是倒过来的
NIO的ByteBuffer vs Netty的ByteBuf?
ByteBuffer是固定长度的数组,ByteBuf则可变长度,两者都可以选择使用堆内存或直接内存,ByteBuf默认使用直接内存
ByteBuf引入了池化功能,类比线程池,否则每次创建一个channel都需要创建一个ByteBuf
ByteBuf使用引用计数来控制其内存回收,这是因为ByteBuf有多种实现方式,例如堆内存的实现底层需要jvm的gc做垃圾收集,直接内存使用其他方式,而Buf池也需要其他方法处理垃圾回收,所以Netty对ByteBuf提供了一个统一的release方法(引用计数减一)来管理ByteBuf。
如何解决粘包?
-
使用短链接:粘包本质上是将多个独立数据放在一起发送了,所以简单的解决方法就是将多个数据单独建立channel连接发送
-
规定最长发送消息
-
使用分隔符
-
规定每个消息前多少字节表示消息长度
Netty如何解决空轮询bug?
jdk NIO中的selector在linux下,可能出现bug,它的select()可能不会阻塞,从而导致空轮询,并迅速占满cpu
netty的解决方法是使用一个计数器,每次select()执行后(正常情况下如果没有事件发生会在这里发生阻塞,异常情况就不会阻塞而直接向下执行),该计数器加一,当计数器达到一个设定的阈值(默认512)后,就会重新创建一个selector替换掉原来那个(因为出问题的是selector),并将原来selector中的一些变量复制进新的selector
源码分析:https://www.bilibili.com/video/BV1py4y1E7oA?p=141&vd_source=78951f3f7dcd752bebcfd9734a584537