I/O网络
阻塞与非阻塞:
阻塞:访问IO的线程是否会阻塞(等待)。
同步和异步:
数据的请求方式。
- 同步会等待资源返回的结果。
- 异步通过回调的方式获取返回的结果
BIO
同步阻塞。传统的socket编程,实现模式为一个连接一个线程,客户端有连接请求时服务器就启动一个线程处理,如果这个连接不做任何事情就会造成不必要的线程开销,可以通过线程池改善(实现多个客户连接服务器)。
存在的问题:
- 针对每个请求都需要创建一个线程。
- 并发较大时需要创建大量线程处理,占用资源大
- 连接建立后,如果当前线程暂时没有数据可读,则线程阻塞在read,造成线程资源浪费
NIO
同步非阻塞。实现模式为一个线程处理多个请求(连接),客户端发送的请求都会注册到多路复用器上多路复用器轮询到连接有I/O请求就进行处理。
AIO
异步非阻塞。引入了异步通道的概念,使用Proactor
模式,简化了程序编写,有效的请求才启动线程,特点是现有操作系统完成后再通知服务端程序启动线程去处理,用于连接数较多且连接时间较长的应用。
Proactor
: 消息异步通知的设计模式,Proactor
通知的不是就绪事件,而是完成事件。
场景分析
- BIO适用于连接数小且固定的架构,对服务器资源要求高,并发局限于应用。JDK1.4以前。
- NIO适用于连接数目多且连接比较短的架构,比如聊天服务器,弹幕系统,服务器间通讯。使用较多
- AIO适用于连接数目多且和长连接的架构,比如相册服务器,充分调用OS参与并发操作。
NIO编程
介绍
- 核心部分:Channel通道,buffer缓冲区,selector选择器
- 面向缓冲区编程。数据读取到缓冲区,需要时可在缓冲区中前后移动,增加了处理过程中的灵活性,提供非阻塞式的高伸缩性网络。
- 当一个请求从通道发送请求或者读取数据时:如果有数据就读取,没有数据就去做其他的事情,不会阻塞线程。写操作也是
NIO与BIO比较
- BIO以流的方式处理数据,NIO以缓冲区的方式处理数据。NIO效率更高。
- BIO是阻塞的,NIO是非阻塞的。
BIO是基于字节和字符流操作,NIO基于channel和buffer缓冲区进行操作。
数据总是从通道读取到缓冲区,或者从缓冲区写入到通道,selector用于监听多个通道的事件,因此单线程就可以监听多个客户端通道
流程:
客户端与服务器建立连接,先获取一个通道,通道注册到selector,selector 轮询查看通道的事件(状态),如果客户端向channel的buffer写入了数据,selector监听到了对应事件(例如写事件),则由server端的线程进行操作。如果没有监听到事件则不会让服务端的线程处理。
即:IO多路复用
缓冲区Buffer
Buffer是内存块。Buffer对象就是用来操作内存块的。
介绍:缓冲区本质上是一个可以读写数据的内存块,可以理解为一个数组,Buffer对象提供了可以读写内存块的API,并且可以跟踪记录缓冲区的状态变化。Channel读写数据必须经过buffer。
常见API
包含7个子类(byte,short,int,long,float,double,char)常用子类 ByteBuffer.
ByteBuffer.alloate(长度)
创建byte类型的指定长度的缓冲区。没数据的
ByteBuffer.wrap(byte[] array)
创建一个有内容的byte类型缓冲区。有数据的。
写模式的时候position相当于是当前在那个位置,然后limit理解为length+1
flip()
切换读模式: 将position设置成0就是从头开始读,然后limit设置成原来position的位置 相当于是记录有多少个数据当position=limit就表示读完了。
clear()
切换写模式:将position 设置成0 就是从头开始覆盖写,然后limit设置成最大容量。
Channel
通道可以读也可以写,流一半是单向的,只能读或写,所以需要分别创建一个输入流和输出流。通道可以异步读写,都是基于缓冲区Buffer来读写
常见的实现类有:FileChannel,ServerSocketChannel,SocketChannel
。常用的ServerSocket 和Socket就可以完成客户端服务端的通信编写。
使用
server
- 创建
ServerSocketChannel
- 绑定端口
- 配置成非阻塞模式
configureBlocking(false)
- while true里面accept。如果有
accpet
会返回一个channel
- 如果channel不为空说明有传过来的数据
创建
ByteBuffer
用channel读取 read()返回值: 正数 有效字节数 0 没有读到数据 -1 读到末尾
- 给客户端回写数据write()
- 释放资源
client
- 打开通道
SocketChannel.open()
- 设置ip端口号
- 写出数据 write()
- 读取server写回的数据 read()
- 释放资源
Selector
检测多个注册到服务端的通道上是否有事件发生,然后对每个事件进行相应的处理。用一个线程,处理多个客户端连接和请求。
所以主要作用:监听通道事件,根据不同事件做不同处理。这样只有在通道监听到读写事件才会进行读写操作,不用节省资源。
API
Selector.open
得到一个选择器
Selector.select()
阻塞监听所有注册的通道,当有事件发生,放入到了selectionkey
的集合中。
Selector.slectedKeys
返回事件集合。
isAcceptable
连接继续事件:就是发起连接 ==》ACCEPTisConnectable
连接就绪事件:就是连接成功==》CONNECTisReadable
读就绪事件==》READisWriteable
写就绪事件==》WRITE
事件用完后删除,防止二次处理。
流程
- serverSocketChannel.open打开一个通道
- selector.open创建一个selector
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
服务端注册连接事件- while true判断里面有没有事件
- 如果是isAcceptable,获取到的客户端通道设置成非阻塞然后注册到selector并设置读事件。
- 如果是isReadable,获得客户端通道key.channel 读取数据到缓冲区。
- 回写数据。
- 关闭资源。
Netty
原生NIO存在的bug
- NIO类库和API使用复杂。
- 需要掌握多线程以及reactor模式
- 开发工作了难度大:例如客户端重连断连,半包读写,失败缓存。等
- JDK-NIO有Epoll Bug 导致selector空轮询CPU100%。
Netty是Jboos提供的异步的基于NIO事件驱动的网络应用程序框架,快速开发高性能高可靠性的网络IO程序。简化了NIO的开发过程。
优势:
- 提供阻塞和非阻塞的Socket,可灵活扩展事件,可定制的线程模型
- 具有更高的性能吞吐量,使用零拷贝,节省资源。
- SSL
- 支持多种协议,预置多种编解码功能,支持开发私有协议。
线程模型
- 传统阻塞IO 详情见BIO
Reactor模型
是一种分发的模式(Dispatcher模式)一个或多个输入(也就是请求)同时传递给服务端的模式。服务端程序处理多个请求,同步分发到相应的处理线程。高并发的处理的关键是使用IO复用来监听事件,收到事件后分发给某个线程。
Reactor中 包含一个Reactor由selector和dispatcher组成,selector用于监听请求,dispatch用于分发请求,如果请求是连接请求 会分发给Acceptor 由Acceptor建立连接,IO的读写请求则分发给handler由handler进行读取-处理-响应
优点:模型简单,没有多线程、进程通信、竞争问题。
缺点:
- 性能问题:单线程,Handler在处理连接的业务时,整个进程无法处理其他连接的事情,造成性能瓶颈。
- 可靠性问题:线程意外终止或者死循环,整个系统通信模块不可用,造成节点故障
单Reactor多线程
在单Reactor单线程的基础上有多个handler,此时handler只负责读取和响应数据,并且增加了worker线程池,由worker线程来处理业务。所以多线程实际上是增加了work线程。
优点:充分利用多核CPU的处理能力。
缺点:多线程数据共享和访问比较复杂,reactor要处理所有的事件的监听和响应,而且是单线程运行,在高并发场景容易性能瓶颈
主从Reactor多线程
在单reactor多线程的基础上,reactor升级为主从,主Reactor(主线程)只用于监听连接请求,在由Acceptor建立连接后交给Reactor子线程,子线程会将连接加入到自己的连接队列进行事件监听,然后再分发给handler,再到worker,在实际开发中 子线程是可以扩展的。所以主从多线程,个人觉得扩展的是reactor子线程。
优点:
- 主从reactor,职责明确,主线程只需接受新连接,子线程完成后续业务处理
主从之间数据交互简单,主线程只需要把新连接交给子线程,子线程不需要返回数据。
之前的模式单reactor还要处理数据的响应
- 多个子reactor能够应对更高的并发请求。
缺点:复杂度难度较高,类似的有Nginx Netty 这种模式也叫1+M+N线程模式即使用1个(代指相对较少)连接建立线程+M个IO线程+N个业务处理线程
Netty线程模型
基于Reactor主从做了改进,其实就是在主线程将建立连接后ServerSocketChannel返回的SocketChannel封装成了NioSocketChannel 注册进子线程的selector。
由主从两组线程池组成BossGroup和WorkerGroup,线程池由NioEventLoop线程组成,所以也就是NioEventLoopGroup。NioEventLoop包含了selector和taskqueue
其中Boss主要用轮询监听建立连接,并且将建立连接后的连接注册到worker,然后在执行taskqueue其他tasks
worker也是监听读写事件,处理读写,处理其他task
对于Boss来说
- select:轮询注册ssc的accpet事件
- processSelectedKeys:处理accept事件,与客户端建立连接生成NioSocketChannel并注册到work的selector
- runAllTask:再去以此循环处理队列中的其他任务
对于worker来说:
- select:轮询读写事件
- processSelectedKeys:处理读写事件
- runAllTask:依次循环处理其他任务
在processSelectedKeys
中会使用pipeline管道,管道中引用了channel。也就是说通过pipeline可以获取到对于的channel,并且管道中维护了很多的处理器(过滤、拦截、自定义等)
Server端demo
{
// 1. 创建bossGroup线程组: 处理网络事件--连接事件
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
// 2. 创建workerGroup线程组: 处理网络事件--读写事件2*处理器线程数
EventLoopGroup workerGroup = new NioEventLoopGroup();
// 3. 创建服务端启动助手
ServerBootstrap serverBootstrap = new ServerBootstrap();
// 4. 设置bossGroup线程组和workerGroup线程组
serverBootstrap.group(bossGroup, workerGroup)
// 5. 设置服务端通道实现为NIO
.channel(NioServerSocketChannel.class)
// 6. Boss参数设置.初始化服务端可连接队列
.option(ChannelOption.SO_BACKLOG, 128)
// 6.1 child参数设置。两个服务之间使用心跳来检测对方是否还活着
//https://ihui.ink/post/netty/channel-options/
.childOption(ChannelOption.SO_KEEPALIVE, Boolean.TRUE)
// 7. 创建一个通道初始化对象
.childHandler(new ChannelInitializer<>() {
@Override
protected void initChannel(Channel ch) throws Exception {
// 8. 向pipeline中添加自定义业务处理handler
ch.pipeline().addLast(new NettyServerHandler());
}
});
// 9. 启动服务端并绑定端口,同时将异步改为同步
// ChannelFuture future = serverBootstrap.bind(9999).sync();//同步
ChannelFuture bind = serverBootstrap.bind(9999);//异步
bind.addListener(future -> {
if (future.isSuccess()) {
System.out.println("端口绑定成功");
} else {
System.out.println("端口绑定失败");
}
});
System.out.println("服务器启动成功....");
// 10. 关闭通道(并不是真正意义上的关闭,而是监听通道关闭状态)
// 关闭连接池
bind.channel().closeFuture().sync();
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
TCP粘包拆包
场景
如果发送两个独立的数据包,但服务端一次性接收到了两个数据包则称为粘包;
如果第二个数据包比较大,服务端两次读取到了两个数据包第一次读到了完成的第一个包和第二个包的部分内容,第二次读取到第二个包的剩余部分则称为拆包。
如果两个数据包都跟大,服务端可能会分多次才能将两个数据包接收完全,期间会发生多次拆包。
原因
因为数据的发送和接收方都需要经过操作系统的缓冲区,缓冲区数据堆积,导致多个请求数据粘在一起,拆包则可理解为发送的数据大于缓冲区,进行拆分处理。
解决方案
业内常用
- 消息长度固定,累计读取长度为定长的报文。
- 换行符作为消息结束符
- 特殊分隔符作为消息结束标志,例如回车
- 消息头定义长度字段标识消息总长度
Netty中的解决方案
Netty提供的解码器
- 定长拆包器FixedLengthFrameDecoder。拆分定长的数据包。
- 行拆包器LineBasedFrameDecoder,以换行符为分隔符拆分
- 分隔符拆包
- 数据包长度,此方法要求协议中要包含数据包的长度