深入理解BIO、NIO、AIO
IO模型
IO是计算机的作用组成部分之一,有了IO,计算机与计算机之间,计算机中的资源才能进行交互,计算机与计算机之间发送数据和接受数据都是通过IO进行操作的,所以这里分享的主要是网络IO,下面说的IO都是网络IO,那么计算机与计算机之间发送和接受数据肯定要受限于网络资源的好坏,但是如果说不考虑网络的原因,一个比较好的网络IO模型对于计算机接受数据的性能肯定是质的提升。我们作为java程序员,编写的程序基本上都是在linux下面运行,不会在windows下面运行,所以这篇文章讲解的IO模型都是基于linux,IO模型简单来说就是用什么样的通道来接受和发送数据,现在就java的版本而言,目前支持了3中网络编程的IO模型:BIO,NIO,AIO;BIO简单来说就是最高的一种IO模型,之所以叫BIO,主要体现在Blocking,就是阻塞的意思,就是阻塞的IO,而NIO可以叫New IO也可以叫 Non Blocking IO,就是非阻塞的IO,而NIO的2.0就叫AIO,AIO简单来说就异步非阻塞,而BIO和NIO都是同步的IO,BIO叫做同步阻塞IO,NIO叫做同步非阻塞的IO。关于这三个IO的概念,网络上各大博客都有讲解,这里就不过多的赘述了。
BIO(Blocking IO)
同步阻塞IO,是最早的一种实现方式,其实java中的网络IO模型不是说开发者开发的越早越好,而是要根据操作系统来的,比如你开发再先进的IO,但是操作系统支持不是很好的话也是无用的,比如AIO,BIO在jdk1.4之前是一直使用的一种同步阻塞的IO,可以这样说,在目前的传统的大多数公司,比如我现在所待的公司里面也有很多的交易系统还在使用BIO,可能大多数互联网公司目前都已经采用了NIO,如果说在一个系统中,交易并发根本就不高,很低的情况下,其实BIO也是可以的,BIO是一种同步阻塞的IO,简单来说当服务端开启了一个BIO,那么服务器的ServerSocket就需要accept,监听客户端的连接和发送数据,但是这个时候如果没有客户端过来建立连接,那么这个时候服务端的IO就会一直阻塞住,直到客户端过来建立了连接,但是客户端建立了连接也不一定会马上发数据,那么这个时候服务端接受的Socket连接也还是会一样的阻塞在read上,也会一直阻塞,如果说服务端是一个单线程的io,那么如果说有三个连接过来建立了连接,但是第一个连接迟迟不发送数据,那么后面的两个连接数据是发布进来的,那么这时候这样阻塞下去,肯定会出问题,所以改进版的BIO就是来一个客户端,从线程池中分配一个线程去处理这个socket,但是有个问题就是比如并发比较高的系统,比如一次性客户端发送了10万个连接过来,那么BIO这种模型就没有办法承受,难道你开启10万个线程,肯定是不可能的,那么如果你的线程池配置的是500,那么这个时候就只能一次性接受500个连接,就是并发数是500,后面的连接只能排队阻塞,因为连接池也是使用的阻塞队列来实现的;所以BIO是没有办法处理大量的并发连接的,而且在一些并发稍微高一点的场景,它的劣势就来了,因为线程池不够,已经占用的线程池没有办法及时释放,那么导致的问题就是线程不够用或者浪费了大量的线程;这也就是BIO的一个很大的弊端,比如你开了100个线程池,这个时候有80个socket过来了,那么你肯定要分配80个连接给这80个socke,但是如果说你的这个80个socket在处理业务的时候非常慢或者卡住,那么它们是不会释放连接的,如果后面过来的socket如果连接不够了,那么这个时候就只有阻塞到队列中等待,所以这也是一个很大的问题。
上图是一个线程的IO模型,就是ServerSocket调用accept过后,如果这个时候有客户端连接了,那么也在main线程中直接处理,不开新的线程去处理,当然了,这种方式目前没有任何系统会采用这种模型,我这里只是把它列举出来分析一下,简单来说就是server端开启了一个serversocket过,调用accept进行阻塞,如果这个时候有客户端连接过来,然后在main线程中进行处理,如果这个时候有了第二个,第三个client过来了,如果说第一个client没有发送数据,那么第二个第三个发送数据是没有办法接受的,因为线程已经阻塞在read事件上了,因为是单线程,而且是服务线程,所以这个时候所有过来的连接都得排队等着。
public class BioScoket {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8000);
while (true){
System.out.println("等待客户端连接....");
Socket socket = serverSocket.accept();
System.out.println("有客户端连接过来了");
handlerConn(socket);
}
}
private static void handlerConn(Socket socket) throws IOException {
System.out.println("开始读取客户端发过来的数据");
byte [] data = new byte[1024];
//如果客户端连接了没有发送数据,那么一直会阻塞在这里
int read = socket.getInputStream().read(data);
System.out.println("数据读取完毕");
if(read != -1){
System.out.println("收到客户端数据:"+new String(data,0,read));
}
}
}
上面的程序就是对上图的一个实现,程序中accept和read都会阻塞,因为都在一个线程中,所以如果说有好几个连接过来,而排在前面的连接一直不发数据的话,那么会一直阻塞到read,而其他socket是没有办法发送数据的
这个就是多线程的IO,这种模式会浪费很多的线程,而且比如Client占用了线程,然后由于一些原因一直没有释放这个连接,那么这个连接就会被长时间的占用
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8000);
while (true){
System.out.println("等待客户端连接....");
Socket socket = serverSocket.accept();
System.out.println("有客户端连接过来了");
//handlerConn(socket);
new Thread(() ->{
try {
handlerConn(socket);
} catch (IOException e) {
e.printStackTrace();
}
}).start();
}
}
BIO的缺点
BIO的缺点通过上面的例子就显而易见
IO代码里read操作是阻塞操作,如果连接不做数据读写操作会导致线程阻塞,浪费资源;
如果线程很多,会导致服务器线程太多,压力太大,比如C10K(10k的连接)问题。
应用场景
BIO 方式适用于连接数目比较小且固定的架构, 这种方式对服务器资源要求比较高, 但程序简单易理解。
NIO(Non Blocking IO)
轮训的NIO
同步非阻塞的IO也就是NIO,NIO的设计架构就是客户端只有一个线程来处理,那么客户端过来的所有连接都会被注册到多路复用器上,然后多路复用器轮训到有连接过来的socket就进行处理,它是一种同步非阻塞的IO,这种IO就大大的节约的线程,一个线程就可以处理大批量的socket连接,当然了,这些socket连接也可以交给空闲的线程去处理,可以有很多的方式,比如可以将连接进行分批次,派发的模式去处理,这里就分析了,这里主要NIO的架构分析清楚,NIO是在JDK1.4之后出现的
public class RoudNIOServer {
static List<SocketChannel> channelList = new ArrayList<>();
public static void main(String[] args) throws IOException {
//创建NIO ServerSocketChannel,与BIO的serverSocket类似
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.bind(new InetSocketAddress(8000));
serverSocket.configureBlocking(false);
System.out.println("服务器启动成功...");
while (true) {
// 非阻塞模式accept方法不会阻塞,否则会阻塞
// NIO的非阻塞是由操作系统内部实现的,底层调用了linux内核的accept函数
SocketChannel socketChannel = serverSocket.accept();
if (socketChannel != null) {
//到这里就代表有新的连接过来了,然后加入到就绪列表中
socketChannel.configureBlocking(false);
channelList.add(socketChannel);
}
// 遍历连接进行数据读取
Iterator<SocketChannel> iterator = channelList.iterator();
while (iterator.hasNext()) {
SocketChannel next = iterator.next();
ByteBuffer buffer = ByteBuffer.allocate(1024);
//不会阻塞
int read = next.read(buffer);
if (read > 0) {
System.out.println("接受到客户端的数据:" + new String(buffer.array(),0,read));
} else if (read == -1) {
//客户端连接断开,将socket从列表移除
iterator.remove();
System.out.println("客户端断开连接");
}
}
}
}
}
而且多个客户端可以建立了连接,想什么时候发就什么时候发,因为读取数据不阻塞,接受连接不阻塞,但是就会一直轮训,上面的这个简单的NIO没有使用多路复用器,但是也是一个简单的NIO,从上面可能已经看出了一些问题了,就是每次都需要轮训一次,如果说这个时候就绪列表中有了10万个连接,但是这个时候只有3个连接发了数据过来,那么你还要轮训10万次,每次都去read,看缓冲区是否有数据,如果没有,就继续循环下一个,这样是非常耗性能的,其实从上面都已经看出来了,其实上面的这个NIO就是多路复用器中的select,不是java的aip中的select,是linux中的select,linxu中有select、poll、epoll,其实上面就是select,select的模型在nginx原理有讲了,就是每个socket都维护了一个缓冲区和等待队列,上面的程序和select还是有区别的,select是维护了一个缓冲区和等待队列,程序启动时,将当前的thread放入等待队列,当内核接受到数据拷贝到用户线程也就是socket的缓冲区过后,会将等待队列的线程放入工作队列中,然后向cpu发起中断信号,让用户线程运行;线程运行的时候会去轮训这个列表,所以上面的程序和select有点类似,但是都有一个致命的弱点就是需要将所有的连接都轮训一次,看是否有数据过来,有数据过来就读取数据处理,没有数据过来就空循环。
IO多路复用器
public class EpollNIOServer {
public static void main(String[] args) throws IOException {
//打开一个serversocket
ServerSocketChannel socketChannel = ServerSocketChannel.open();
//绑定一个端口
socketChannel.bind(new InetSocketAddress(8000));
//设置为非阻塞
socketChannel.configureBlocking(false);
//打开Selector处理Channel,即创建epoll
Selector selector = Selector.open();
//将服务端的serverSocket注册到多路复用器selecctor上
socketChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("服务启动成功...");
while (true) {
//阻塞,直到有新的事件(比如连接,读、写事件)
selector.select();
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey next = iterator.next();
if (next.isAcceptable()) {
//连接事件
ServerSocketChannel ssc = (ServerSocketChannel) next.channel();
//接受一个连接SocketChannel
SocketChannel accept = ssc.accept();
accept.configureBlocking(false);
//这里只注册了读事件,如果需要给客户端发送数据可以注册写事件
accept.register(selector, SelectionKey.OP_READ);
}
if (next.isReadable()) {
//读取事件
SocketChannel sc = (SocketChannel) next.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
int read = sc.read(buffer);
if (read > 0) {
System.out.println("读取客户端发过来的数据:" + new String(buffer.array(), 0, read));
} else if (read == -1) {
System.out.println("客户端关闭了连接");
sc.close();
}
}
//从事件集合里删除本次处理的key,防止下次select重复处理
iterator.remove();
}
}
}
}
NIO 有三大核心组件: Channel(通道), Buffer(缓冲区),Selector(多路复用器)
1、channel 类似于流,每个 channel 对应一个 buffer缓冲区,buffer 底层就是个数组
2、channel 会注册到 selector 上,由 selector 根据 channel 读写事件的发生将其交由某个空闲的线程处理
3、NIO 的 Buffer 和 channel 都是既可以读也可以写
上面的程序中和之前的NIO的程序有两个非常重要的地方不同
Selector selector = Selector.open();
socketChannel.register(selector, SelectionKey.OP_ACCEPT);
selector.select();
这三行代码就是IO多路复用器的最重要的地方,java的io多路复用器就利用到了linux的EPOLL,在jdk1.4之前,selector用到的是select、poll,在jdk1.4以后,使用就是epoll了;而select和poll都有一个通病就是它对于socket fd列表中的连接事件只能挨着循环去判断是否有数据过来了,如果有数据过来了,就取出数据执行socket,而epoll有了事件机制,也就是回调函数,如果select()过后返回的就是需要处理的数据,也就是说如果有10万个连接建立了,这个时候如果只有三个连接发了数据过来,那么select poll需要循环着10万个连接,取出有数据的三个连接去处理,而epoll就不需要,只需要取出有数据的三个连接进行处理就可以了,所以epoll在性能上是最优的,而netty也就时采用了NIO中的epoll
,那么它是如何做到的呢?
select/poll的流程图大概如上图,启动serverSocket过后,开启多路复用器,将seversocket也注册到这个多路复用器上,然后开启serversocket的select进行阻塞,所有客户端发过来的请求都会被select所监听到,这个时候接触阻塞,然后开始处理事件。
- 调用java api创建socketchannel;
- 调用select进行阻塞,这个时候当前线程都会被放入到每个连接过来socket的等待队列中;
- 当某个socket有数据过来,就是说select阻塞的所有的socket中只要有一个socket有数据过来了(网卡接受数据,内核拷贝数据到用户空间,内核向CPU发出中断信号),然后将每个socket中的等待队列中的线程移除,然后放入到工作队列中,然后cpu开始调度工作队列的线程;
- 然后 select解除阻塞,那么开始循环fds数组(select是一个数组,而poll是一个链表)/链表,然后判断那些socket的缓冲区有数据过来了,对有数据过来的socket进行处理。
- 所有select/poll会面临三次循环,它的缺点就是我根本不知道哪个socket有数据过来了,我需要将整个fds迭代一次,才知道哪个socket有数据 ,才会去处理,为什么说是三次循环,第一次是启动select阻塞的时候会将当前的thread循环依次放入到每个socket中的等待队列中,因为要阻塞,线程就被放入socket的等待队列;第二次循环是接触阻塞,就是网卡接受了数据过后,需要将Thread从阻塞队列移除,然后放入工作队列,这个时候只要有一个socket来了数据都需要做这个事情,将所有的socket等待队列的Thead移除,放入工作队列;第三次循环是要拿到fds数组或者链表,然后循环,对有数据的socket进行处理。所有select/poll的缺点就在这里,它的流程和epoll很像,但是epoll采用了事件通知机制,就避免了这种多次循环而带来了性能开销。
epoll和select的流程大体上一致,只是epoll创建(epoll_ctl)的时候会绑定一个回调函数,也就是利用事件通知机制来告诉socket,只要来了数据就会通知,这样就我们的线程就知道哪些连接,或者连接中的哪些socket发数据过来了,就不需要要循环列表去判断,这就是epoll和select poll的区别。
NIO整个调用流程就是Java调用了操作系统的内核函数来创建Socket,获取到Socket的文件描述符,再创建一个Selector对象,对应操作系统的Epoll描述符,将获取到的Socket连接的文件描述符的事件绑定到Selector对应的Epoll文件描述符上,进行事件的异步通知,这样就实现了使用一条线程,并且不需要太多的无效的遍历,将事件处理交给了操作系统内核(操作系统中断程序实现),大大提高了效率。在打开一个多路复用器的时候,也就是selector.open的时候会创建一个多路复用器,其实就是调用的linux内核的epoll_create,然后创建了eventpoll对象和一个就绪列表rdlist,当seversocket绑定端口过后,select阻塞的时候有客户端连接过来的时候,会将连接注册到fds中,是一个红黑树,这个红黑树绑定了socketChannel的fd(socket文件描述符)和这个fd的回调函数,当有数据过来或者新建连接的时候会调用这个回调函数,然后将这个socket的信息(连接、读、写)放入到就绪列表(双向链表),然后这个时候select就接触阻塞, 获取了rdlist的数据,然后进行处理;在epoll中有三个比较重要的函数epoll_create,epoll_ctl,epoll_wait,在nginx原理中都已经分析了这三个函数,这里再来看下
EPOLL_CREATE
int epoll_create(int size);
创建一个epoll实例,并返回一个非负数作为文件描述符,用于对epoll接口的所有后续调用。参数size代表可能会容纳size个描述符,但size不是一个最大值,只是提示操作系统它的数量级,现在这个参数基本上已经弃用了,其实epoll_create会创建两个数据结构出来,一个是eventpoll,一个是一个就绪列表,而epoll_ctl操作的就是就是eventpoll对象,对红黑树中的fd进行添加、删除、修改。
EPOLL_CTL
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
使用文件描述符epfd引用的epoll实例,对目标文件描述符fd执行op操作。
参数epfd表示epoll对应的文件描述符,参数fd表示socket对应的文件描述符。
参数op有以下几个值:
EPOLL_CTL_ADD:注册新的fd到epfd中,并关联事件event;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL:从epfd中移除fd,并且忽略掉绑定的event,这时event可以为null;
参数event是一个结构体
struct epoll_event {
2 __uint32_t events; /* Epoll events */
3 epoll_data_t data; /* User data variable */
4 };
5 6
typedef union epoll_data {
7 void *ptr;
8 int fd;
9 __uint32_t u32;
10 __uint64_t u64;
11 } epoll_data_t;
events有很多可选值,这里只举例最常见的几个:
EPOLLIN :表示对应的文件描述符是可读的;
EPOLLOUT:表示对应的文件描述符是可写的;
EPOLLERR:表示对应的文件描述符发生了错误;
成功则返回0,失败返回-1
EPOLL_WAIT
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
等待文件描述符epfd上的事件。epfd是Epoll对应的文件描述符,events表示调用者所有可用事件的集合,maxevents表示最多等到多少个事件就返回,timeout是超时时间
I/O多路复用底层主要用的Linux 内核函数(select,poll,epoll)来实现,windows不支持epoll实现,windows底层是基于winsock2的select函数实现的(不开源)
EPOLL源码流程分析
上图将NIO的epoll的创建,操作,等待过程做了一个源码跟踪分析,其实最终都是调用linux底层的那三个函数,epoll_create,epoll_ctl,epoll_wait来做不同的事情
selector.open()调用了epoll_create;
select()调用了epoll_ctl和epoll_wait。
AIO(NIO 2.0)
AIO是异步非阻塞的,异步非阻塞的意思就是异步响应,不阻塞,类似于Reactor模型,spring webflux也是这种模型,这种IO模型适用于由操作系统完成后回调通知服务端程序启动线程去处理, 一般适用于连接数较多且连接时间较长的应用;AIO方式适用于连接数目多且连接比较长(重操作)的架构,JDK7 开始支持
public class AIOServer {
public static void main(String[] args) throws IOException {
final AsynchronousServerSocketChannel serverChannel = AsynchronousServerSocketChannel.open();
serverChannel.bind(new InetSocketAddress(8000));
serverChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Object>() {
@Override
public void completed(AsynchronousSocketChannel socketChannel, Object attachment) {
//再此接收客户端连接,如果不写这行代码后面的客户端连接连不上服务端
serverChannel.accept(attachment, this);
ByteBuffer buffer = ByteBuffer.allocate(1024);
//异步读取数据
socketChannel.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer result, ByteBuffer attachment) {
buffer.flip();
System.out.println(new String(buffer.array(), 0, result));
socketChannel.write(ByteBuffer.wrap("HelloClient".getBytes()));
}
@Override
public void failed(Throwable exc, ByteBuffer attachment) {
}
});
}
@Override
public void failed(Throwable exc, Object attachment) {
}
});
System.in.read();
}
}
public class AIOClient {
public static void main(String... args) throws Exception {
AsynchronousSocketChannel socketChannel = AsynchronousSocketChannel.open();
socketChannel.connect(new InetSocketAddress("127.0.0.1", 9000)).get();
socketChannel.write(ByteBuffer.wrap("HelloServer".getBytes()));
ByteBuffer buffer = ByteBuffer.allocate(512);
Integer len = socketChannel.read(buffer).get();
if (len != ‐1) {
System.out.println("客户端收到信息:" + new String(buffer.array(), 0, len));
}
}
}
其他不多说,看上图代码就可以知道AIO的体现主要异步,那么异步主要体现在发送一个交易可以通过异步通知,异步的意思就是发送完了不需要等待结果的意思,而AIO是如何做到的,就是通过异步响应的机制来实现的,比如上面的accept方法,就是有了连接过来才会进入回调的方法completed,所以有了连接直接进异步的回调方法,包括读取数据一样的,有数据发送过来才会执行socketChannel.read的时候只有发送了数据才会进入这个异步的读取数据的方法。所以异步非阻塞就是这个道理。
BIO、 NIO、 AIO 对比
为什么Netty使用NIO而不是AIO?在Linux系统上,AIO的底层实现仍使用Epoll,没有很好实现AIO,因此在性能上没有明显的优势,而且被JDK封装了一层不容易深度优
化,Linux上AIO还不够成熟。Netty是异步非阻塞框架,Netty在NIO上做了很多异步的封装。
同步异步与阻塞非阻塞(段子)
老张爱喝茶,废话不说,煮开水。
出场人物:老张,水壶两把(普通水壶,简称水壶;会响的水壶,简称响水壶)。
1 老张把水壶放到火上,立等水开。(同步阻塞)
老张觉得自己有点傻
2 老张把水壶放到火上,去客厅看电视,时不时去厨房看看水开没有。(同步非阻塞)
老张还是觉得自己有点傻,于是变高端了,买了把会响笛的那种水壶。水开之后,能大声发出嘀~~~~的噪音。
3 老张把响水壶放到火上,立等水开。(异步阻塞)
老张觉得这样傻等意义不大
4 老张把响水壶放到火上,去客厅看电视,水壶响之前不再去看它了,响了再去拿壶。(异步非阻塞)
老张觉得自己聪明了。
所谓同步异步,只是对于水壶而言。
普通水壶,同步;响水壶,异步。
虽然都能干活,但响水壶可以在自己完工之后,提示老张水开了。这是普通水壶所不能及的。
同步只能让调用者去轮询自己(情况2中),造成老张效率的低下。
所谓阻塞非阻塞,仅仅对于老张而言。
立等的老张,阻塞;看电视的老张,非阻塞。