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

深度长文:从Bio到Nio再到Aio,再到响应式编程

时间:2023-03-21 00:51:54 科技观察

本文转载自微信公众号“小姐姐的味道”,作者小姐姐养的狗。转载本文请联系味觉小姐公众号。如果要问计算机系统中哪些概念比较折腾人,nio绝对可以算作一个。配合多变种网络编程,nio加多线程一般可以完成双杀。Linux有5种常见的IO模型。其中,阻塞IO是bio,IO多路复用是nio,异步IO是aio。本文着重于此。阻塞IO(bio)非阻塞IOIO多路复用(nio)信号驱动IO异步IO(aio)在网络编程中,Reactor模型一定要了解。现在,大部分IO相关的组件都采用了Reactor模型,比如Tomcat、Redis、Nginx等,可见Reactor应用的广泛性。Reactor是NIO的基础。为什么NIO的性能可以比传统的blockingIO更高呢?我们先来看看传统阻塞IO的一些特点。1.阻塞IO模型如上图所示,是一个典型的BIO模型。每当有连接到来,经过协调器处理后,就会开启相应的线程接管。如果有1000个连接,则需要1000个线程。线程资源非常昂贵。除了占用大量内存外,还占用大量CPU调度时间。所以当连接很多的时候,BIO的效率会变得很低。下面的代码是使用ServerSocket实现的一个简单的socket服务器,监听8888端口。publicclassBIO{staticbooleanstop=false;publicstaticvoidmain(String[]args)throwsException{intconnectionNum=0;intport=8888;ExecutorServiceservice=Executors.newCachedThreadPool();ServerSocketserverSocket=newServerSocket(port);while(!stop){if(10==connectionNum){stop=true;}Socketsocket=serverSocket.accept();service.execute(()->{try{Scannerscanner=newScanner(socket.getInputStream());PrintStreamprintStream=newPrintStream(socket.getOutputStream());while(!stop){Strings=scanner.next().trim();printStream.println("PONG:"+s);}}catch(Exceptionex){ex.printStackTrace();}});connectionNum++;}服务。shutdown();serverSocket.close();}}启动后,使用nc命令测试连接,结果如下。$nc-vlocalhost8888Connectiontolocalhostport8888[tcp/ddi-tcp-1]成功!helloPONG:hellonicePONG:nice可以看到BIO的读写操作被阻塞了,线程的整个生命周期和bio的生命周期是一样的连接,并且不能重复使用。就单个阻塞IO而言,其效率并不比NIO慢。但是当服务连接数增加的时候,考虑到整个服务器的资源调度和资源利用率等因素,NIO的效果就很明显了,NIO非常适合高并发的场景。2.非阻塞IO模型其实在处理IO动作的时候,大部分时间都花在了等待上。例如,socket连接需要很长时间才能执行一次连接操作。在完成连接期间,不占用额外的系统资源,但只能在线程中阻塞等待。在这种情况下,系统资源就无法得到合理利用。Java的NIO是在Linux上底层使用epoll实现的。epoll是一个高性能的多路复用I/O工具,改进了select和poll等工具的一些功能。在网络编程中,对epoll的概念有所了解几乎是面试必问的问题。内核直接支持epoll的数据结构。通过epoll_create、epoll_ctl等函数的运行,可以构造出与描述符(fd)相关的事件组合(event)。这里还有两个比较重要的概念:fd每个连接,每个文件对应一个描述符,比如端口号。当内核定位到这些连接时,就是fd处理的事件。当fd对应的资源有状态或者数据发生变化时,epoll_item结构体会被更新。当没有事件变化时,epoll会阻塞等待,不会占用系统资源;一旦有新的事件到来,epoll就会被激活,并将该事件通知给应用端。还会有一道关于epoll的面试题:相对于select,epoll有哪些改进?这里直接回答:epoll不再需要像select那样轮询fd集合,也不需要在调用应用程序获取就绪fd的事件时,在用户态和内核态之间交换fd集合的复杂性,epoll是O(1),select是O(n)select最多支持1024个fds左右,epoll支持65535个select使用轮询方式检测就绪事件,epoll使用通知方式,效率更高我们还是用Java的NIO代码作为一个例子看看NIO的具体概念。publicclassNIO{staticbooleanstop=false;publicstaticvoidmain(String[]args)throwsException{intconnectionNum=0;intport=8888;ExecutorServiceservice=Executors.newCachedThreadPool();ServerSocketChannelssc=ServerSocketChannel.open();ssc.configureBlocking(false);ssc.socket().bind(newInetSocketAddress("localhost",port));Selectorselector=Selector.open();ssc.register(selector,ssc.validOps());while(!stop){if(10==connectionNum){stop=true;}intnum=selector.select();if(num==0){continue;}Iteratorevents=selector.selectedKeys().iterator();while(events.hasNext()){SelectionKeyevent=events.next();if(event.isAcceptable()){SocketChannelsc=ssc.accept();sc.configureBlocking(false);sc.register(selector,SelectionKey.OP_READ);connectionNum++;}elseif(event.isReadable()){try{SocketChannelsc=(SocketChannel)event.channel();ByteBufferbuf=ByteBuffer.allocate(1024);intsize=sc.read(buf);if(-1==size){sc.close();}Stringresult=newString(buf.array()).trim();ByteBufferwrap=ByteBuffer.wrap(("PONG:"+result).getBytes());sc.write(wrap);}catch(Exceptionex){ex.printStackTrace();}}elseif(event.isWritable()){SocketChannelsc=(SocketChannel)event.channel();}events.remove();}}service.shutdown();ssc.close();}}以上代码比较长,使用NIO实现,是和BIO一样从它的API设计上,我们可以看出一些epoll的影子。首先,我们创建一个服务器ssc,并打??开一个新的事件选择器来监听它的OP_ACCEPT事件。ServerSocketChannelssc=ServerSocketChannel.open();Selectorselector=Selector.open();ssc.register(selector,ssc.validOps());有4种类型的事件。它们是新连接事件(OP_ACCEPT)、连接就绪事件(OP_CONNECT)、读就绪事件(OP_READ)和写就绪事件(OP_WRITE)。任何网络和文件操作都可以抽象为这四个事件。接下来,在while循环中,使用select函数阻塞在主线程中。所谓阻塞就是操作系统不再为当前线程分配CPU事件片,所以select函数几乎不占用任何系统资源。intnum=selector.select();一旦有新的事件到来,比如有新的连接,就可以调度主线程,向下执行程序。此时可以根据订阅事件通知不断获取订阅事件。由于可能有多个连接和事件注册到选择器,因此会有多个这些事件。我们使用安全的迭代器循环进行处理,完成后将其删除。如果事件没有被删除,或者漏掉了某个事件的处理怎么办?后果相当严重。由于事件一直存在,我们的程序就会陷入死循环。Iteratorevents=selector.selectedKeys().iterator();while(events.hasNext()){SelectionKeyevent=events.next();...events.remove();}}当一个新的连接到达时,我们订阅了更多活动。对于我们的数据读取,对应的事件是OP_READ。与BIO编程的面向流方式不同,NIO操作的对象是抽象概念Channel,通过缓冲区进行数据交换。SocketChannelsc=ssc.accept();sc.configureBlocking(false);sc.register(selector,SelectionKey.OP_READ);值得注意的是:服务端和客户端的实现是可以不同的。比如服务端是NIO,客户端可以是BIO,没有强制要求。另一个在面试中经常被问到的事件是OP_WRITE。正如我们上面提到的,这个事件表明它已准备好写入。当底层缓冲区空闲时,此事件将继续发生,浪费CPU资源。所以我们一般不注册OP_WRITE。这里还有一个细节。在读取数据时,它不像BIO方式那样使用循环获取数据。在下面的代码中,我们创建了一个1024字节的缓冲区来读取数据。如果连接中的数据大于1024字节怎么办?SocketChannelsc=(SocketChannel)event.channel();ByteBufferbuf=ByteBuffer.allocate(1024);intsize=sc.read(buf);这就涉及到两个事件的通知机制。Level-triggered(电平触发)称为LT模式。只要缓冲区有数据,事件就会一直发生边沿触发(edge-triggered)称为ET模式。缓冲区有数据,只会触发一次。如果事件要再次触发,必须先读取fd中的数据,才能看到。Java的NIO采用了水平触发的方式。LT模式频繁唤醒线程,效率低于ET模式。所以Netty采用JNI方式实现ET模式,效率更高。3.Reactor模式了解了BIO和NIO的一些使用方法后,Reactor模式就呼之欲出了。NIO是基于事件机制的。有一个名为Selector的选择器,它阻止获取感兴趣的事件列表。拿到事件列表后,就可以通过分发器进行真正的数据操作了。上图是DougLea在讲解NIO时的一张图,表示最简单的Reactor模型的基本要素。大家可以对比分析上面的NIO代码。里面主要有四个元素:Acceptor处理客户端的连接,并绑定到一个特定的事件处理器Event发生的特定事件Handler执行特定的事件处理器。例如,为了处理读写事件,Reactor将特定的事件分配给Handler。我们可以进一步细化上述模型。下图也在DougLea的ppt中。它将Reactor部分分为两部分,mainReactor和subReactor。mainReactor负责监听和处理新的连接,然后将后续的事件处理交给subReactor。subReactor处理事件的方式也从阻塞方式变成了多线程处理,引入了任务队列的方式。熟悉Netty的同学可以看出,这个模型是Netty设计的基础。在Netty中,Boss线程对应连接的处理和调度,相当于mainReactor;Work线程对应subReactor,采用多线程的方式负责读写事件的分发和处理。该模型将各个组件的职责划分得更细,耦合度更低,可以有效解决C10k问题。4、AIO对NIO的概念还有很多误解。面试官可能会问你:为什么我用NIO的时候用Channel读写,但是socket操作还是阻塞的?NIO主要体现在哪里?//这行代码被屏蔽了intsize=sc.read(buf);答案是NIO只负责通知发生在fd描述符上的事件。事件的获取和通知部分是非阻塞的,但是收到通知后的操作是阻塞的。即使使用多个线程来处理这些事件,它仍然是阻塞的。AIO更进了一步,使这些对事件的操作也成为非阻塞的。下面是一段典型的AIO代码,通过注册CompletionHandler回调函数来处理事件。这里的事件是隐藏的,比如read函数,不仅表示Channel是可读的,还会自动将数据读入ByteBuffer中。当读取完成后,会通过回调函数通知你进行后续操作。publicclassAIO{publicstaticvoidmain(String[]args)throwsException{intport=8888;AsynchronousServerSocketChannelssc=AsynchronousServerSocketChannel.open();ssc.bind(newInetSocketAddress("localhost",port));ssc.accept(null,newCompletionHandler(){voidjob(finalAsynchronousSocketChannelsc){ByteBufferbuffer=ByteBuffer.allocate(1024);sc.read(buffer,buffer,newCompletionHandler(){@Overridepublicvoidcompleted(Integerresult,ByteBufferattachment){Stringstr=newString(attachment.array()).trim();ByteBufferwrap=ByteBuffer.wrap(("PONG:"+str).getBytes());sc.write(wrap,null,newCompletionHandler(){@Overridepublicvoidcompleted(Integerresult,Objectattachment){job(sc);}@Overridepublicvoidfailed(Throwableexc,Objectattachment){System.out.println("error");}});}@Overridepublicvoidfailed(Throwableexc,ByteBufferattachment){System.out.println("error");}});}@Overridepublicvoidcompleted(AsynchronousSocketChannelsc,Objectattachment){ssc.accept(null,this);job(sc);}@Overridepublicvoidfailed(Throwableexc,Objectattachment){exc.printStackTrace();System.out.println("error");}});Thread.sleep(Integer.MAX_VALUE);}}AIO是Java1.7中加入的,理论上性能会有所提升,但现在发展不太好,自动读取数据的部分,一定要有位置要实现它,不在框架中,它必须在内核中。Netty的NIO模型加多线程在这方面做得很好,编程模型也很简单。因此,市场上AIO实践不多,在采用技术选型时一定要慎重。5、响应式编程大家可能听说过Spring5的webflux,webflux是一套可以替代springmvc的解决方案,可以编写响应式应用。两者的关系见下图。它的底层使用netty,所以操作是异步非阻塞的。类似的组件还有vert.x、akka、rxjava等。webflux是一个运行在projectreactor之上的包,它的基本特性由后者提供。至于底层的非阻塞模型,则由Netty来保证。非阻塞特性我们可以理解,但是响应式是什么概念呢?响应式编程是一种面向数据流和变化传播的编程范式。这意味着静态或动态数据流可以很容易地用编程语言表达,相关的计算模型将通过数据流自动传播变化的值。这段话非常晦涩。从编程的角度来说,就是:用简单的API表达生产者-消费者模型,自动处理背压问题。背压是指生产者和消费者之间的流量控制。通过完全异步操作,它减少了无用的等待和资源消耗。Java的lambda表达式可以担当起简洁的重任,而Java9引入了ReactiveStream,方便了我们的操作。比如下面是SpringCloudGateWay的FluentAPI写法,响应式编程的API都是类似的。publicRouteLocatorcustomerRouteLocator(RouteLocatorBuilderbuilder){returnbuilder.routes().route(r->r.path("/market/**").filters(f->f.filter(newRequestTimeFilter()).addResponseHeader("X-Response-Default-Foo","Default-Bar")).uri("http://localhost:8080/market/list").order(0).id("customer_filter_router")).build();}来自旧版从reactor的开发模式过渡到reactor的开发模式是有一定成本的,但是确实可以提升我们应用的性能。是否使用取决于编程难度和性能之间的权衡。小结通过上面的描述,我们了解到BIO的线程模型是一个连接对应一个线程,这是一种资源浪费;NIO通过监听关键事件,通过主动通知完成非阻塞操作,但不影响事件本身。AIO的处理仍然是非阻塞的;AIO是完全异步非阻塞的,但是在现实中很少用到。利用Netty的多Acceptor模式和多线程模式,我们可以轻松完成类似AIO的操作。Netty的事件触发机制采用高效的ET模式,支持更多的连接数和更高的性能。使用Netty,你可以构建响应式编程的基础,用lambda表达式这样的写法,你可以完成WebFlux这样的响应式框架。响应式编程是一种趋势。现在越来越多的框架和底层数据库支持响应式编程,我们的应用响应会更快。作者简介:品味小姐姐(xjjdog),一个不允许程序员走弯路的公众号。专注于基础架构和Linux。十年架构,每天百亿流量,与你探讨高并发世界,给你不一样的滋味。