@Catyee
2021-08-03T12:34:52.000000Z
字数 6715
阅读 436
面试
IO即输入输出,包含了很多种类,最简单的是文件IO,对于文件IO无论哪种IO模型其实都是阻塞的IO,因为文件是时时刻刻处于可读或可写状态的,所以在读完或写完之前都是阻塞的。另一种IO是网络IO,'阻塞'、'同步'等概念都是针对的网络IO。网络编程的基本模型是C/S模型,即两个进程间的通信,服务端提供IP和监听端口,客户端通过连接操作向服务端监听的地址发起连接请求,通过三次握手连接,如果连接成功建立,双方就可以通过套接字进行通信。
一个完整的网络通信请求,就是客户端发送消息,服务端读取消息,服务端响应消息给客户端,客户端读取响应消息。但是消息并不是从客户端应用程序直接传输到服务端应用程序,而是先通过网络,到达服务端应用程序所在机器的网卡,服务端应用程序是无法直接读取网卡这样硬件上的数据的,必须由操作系统内核来完成,操作系统将数据读取到内核空间,然后传输到用户空间,然后应用程序才能读取到数据。所以网络IO的本质其实是操作系统的IO,操作系统有不同的IO模型,上层应用给用户使用的io模型其实也都是封装的操作系统的IO模型(java中IO模型最终都会调用native方法,实际上就是调用操作系统的方法)
网络IO中阻塞和同步是针对请求者来说的,一个请求过来了,在同一个请求中它能获得结果得到响应,就是同步的;如果一个请求过来了,服务器说我接受到你的请求了,但是我要处理一会儿,你过会儿再来看我处理好了没(轮询)或者处理好了我通知你(回调),然后这个请求断开了,之后要么客户端轮询服务器,要么服务器通知客户端,总之不在一个请求中,这就是异步。
阻塞和同步是有区别的,阻塞也是针对请求着来说的,一个请求过来了,在没获取到结果没得到响应之前,它无法做其它事情,它就是阻塞的;如果在它能做其它事情,它就是非阻塞的。
从这两个维度来说,有阻塞同步、非阻塞同步、非阻塞异步(简称异步)三种,但是没有"阻塞异步"的情况,因为既然异步了,就不会阻塞了。
可以看到这里的阻塞、同步的概念与线程中阻塞和同步的概念是有区别的。线程中阻塞和同步是同一个概念,同步意味着阻塞,异步意味着非阻塞,没有"同步非阻塞"的情况。
BIO即阻塞同步的IO。BIO模型有四个重要的概念:ServerSocket、Socket、InputStream、OutputStream。
ServerSocket负责绑定IP和监听端口,也就是服务器;Socket即客户端,它代表了客户端的一次请求;当服务端和客户端建立连接之后,通过InputStream和OutputStream进行读写操作。
一次请求的过程即建立连接、读取客户端发来的数据、向客户端写入响应的数据,在BIO中这三个过程有两个阻塞(这里的阻塞是指线程的阻塞):
// accept()方法用于接收客户端连接,它会阻塞住,直到有客户端发起连接
// 一旦接收到连接,就可以获取到这个客户端请求,然后通过这个客户端请求进行读写操作
Socket socket = serverSocket.accept();
// read()方法用来读取客户端的数据,如果没有数据它就会一直阻塞
socket.getInputStream().read();
当服务器处理完请求之后,要响应客户端,还要用socket.getOutputStream().writer()方法去写响应数据。
这里关键点是ServerSocket的accept()方法只有一个返回,这意味着ServerSocket每次只能接收一个请求,即使同一时刻有非常多的客户端同时发送了请求,也只能接收一个,这导致了单线程下只能处理完当前请求才能处理下一个请求,这意味着一个请求没有结束之前,另一个请求是没法得到响应的。如果每个请求的处理时间都非常短,这种情况还好一点,因为每个请求也都能很快得到响应,但是偏偏我们不知道建立连接之后客户端什么时候会发送数据,而服务端读取数据的操作还是阻塞的,这样可能导致当前等待的请求长时间得不到响应。
上述问题总结一下其实是两个问题:
为了应对这种情况,我们就要从上面分析的两个问题着手,对于问题1我们修改不了BIO的模型,没法让ServerSocket的accept()方法一次返回多个请求;所以我们只能在缩短处理请求的时间上做文章,那怎么缩短呢?用同一个线程去处理读写不管有多快总会占据一点点时间,那用另一个线程异步处理读写不就可以了?比如一个线程用来接收客户端的请求,每接收到一个客户端的请求,立刻启动另外一个线程去读取请求中的数据,并向客户端写响应数据,这样就可以同时处理多个请求了,不会再有当前请求阻塞了后面所有请求。
但是线程资源也挺宝贵的,如果请求数量巨大,总不能每一个请求都新创建一个线程吧,这个时候就可以用线程池,还是用一个线程去接收客户端的请求,接收到之后扔进线程池,让线程池中的线程去读取请求中的数据,并向客户端写响应数据,这就是以前伪异步IO的做法。
但即使如此也依然有缺陷,比如一个线程池有200个线程,但是请求有一万个,就会有很多请求陷入排队,如果队列满了,就会被拒绝。可以看到即使伪异步IO也无法应对海量请求的情况,其根本原因还在于BIO的IO模型是阻塞同步的,ServerSocket.accept()一次只能接收到一个请求,这导致了单线程每次只能处理一个请求,要想不阻塞其它请求就必须用多线程。总而言之,一个请求要对应一个线程,从这个角度来看,如果一个服务端是基于BIO开发的,那它其实是线程驱动的,请求和线程的比例是1:1。
NIO即同步非阻塞IO,采用了多路复用的IO模型。NIO模型有三个重要的概念:Channel、Selector、Buffer。
Channel即通道, NIO中用Channel来传输数据,对应了BIO中的InputStream和OutputStream,由于计算机底层通信大多都是全双工的,这里Channel也设计成了全双工的,也就是说通过Channel既可以发送数据也可以接收数据,这是和Inputstream、OutputStream不一样的地方;ServerSocketChannel和SocketChannel对应了BIO中的ServerSocket和Socket,即服务端和客户端。
Selector即多路复用器,多路复用器是NIO**同步非阻塞**的关键,从字面意思来看,NIO也是同步的,关键在于非阻塞,回到BIO中,我们知道BIO效率不高主要有两个点:
而要提高IO的效率也就要从这两个点着手,关键就在于一次能够接收多个请求并建立连接,建立连接之后还要能够知道每个连接什么时候发送数据过来,什么时候发送数据出去,而这就是Selector多路复用器的作用,Selector可以监听发起请求(OP_CONNECT)、接收请求(OP_ACCEPT)、读取数据(OP_READ)和写入数据(OP_WRITE)这四种类型的事件(注意:监听是阻塞的,但是可以设置超时时间,也可以在有事件发生的时候手动唤醒。)最关键的在于Selector可以监听不同channal上所有注册的事件,如下:
/*
select方法用于监听事件,它会一直阻塞到有事件过来,但是也可以调用wekeup()方法手动唤醒,select方法返回的是一个int值,它代表了当前监听到了几个活跃事件,如果大于0,就可以调用selectedKeys()返回监听到的事件,注意是返回的是一个set,表示可以是多个事件,假如监听到的是多个accept事件(接收请求),意味有多个请求过来了,这个时候可以和每一个请求都建立连接,而不需要一个请求处理完了再和下一个请求建立连接。
*/
public abstract int select() throws IOException;
public abstract Set<SelectionKey> selectedKeys();
不同通道可以代表不同服务(绑定了不同的端口),每一个通道又可能会有许多客户端发送请求,每一个请求也可能有多次的读写事件,但不管情况有多复杂都只需要一个Selector就可以了,这意味着使用单线程就可以同时处理所有请求,而在处理每个请求的读写事件的时候都需要用到NIO的Buffer,NIO的Buffer的读写是不会阻塞的,所以即使使用单线程处理所有请求也不会阻塞。
举个例子,先在一个Selector上注册了多个通道的accept事件,然后多个客户端同时发出请求,这些请求都会被监听到,都可以建立连接;建立连接之后再注册每个通道的读事件,假如A和B同时发送了数据,但是A出现了网络延迟,Selector先监听到B请求的读事件,这个时候就就会先处理B请求的读事件,等到A请求的读事件到了再处理A请求的读事件,这样即使A请求先建立连接也不会阻塞B请求。这在原来的BIO中是做不到的,BIO中如果用单线程,只能先和一个请求建立连接,处理完了这个一个请求,然后再和另一个请求建立连接,再处理另外一个请求,如果A请求先建立连接,在请求没有结束之前,会一直阻塞B请求。
Selector的底层根据不同的操作系统有不同的实现,Windows操作系统下是依赖select函数,linux操作系统下是依赖于epoll函数,当然还要看操作系统的版本,不同版本可能还有差异。总而言之,其作用就是监听多个通道的活跃事件。
Buffer即缓冲区,在BIO中是可以不使用Buffer的,我们可以直接从InputStream中读取数据,或者直接往OutputStream中写数据。但是NIO中我们没法直接操作Channel,所有的Channel都要绑定一个Buffer,每次需要写数据只能先写入到buffer中去,每次需要读数据也只能先读入到buffer中来。引入Buffer,最关键的点是BIO中流的读写都是阻塞的,但是NIO中对Buffer的读写都不是阻塞的。
另外,也可以增加操作的灵活性,比如在流中数据都是一次性的,消费完就结束了,不能二次读取,但是Buffer可以;再比如在流中我们没法用一个指针前后移动,然后读取一部分数据,但是Buffer也可以。还有一个比较重要的点就是NIO的Buffer可以分配直接内存(堆外内存),堆外内存也是实现零拷贝的一种途径,某些情况下可以提升效率,这也是之前的BIO无法做到的。
在NIO的网络编程中,虽然一个线程就能处理多个通道的所有请求,但是性能上肯定存在问题,比如处理读写的时候总是需要消耗一些时间,这个时候其它连接只能等待。那怎么提高性能呢?有一种设计模式叫做Reactor模式,它是一种事件处理模式,它基于事件驱动,将不同事件分发给相应的Handler来处理,从而提高性能。NIO中Selector监听的也是一个一个事件,不是正好和Reactor模式匹配吗?
实际上也确实如此,NIO中用一个线程处理监听到的所有事件,其实就是Reactor最朴素的模型,即单线程的Reactor模式。想进一步提高效率可以使用Reactor的多线程模式:
用一个线程负责监听所有通道上的各种事件,但是监听到事件之后自己并不处理事件,而是把处理事件交给线程池的线程去做。(依然只使用一个Selector,一个线程监听到Accept、Read、Write事件之后把SelectionKey获取到,扔到线程池中,让线程池中的线程去处理对应的事件,并注册新的事件)
但是多线程的Reactor模式也有可能会出现性能问题,主要在于虽然处理业务的逻辑交给了线程池去做,但是依然只使用了一个Selector来监听所有事件,现在都是多核CPU,并没有发挥多核CPU的特点(多核CPU每核都可以跑一个线程,真正意义的并行),所以进一步提升的方式是使用Reactor的主从模式:
使用一个Acceptor线程专门用于监听不同通道的Accept事件,当接收到一个Accept事件之后连同通道一起交给一个SubReactor线程(subReactor线程的数量就是CPU的核数),这个SubReactor线程自己维护一个Selector,用来监听Acceptor线程分发给它的所有通道上的读写事件,如果监听到事件,自己也不处理,而是把事件交给worker线程池中的线程去处理。
所谓零拷贝就是指IO操作的时候避免在用户态与内核态之间来回拷贝数据。
在NIO中FileChannel的transferTo()和TransferFrom()方法实现了零拷贝的功能,实际上这两个方法的底层都是调用linux中独有的指令sendfile来完成拷贝工作,这个拷贝的过程不需要经过用户态。(零拷贝依赖操作系统,比如现在是windows系统,即使使用transferTo方法,其实也不是零拷贝)
利用直接内存也可以完成零拷贝(要看具体怎么使用):
如果使用DirectByteBuffer,实际上是开辟了一块内核空间,以网络IO往外写为例:将DirectByteBuffer中的内容直接写到网络协议通道中,因为没有内核空间到用户空间的拷贝,所以也是零拷贝
如果使用的MappedByteBuffer,实际上是将文件映射到内核内存,应用可以直接访问这个内核内存,就像访问用户态的内存空间一样,这样就不会产生内核空间到用户空间的内存拷贝。
NIO中直接内存(堆外内存)分配方式有两种:即DirectByteBuffer和MappedByteBuffer。
与这两种直接内存相对应的是HeapByteBuffer,即堆内内存,当我们调用ByteBuffer.allocate()方法的时候返回的就是这种缓冲区。因为它是在JVM堆内存中的,所以支持GC和缓存优化。
AIO即异步非阻塞IO,是真正实现了异步的IO模型。回想我们的NIO编程,总是遵循这样的写法:注册——>监听事件——>处理事件——>再注册,无限循环下去,我们需要写一个循环来从Selector中拿到所有监听的事件,然后再做不同的处理。但是到了AIO我们就不需要轮询来获取事件了,当事件到来的时候可以通过回调函数直接来处理读件。
有一个经典的举例,就是烧开水:
假设有这么一个场景,有一排水壶(请求)在烧水。
AIO的做法是,每个水壶上装一个开关,当水开了以后会提醒对应的线程去处理。(回调)
NIO的做法是,叫一个线程不停的循环观察每一个水壶,根据每个水壶当前的状态去处理。(轮询)
BIO的做法是,叫一个线程停留在一个水壶那,直到这个水壶烧开,才去处理下一个水壶。(阻塞)
从理论上来说,AIO的IO模型肯定是要优于BIO和NIO的,但是现在主流的网络编程框架(比如netty,zookeeper)都没有使用AIO,而是使用的NIO,这是为什么呢?
其实AIO在底层实现的最好的是Windows系统,Linux也实现了异步非阻塞的io模型,但是还有缺陷,而一般情况下我们都是以linux作为服务器,所以也就使用已经很成熟的NIO模型,相信等某一天Linux的异步非阻塞io模型成熟了,我们也会使用成熟的AIO模型吧。