当前位置: 首页 > 科技观察

NIO与BIO的区别、NIO的运行原理和并发使用场景

时间:2023-03-16 18:37:45 科技观察

NIO和BIO的区别,NIO的运行原理和并发使用场景,这也是I/O多路复用的基础,越来越多地应用于大型应用服务器,成为解决高并发和并发的有效途径大量连接,I/O处理问题。那么NIO的本质是什么?它如何结合事件模型来解放线程,提高系统吞吐量?本文将从传统的阻塞I/O和线??程池模型所面临的问题入手,然后对比几种常见的I/O模型,一步步分析NIO是如何使用事件模型来处理I/O的,解决了处理海量连接的线程池瓶颈,包括以面向事件的方式编写服务端/客户端程序。***扩展到一些进阶的话题,比如Reactor和Proactor模型的对比,Selector的觉醒,Buffer的选择等。注:本文代码均为伪代码,主要用于说明目的,不得转载在生产环境中使用。传统BIO模型分析让我们先回忆一下传统服务器端同步阻塞I/O处理(即BIO,BlockingI/O)的经典编程模型:{ExecutorServiceexecutor=Executors.newFixedThreadPollExecutor(100);//线程池ServerSocketserverSocket=newServerSocket();serverSocket.bind(8088);while(!Thread.currentThread.isInturrupted()){//主线程无休止地等待新连接的到来Socketsocket=serverSocket.accept();executor.submit(newConnectIOnHandler(socket));//为新连接创建新线程}classConnectIOnHandlerextendsThread{privateSocketsocket;publicConnectIOnHandler(Socketsocket){this.socket=socket;}publicvoidrun(){while(!Thread.currentThread.isInturrupted()&&!socket.isClosed()){无限循环处理读写事件StringsomeThing=socket.read()....//读取数据if(someThing!=null){...//处理数据socket.write()....//写入数据}}}}这是经典的per-connectionper-thread模型。使用多线程的主要原因是socket.accept()、socket.read()、socket.write()这三个主要函数都是同步阻塞的。当连接正在处理I/O时,系统将被阻塞。如果是单线程,肯定挂在那里;但是释放了CPU,可以开启多线程让CPU去处理更多的事情。事实上,这就是所有使用多线程的本质:利用多核。当I/O阻塞系统但CPU空闲时,可以使用多线程来使用CPU资源。现在的多线程普遍使用线程池,可以使得创建和回收线程的成本比较低。当活跃连接数不是特别多时(单机小于1000),这种模型比较好,让每个连接专注于自己的I/O,编程模型简单,不用想太多关于系统过载,限流等问题。线程池本身就是一个天然的漏斗,可以缓冲一些系统无法处理的连接或请求。然而,这个模型最根本的问题是它严重依赖线程。但是线程是非常“昂贵”的资源,主要表现在:创建和销毁线程的成本非常高。在Linux这样的操作系统中,线程本质上就是一个进程。创建和销毁都是重量级的系统功能。线程本身占用大量内存,像Java的线程栈,一般至少分配512K到1M的空间。如果系统的线程数超过一千,恐怕整个JVM的内存都要消耗掉一半。线程切换的代价非常高。当操作系统发生线程切换时,需要保存线程的上下文,然后执行系统调用。如果线程数过多,执行线程切换的时间甚至可能比线程执行的时间还要长。这时候表现往往是系统负载高,CPUsyusage特别高(超过20%),导致系统几乎陷入不可用状态。容易造成系统负载参差不齐。因为系统负载是根据活跃线程数或者CPU核数来决定的,一旦线程数很高但是外网环境不是很稳定,很容易造成大量的请求结果同时返回时间,激活大量阻塞线程,造成系统负载压力过大。所以,当面对10万甚至上千的连接时,传统的BIO模型就无能为力了。随着移动应用的兴起和各种网络游戏的盛行,最长连接越来越普遍。这时候就需要一种更高效的I/O处理模型。NIO是如何工作的很多刚接触NIO的人,第一眼看到Java中比较晦涩的API,比如:Channel、Selector、Socket等;然后还有几百行代码来演示NIO服务器Demo……是不是瞬间头疼了?我们先不管这些,抛开现象看本质,先分析一下NIO是如何工作的。1.常见I/O模型比较所有系统I/O都分为两个阶段:等待就绪和运行。比如读取函数分为等待系统可读和真正读取;同样,写入函数也分为等待网卡可以写入和真正写入。需要注意的是,阻塞等待ready不占用CPU,是“空等待”;而真正阻塞的读写操作使用的是CPU,是真正“工作”的,而且这个过程非常快,属于内存复制,带宽通常在1GB/s级别以上,基本可以理解为不费时。下图是几种常见I/O模型的对比:接收数据并返回读取数据。对于NIO,如果TCPRecvBuffer有数据,它会从网卡中读取数据到内存中,返回给用户;否则直接返回0,永不阻塞。最新的AIO(AsyncI/O)会更进一步:不仅等待就绪是非阻塞的,就连网卡到内存的数据传输过程也是异步的。也就是说,在BIO模式下,用户最关心“我想读”,在NIO模式下,用户最关心“我会读”,而在AIO模式下,用户更需要关注“阅读”。NIO的一个重要特点是:socket主要的read、write、register和receive函数在ready阶段是非阻塞的,真正的I/O操作是同步阻塞的(消耗CPU但性能非常高)。2.如何结合事件模型使用NIO的同步非阻塞特性下面我们来看看如何使用事件模型在单线程中处理所有的I/O请求:NIO中主要有几个事件:阅读就绪,写就绪,新连接到达。我们首先需要注册当这些事件到达时对应的处理器。然后在合适的时候告诉事件选择器:我对这个事件感兴趣。对于write操作,当不能写入时,对write事件感兴趣;对于读操作,是在连接完成,系统无法承载新读取的数据时;对于accept,一般是在服务器刚启动的时候;而对于connect,一般是在连接失败需要重连或者直接异步调用连接的时候。其次,使用无限循环选择就绪事件,执行系统调用(select,Linux2.6之前的poll,2.6之后的epoll,IOCPforWindows),阻塞等待新事件的到来。当一个新的事件到来时,一个标志将被注册在选择器上,以指示它是可读的、可写的,或者一个连接即将到来。注意select是阻塞的,无论是通过操作系统通知(epoll)还是不停的轮询(select,poll),这个函数都是阻塞的。所以你可以安全大胆的在while(true)调用这个函数而不用担心CPU空转。所以我们的程序大概是这样的:interfaceChannelHandler{voidchannelReadable(Channelchannel);voidchannelWritable(Channelchannel);}classChannel{Socketsocket;Eventevent;//read,writeorconnect}//IO线程主循环:classIoThreadextendsThread{publicvoidrun(){Channelchannel;while(channel=Selector.select()){//选择就绪事件和对应的连接if(channel.event==accept){registerNewChannelHandler(channel);//如果是新连接,注册一个新的reader写handler}if(channel.event==write){getChannelHandler(channel).channelWritable(channel);//如果可以写入,则执行写入事件}if(channel.event==read){getChannelHandler(channel).channelReadable(channel);//如果可以读取,则执行读取事件}}}MaphandlerMap;//所有通道对应的事件处理器}这个程序很短,最简单的Reactor模式:注册所有感兴趣的事件处理程序,选择就绪事件的单线程轮询,执行事件处理程序。3、优化线程模型从上面的例子我们可以大致总结一下NIO是如何解决线程瓶颈和处理海量连接的:NIO从原来的阻塞式读写(占用线程)变成了单线程轮询事件,并且发现用于读写的网络描述符。除了事件的轮询被阻塞(什么事都必须阻塞),其余的I/O操作都是纯CPU操作,不需要开启多线程。并且由于线程的节省,连接数较大时线程切换带来的问题也将得到解决,进而为处理海量连接提供了可能。单线程I/O处理的效率确实很高。没有线程切换,就是拼命的读、写、选择事件。但是现在的服务器一般都是多核处理器。如果能用多核做I/O,效率无疑会大大提高。仔细分析一下我们需要的线程,其实主要有以下几种:事件分发器,单线程选择就绪事件。I/O处理器,包括connect、read、write等,这种纯CPU操作,一般只需要开启一个CPU核心线程即可。业务线程,业务处理完I/O后,一般都有自己的业务逻辑,有的还会有其他的阻塞I/O,比如DB操作,RPC等,只要有阻塞,就需要一个单独的线程。Java的Selector对于Linux系统有一个致命的限制:同一个通道的select不能被并发调用。因此,如果有多个I/O线程,必须保证一个socket只能属于一个IoThread,一个IoThread可以管理多个socket。另外,连接处理和读写处理通常可以分开,这样海量连接的注册和读写就可以分布式处理。read()和write()虽然是比较高效的非阻塞函数,但毕竟会占用CPU,面对更高的并发也无能为力。NIO在客户端的魔力通过上面的分析可以看出,NIO在服务端解放线程、优化I/O、处理海量连接方面确实有自己的用武之地。1、NIO的使用场景有哪些?常见的客户端BIO+连接池模型可以建立n个连接,然后当某个连接被I/O占用时,可以使用其他连接来提高性能。但是多线程模型面临着和服务器一样的问题:如果期望通过增加连接数来提升性能,连接数受线程数限制,线程很昂贵,很多线程无法创建,性能会遇到瓶颈。2、Redis对每个连接顺序请求对于Redis来说,由于服务端是全局序列化的,所以可以保证同一个连接的所有请求与返回顺序一致。这样就可以采用单线程+队列的方式来缓冲请求数据。然后pipeline发送,返回future,当channel可读时,直接从queue中取出future,done()就可以了。伪代码如下:;queue.drainTo(list);if(channel.isWritable()){channel.writeAndFlush(list);}});}publicvoidChannelReadFinish(Channelchannel,BufferBuffer){Listresult=handleBuffer();//处理数据//来自cmdQueue取出future并设置值,future.done();}publicvoidChannelWritable(Channelchannel){channel.flush();}}这样可以充分利用pipeline提高I/O能力的同时时间获得异步处理能力。3、多连接和短连接的HttpClient类似于竞速爬虫的项目。它往往需要建立无数个HTTP短连接,然后爬取,再销毁。当单机需要爬取上千个网站线程,线程数有限时,如何保证性能呢?为什么不尝试NIO,一个用于连接、写入和读取操作的线程?如果操作系统无法连接、读取和写入,只需注册一个事件并等待下一个周期。如何存储不同的请求/响应?由于http是没有版本的无状态协议,也没有办法使用队列,所以好像没有多少办法。比较笨的方法是直接将socketreference作为不同socket的map的key进行存储。4、常见的RPC框架,比如Thrift、Dubbo,内部一般会维护请求协议和请求号,可以维护一个以请求号为key,结果为future的map。结合NIO+长连接,采集性能非常好。NIO进阶专题1.Proactor和Reactor一般来说,I/O多路复用机制需要一个事件调度器(eventdispatcher)。事件分发器的作用就是把那些读写事件源分发给各个读写事件的handler,就像楼下的快递员在喊:谁的快件到了,快来拿!开发者一开始就需要向dispatcher注册感兴趣的事件,并提供相应的handlers(事件处理器),或者回调函数;事件调度器将在适当的时候将请求的事件分发给这些处理程序或回调函数。涉及事件调度器的两种模式称为:Reactor和Proactor。Reactor模式基于同步I/O,而Proactor模式与异步I/O相关。在Reactor模式下,事件派发器等待一个事件或应用程序的状态或一个操作的发生(比如一个文件描述符可以被读写,或者一个socket可以被读写),事件派发器通过event传给预先注册的事件处理函数或回调函数,后者会做实际的读写操作。Proactor模式下,事件处理器(或事件分发器发起)直接发起异步读写操作(相当于请求),实际工作由操作系统完成。启动时需要提供的参数包括用于存放读取数据的缓冲区,读取数据的大小或用于存放外发数据的缓冲区,以及请求完成后的回调函数。事件派发器知道这个请求,它静静地等待这个请求的完成,然后将完成事件转发给相应的事件处理程序或回调。例如在Windows上,事件处理程序下发一个异步IO操作(称为重叠技术),事件分发器等IOComplete事件完成。这种异步模式的典型实现是基于操作系统底层的异步API,所以我们可以称之为“系统级”或“真正的”异步,因为具体的读写是由操作系统完成的。2.缓冲区的选择对于NIO来说,缓存的使用可以使用DirectByteBuffer和HeapByteBuffer。如果使用DirectByteBuffer,一般来说,可以减少从系统空间到用户空间的一份拷贝。但是Buffer的创建和销毁成本较高,不适合维护。通常,内存池用于提高性能。如果数据量比较小,可以考虑使用heapBuffer;否则,您可以使用directBuffer。NIO的问题使用NIO!=highperformance,当连接数小于1000时,并发度不高或者NIO在局域网环境下没有明显的性能优势。NIO并没有完全屏蔽平台差异,它仍然是基于各个操作系统的I/O系统来实现的,差异依然存在。使用NIO进行网络编程构建事件驱动模型并不容易,而且有很多坑。推荐大家使用成熟的NIO框架:如Netty、MINA等,解决了NIO的很多陷阱,屏蔽了操作系统的差异,性能和编程模型更好。总结***总结一下NIO给我们带来了什么:事件驱动模型避免了多线程单线程处理多任务非阻塞I/O,I/O读写不再阻塞,而是返回0基于块传输,通常比基于流的传输更高效和更高级的IO功能,零拷贝IO复用大大提高了Java网络应用的可扩展性和实用性