当前位置: 首页 > 后端技术 > Java

透过现象看JavaAIO的本质|得物科技

时间:2023-04-02 02:10:20 Java

1.前言关于JavaBIO、NIO、AIO的区别和原理的文章很多,但主要是BIO和NIO之间的讨论,而关于AIO的文章很少,很多只是介绍概念和代码示例。在学习AIO的时候,我注意到了以下现象:1.2011年Java7发布,增加了一种叫做异步IO的AIO编程模型,但是近12年过去了,通常的开发框架中间件,NIO仍然是中流砥柱,例如网络框架Netty、Mina和Web容器Tomcat和Undertow。2.JavaAIO也叫NIO2.0。是不是也是基于NIO?3.Netty放弃了对AIO的支持。https://github.com/netty/netty/issues/25154,AIO貌似只解决了问题,又发布了一个孤独的。这些现象难免会让很多人感到困惑,所以当我决定写这篇文章的时候,我并不想简单地重复AIO的概念,而是如何通过现象来分析、思考和理解JavaAIO的本质。2、什么是异步2.1我们所知道的asynchronousAIO的A是Asynchronous异步的意思。在了解AIO原理之前,我们先明确一下“异步”是个什么样的概念。说到异步编程,在平时的开发中是比较常见的,比如下面的代码示例:@Asyncpublicvoidcreate(){//TODO}publicvoidbuild(){executor.execute(()->build());无论是使用@Async注解还是提交任务到线程池,最终的结果都是一样的,就是将要执行的任务交给另一个线程执行。这时候可以大致认为所谓的“异步”就是多线程,执行任务。2.2JavaBIO和NIO是同步的还是异步的?不管JavaBIO和NIO是同步的还是异步的,我们先按照异步的思想来做异步编程。2.2.1BIO实例byte[]data=newbyte[1024];InputStreamin=socket.getInputStream();in.read(data);//接收数据,异步处理executor.execute(()->handle(data));publicvoidhandle(byte[]data){//TODO}BIOread()时,虽然线程阻塞,但在接收数据时,可以异步启动一个线程进行处理。2.2.2NIO例子selector.select();Setkeys=selector.selectedKeys();Iteratoriterator=keys.iterator();while(iterator.hasNext()){SelectionKeykey=iterator.next();如果(key.isReadable()){SocketChannel通道=(SocketChannel)key.channel();ByteBufferbyteBuffer=(ByteBuffer)key.attachment();executor.execute(()->{try{channel.read(byteBuffer);handle(byteBuffer);}catch(Exceptione){}});}}publicstaticvoidhandle(ByteBufferbuffer){//TODO}同样,虽然NIOread()是非阻塞的,但是通过select(),可以阻塞等待数据,当有数据可读时,异步启动一个线程来读取和处理数据。2.2.3理解上的差异这个时候我们发誓,Java的BIO和NIO是异步的还是同步的,就看你的心情了。如果你乐意给它多线程,它就是异步的。但是如果是这样的话,看了很多博客文章,基本搞清楚BIO和NIO是同步的。那么问题出在哪里呢?是什么导致了我们认识上的偏差?这就是参照系的问题。以前学物理的时候,公交车上的乘客是动的还是静止的,需要一个参照系。如果以地面为参照物,他是动的,以公交车为参照物,他是静止的。JavaIO也是如此。需要一个参考系统来定义它是同步的还是异步的。既然是讨论IO是哪种模式,那么就需要了解IO的读写操作,其他的则另起炉灶。处理数据的线程已经不在IO读写范围内,不应该涉及。2.2.4尝试定义异步。所以我们以IO读写操作的事件作为参考。我们首先尝试定义的是发起IO读写的线程(调用读写的线程),以及实际操作IO读写的线程。如果是同一个线程,则称为同步,否则为异步。显然,BIO只能是同步的。调用in.read()会阻塞当前线程。当返回数据时,原始线程接收数据。而NIO也叫同步,道理是一样的。在调用channel.read()时,虽然线程不会阻塞,但读取数据的仍然是当前线程。按照这个思路,AIO应该是发起IO读写的线程,和真正接收数据的线程可能不是同一个线程。是这样吗?现在让我们开始编写JavaAIO的代码。2.3JavaAIO的程序说明示例2.3.1AIO服务端程序publicclassAioServer{publicstaticvoidmain(String[]args)throwsIOException{System.out.println(Thread.currentThread().getName()+"AioServerstart");AsynchronousServerSocketChannelserverChannel=AsynchronousServerSocketChannel.open().bind(newInetSocketAddress("127.0.0.1",8080));serverChannel.accept(null,newCompletionHandler(){@Overridepublicvoidcompleted(AsynchronousSocketChannelclientChannel,Voidattachment){System.out.println(Thread.currentThread().getName()+"客户端已连接");ByteBufferbuffer=ByteBuffer.allocate(1024);clientChannel.read(buffer,buffer,newClientHandler());}@Overridepublicvoidfailed(Throwableexc,Voidattachment){System.out.println("接受失败");}});系统.in.read();}}publicclassClientHandlerimplementsCompletionHandler{@Overridepublicvoidcompleted(Integerresult,ByteBufferbuffer){buffer.flip();byte[]data=newbyte[buffer.remaining()];缓冲区。获取(数据);System.out.println(Thread.currentThread().getName()+"收到:"+newString(data,StandardCharsets.UTF_8));}@Overridepublicvoidfailed(Throwableexc,ByteBufferbuffer){}}2.3.2AIO客户端程序publicclassAioClient{publicstaticvoidmain(String[]args)throwsException{AsynchronousSocketChannelchannel=AsynchronousSocketChannel.open();channel.connect(newInetSocketAddress("127.0.0.1",8080));ByteBufferbuffer=ByteBuffer.allocate(1024);buffer.put("JavaAIO".getBytes(StandardCharsets.UTF_8));缓冲区翻转();线程.睡眠(1000L);channel.write(缓冲区);}}2.3.3异步定义猜想结论分别运行服务端和客户端程序。在服务器的运行结果中,主线程发起serverChannel.accept的调用,添加一个CompletionHandler监听回调。当有客户端连接时,Thread-5线程执行accept的完成回调方法,然后Thread-5发起clientChannel.read调用,同时也增加了一个CompletionHandler来监听回调。当接收到数据的时候,执行read的完成回调方法的是Thread-1。这个结论和上面的异步猜想是一致的。发起IO操作(如accept、read、write)的线程和最终完成操作的线程是不一样的。我们称这种IO模式为AIO。当然,这样定义AIO只是为了我们的理解,异步IO的实际定义可能更抽象一点。三、AIO实例提示思考问题1、执行completed()方法的线程是谁创建的,什么时候创建的?2、如何实现AIO注册事件监听和执行回调?3、监听回调的本质是什么?3.1问题一:谁创建了执行completed()方法的线程,一般什么时候创建?这样的问题需要从程序的入口去理解,但是和线程有关。其实可以通过查看线程栈的运行状态来定位线程的运行情况。只运行AIO服务端程序,客户端不运行,打印线程栈(注:程序运行在Linux平台,其他平台略有不同)分析线程栈,发现程序启动了这么多线程1.ThreadThread-0在EPoll.wait()方法2上阻塞.ThreadThread-1,Thread-2。..Thread-n(n等于CPU核数)从阻塞队列中take()个任务,阻塞等待一个任务返回。至此,可以初步得出下一个结论:AIO服务器程序启动后,创建了这些线程,线程都处于阻塞等待状态。另外,我发现这些线程的运行与epoll有关。说到Epoll,我们的印象是JavaNIO是在Linux平台底层用Epoll实现的。JavaAIO也是用Epoll实现的吗?为了证实这个结论,我们从下一个问题3.2开始讨论问题2:AIO注册事件监听和执行回调是如何实现的?一个枯燥的过程,很容易把读者赶走,说服他们离开。对于流程长、逻辑复杂的代码的理解,我们可以把握它的几个上下文,找出有哪些核心流程。以注册监听器read为例clientChannel.read(...),其主要核心流程为:1.注册事件->2.监听事件->3.处理事件3.2.11.注册事件注册事件调用EPoll.ctl(...)函数,这个函数的最后一个参数用来指定是一次性的还是永久的。以上代码事件|EPOLLONSHOT字面意思是一次性的。3.2.22、监听事件3.2.33、处理事件3.2.4核心流程总结分析完上面的代码流程,你会发现每次IO读写必须经历的三个事件是一个——time,也就是处理完事件后,当前流程就结束了。如果要继续下一次IO读写,就得从头再来。这样就会出现所谓的死亡回调(在回调方法中加入下一个回调方法),大大增加了编程的复杂度。3.3问题三:监听回调的本质是什么?先说结论吧。所谓监听回调的本质是用户态线程,调用内核态函数(准确的说是API,如read、write、epollWait)。当函数还没有返回时,用户线程被阻塞。当函数返回时,被阻塞的线程被唤醒并执行所谓的回调函数。要理解这个结论,首先要介绍几个概念3.3.1系统调用和函数调用函数调用:在函数API中找到一个函数,执行相关命令。系统调用执行流程:1.传递系统调用参数2.执行陷阱指令,从用户态切换到核心态,因为系统调用一般需要在核心态下执行3.执行系统调用程序4.返回用户态3.3。2用户态与内核态的通信用户态->内核态,只是通过系统调用。内核态->用户态,内核态不知道用户态程序有什么功能,参数是什么,地址在哪。因此,内核不可能在用户态调用函数,只能通过发送信号的方式。比如关闭程序的kill命令就是通过发送信号让用户程序优雅退出。既然内核态不可能主动调用用户态的函数,那为什么还要回调呢?只能说,这个所谓的回调,其实是一种自我导向、自我执行的用户状态。它不仅监听,还执行回调函数。3.3.3结合实例验证结论为了验证这个结论是否具有说服力,例如通常用于开发和编写代码的IntelliJIDEA监听鼠标键盘事件和处理事件。按照惯例,先打印线程栈,会发现“AWT-XAWT”线程负责监听鼠标键盘等事件,“AWT-EventQueue”线程负责事件处理。定位到具体代码,可以看到“AWT-XAWT”在做一个while循环,调用waitForEvents函数等待事件返回。如果没有事件,则线程已经阻塞在那里。4、JavaAIO的本质是什么?1、由于内核态不能直接调用用户态函数,JavaAIO的本质是只在用户态实现异步。它没有实现理想意义上的异步。理想异步什么是理想异步?这是一个具有两个角色的在线购物示例。网上购物时,消费者A和快递员BA填写家庭地址进行支付并提交订单。这相当于注册监听事件。相当于一个回调。A在网上下单后,就不用操心后续的配送流程,可以继续做其他事情了。B不关心A在送货时是否在家。反正把货扔到家门口就好了。两个人互不依赖,互不干涉。假设A的购物是在用户态进行的,B的快递是在内核态进行的,这种程序运行方式过于理想化,在实际中是无法实现的。现实中,异步A住在高档小区,不能随意进入,快递员只能送到小区门口。A买了一个比较重的产品,比如电视机,因为A要上班不在家,所以请朋友C帮忙把电视机搬到他家。A上班前在门口跟保安D打招呼,说今天要送一台电视机。送到小区门口,请C打电话让他过来取。此时A下单并问候D,相当于注册了一个事件。在AIO中,它是EPoll.ctl(...)注册事件。守在门口的保安就相当于在监视事件。在AIO中,它是Thread-0线程。做EPoll.wait(..)作为快递员把电视送上门就相当于IO事件的到来。保安通知C电视机到了,C来搬电视机,相当于处理了事件。AIO中,Thread-0向任务队列提交任务,Thread-1~n取数据并执行回调方法。整个过程中,保安D一直蹲着,寸步不离,否则电视送上门就被偷走了。朋友C也必须住在A家。他受人托付,物到人不在。这有点不诚实。因此,实际的异步和理想的异步是相互独立的,互不干扰。这两点是相互矛盾的。保安的作用是最大的,这是他人生的高??光时刻。在异步过程中注册事件、监听事件、处理事件、开启多线程,这些过程的发起者都是用户态处理的,所以JavaAIO只在用户态实现异步,先用BIO阻塞和NIO一样,阻塞唤醒后启动异步线程处理的本质是一样的。2、JavaAIO和NIO是一样的,各个平台的底层实现方式也不同。Linux中使用EPoll,Windows中使用IOCP,MacOS中使用KQueue。原理是一样的,都需要一个用户线程阻塞等待IO事件,一个线程池从队列中处理事件。3、Netty之所以去掉AIO,是因为AIO在性能上并不比NIO高。虽然Linux也有一套原生的AIO实现(类似于Windows上的IOCP),但是JavaAIO并没有在Linux中使用,而是用EPoll实现的。4、JavaAIO不支持UDP5,AIO编程方式略复杂,如“死亡回调”