每日一博 | 透过现象看 Java AIO 的本质
1.前言
关于Java BIO、NIO、AIO的区别和原理,这样的文章非常的多的,但主要还是在BIO和NIO这两者之间讨论,而关于AIO这样的文章就少之又少了,很多只是介绍了一下概念和代码示例。
在了解AIO时,有注意到以下几个现象:
1、 2011年Java 7发布,里面增加了AIO称之为异步IO的编程模型,但已经过去了近12年,平时使用的开发框架中间件,还是以NIO为主,例如网络框架Netty、Mina,Web容器Tomcat、Undertow。
2、 Java AIO又称为NIO 2.0,难道它也是基于NIO来实现的?
3、 Netty舍去了AIO的支持。https://github.com/netty/netty/issues/2515
4、 AIO看起来只是解决了有无,发布了个寂寞。
这几个现象不免会令很多人心存疑惑,所以决定写这篇文章时,不想简单的把AIO的概念再复述一遍,而是要透过现象, 如何分析、思考和理解Java AIO的本质。
2.什么是异步
2.1 我们所了解的异步
AIO的A是Asynchronous异步的意思,在了解AIO的原理之前,我们先理清一下“异步”到底是怎样的一个概念。
说起异步编程,在平时的开发还是比较常见,例如以下的代码示例:
@Async public void create() { //TODO } public void build() { executor.execute(() -> build()); }
不管是用@Async注解,还是往线程池里提交任务,他们最终都是同一个结果,就是把要执行的任务,交给另外一个线程来执行。
这个时候,可以大致的认为,所谓的“异步”,就是多线程,执行任务。
2.2 Java BIO和NIO到底是同步还是异步?
Java BIO和NIO到底是同步还是异步,我们先按照异步这个思路,做异步编程。
2.2.1 BIO示例
byte [] data = new byte[1024]; InputStream in = socket.getInputStream(); in.read(data); // 接收到数据,异步处理 executor.execute(() -> handle(data)); public void handle(byte [] data) { // TODO }
BIO在read()时,虽然线程阻塞了,但在收到数据时,可以异步启动一个线程去处理。
2.2.2 NIO示例
selector.select(); Set<SelectionKey> keys = selector.selectedKeys(); Iterator<SelectionKey> iterator = keys.iterator(); while (iterator.hasNext()) { SelectionKey key = iterator.next(); if (key.isReadable()) { SocketChannel channel = (SocketChannel) key.channel(); ByteBuffer byteBuffer = (ByteBuffer) key.attachment(); executor.execute(() -> { try { channel.read(byteBuffer); handle(byteBuffer); } catch (Exception e) { } }); } } public static void handle(ByteBuffer buffer) { // TODO }
同理,NIO虽然read()是非阻塞的,通过select()可以阻塞等待数据,在有数据可读的时候,异步启动一个线程,去读取数据和处理数据。
2.2.3 产生理解的偏差
此时我们信誓旦旦的说,Java的BIO和NIO是异步还是同步,取决你的心情,你高兴给它个多线程,它就是异步的。
但果真如此么,在翻阅了大量博客文章之后,基本一致的阐明了,BIO和NIO是同步的。
那问题点出在哪呢,是什么造成了我们理解上的偏差呢?
那就是参考系的问题,以前学物理时,公交车上的乘客是运动还是静止,需要有参考系前提,如果以地面为参考,他是运动的,以公交车为参考,他是静止的。
Java IO也是一样,需要有个参考系,才能定义它是同步异步,既然我们讨论的是IO是哪一种模式,那就是要针对IO读写操作这件事来理解,而其他的启动另外一个线程去处理数据,已经是脱离IO读写的范围了,不应该把他们扯进来。
2.2.4 尝试定义异步
所以以IO读写操作这事件作为参照,我们先尝试的这样定义,就是发起IO读写的线程(调用read和write的线程),和实际操作IO读写的线程,如果是同一个线程,就称之为同步,否则是异步。
显然BIO只能是同步,调用in.read()当前线程阻塞,有数据返回的时候,接收到数据的还是原来的线程。
而NIO也称之为同步,原因也是如此,调用channel.read()时,线程虽然不会阻塞,但读到数据的还是当前线程。
按照这个思路,AIO应该是发起IO读写的线程,和实际收到数据的线程,可能不是同一个线程
是不是这样呢,现在开始上Java AIO的代码。
2.3 Java AIO的程序示例
2.3.1 AIO服务端程序
public class AioServer { public static void main(String[] args) throws IOException { System.out.println(Thread.currentThread().getName() + " AioServer start"); AsynchronousServerSocketChannel serverChannel = AsynchronousServerSocketChannel.open() .bind(new InetSocketAddress("127.0.0.1", 8080)); serverChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() { @Override public void completed(AsynchronousSocketChannel clientChannel, Void attachment) { System.out.println(Thread.currentThread().getName() + " client is connected"); ByteBuffer buffer = ByteBuffer.allocate(1024); clientChannel.read(buffer, buffer, new ClientHandler()); } @Override public void failed(Throwable exc, Void attachment) { System.out.println("accept fail"); } }); System.in.read(); } } public class ClientHandler implements CompletionHandler<Integer, ByteBuffer> { @Override public void completed(Integer result, ByteBuffer buffer) { buffer.flip(); byte [] data = new byte[buffer.remaining()]; buffer.get(data); System.out.println(Thread.currentThread().getName() + " received:" + new String(data, StandardCharsets.UTF_8)); } @Override public void failed(Throwable exc, ByteBuffer buffer) { } }
2.3.2 AIO客户端程序
public class AioClient { public static void main(String[] args) throws Exception { AsynchronousSocketChannel channel = AsynchronousSocketChannel.open(); channel.connect(new InetSocketAddress("127.0.0.1", 8080)); ByteBuffer buffer = ByteBuffer.allocate(1024); buffer.put("Java AIO".getBytes(StandardCharsets.UTF_8)); buffer.flip(); Thread.sleep(1000L); channel.write(buffer); } }
2.3.3 异步的定义猜想结论
分别运行服务端和客户端程序
在服务端运行结果里,
main线程发起serverChannel.accept的调用,添加了一个CompletionHandler监听回调,当有客户端连接过来时,Thread-5线程执行了accep的completed回调方法。
紧接着Thread-5又发起了clientChannel.read调用,也添加了个CompletionHandler监听回调,当收到数据时,是Thread-1的执行了read的completed回调方法。
这个结论和上面异步猜想一致,发起IO操作(例如accept、read、write)调用的线程,和最终完成这个操作的线程不是同一个,我们把这种IO模式称之AIO,
当然了,这样定义AIO只是为了方便我们理解,实际中对异步IO的定义可能更抽象一点。
3.AIO示例引发思考的问题
1、 执行completed()方法的这个线程是谁创建的,什么时候创建的?
2、 AIO注册事件监听和执行回调是如何实现的?
3、 监听回调的本质是什么?
3.1 问题1:执行completed()方法的这个线程是谁创建的,什么时候创建的
一般,这样的问题,需要从程序的入口的开始了解,但跟线程相关,其实是可以从线程栈的运行情况来定位线程是怎么运行。
只运行AIO服务端程序,客户端不运行,打印一下线程栈(备注:程序在Linux平台上运行,其他平台略有差异)
分析线程栈,发现,程序启动了那么几个线程
1、 线程Thread-0阻塞在EPoll.wait()方法上
2、 线程Thread-1、Thread-2。。。Thread-n(n和CPU核心数量一致)从阻塞队列里take()任务,阻塞等待有任务返回。
此时可以暂定下一个结论:
AIO服务端程序启动之后,就开始创建了这些线程,且线程都处于阻塞等待状态。
另外,发现这些线程的运行都跟Epoll有关系,提到Epoll,我们印象中,Java NIO在Linux平台底层就是用Epoll来实现的,难道Java AIO也是用Epoll来实现么?为了证实这个结论,我们从下一个问题来展开讨论
3.2 问题2:AIO注册事件监听和执行回调是如何实现的
带着这个问题,去阅读分析源码时,发现源码特别的长,而源码解析是一项枯燥乏味的过程,很容易把阅读者给逼走劝退掉。
对于长流程和逻辑复杂的代码的理解,我们可以抓住它几个脉络,找出哪几个核心流程。
以注册监听read为例clientChannel.read(…),它主要的核心流程是:
1、注册事件 -> 2、监听事件 -> 3、处理事件
3.2.1 1、注册事件
注册事件调用EPoll.ctl(…)函数,这个函数在最后的参数用于指定是一次性的,还是永久性。上面代码events | EPOLLONSHOT字面意思看来,是一次性的。
3.2.2 2、监听事件
3.2.3 3、处理事件
3.2.4 核心流程总结
在分析完上面的代码流程后会发现,每一次IO读写都要经历的这三个事件是一次性的,也就是在处理事件完,本次流程就结束了,如果想继续下一次的IO读写,就得从头开始再来一遍。这样就会存在所谓的死亡回调(回调方法里再添加下一个回调方法),这对于编程的复杂度大大提高了。
3.3 问题3: 监听回调的本质是什么?
先说一下结论,所谓监听回调的本质,就是用户态线程,调用内核态的函数(准确的说是API,例如read,write,epollWait),该函数还没有返回时,用户线程被阻塞了。当函数返回时,会唤醒阻塞的线程,执行所谓回调函数。
对于这个结论的理解,要先引入几个概念
3.3.1 系统调用与函数调用
函数调用:
找到某个函数,并执行函数里的相关命令
系统调用:
操作系统对用户应用程序提供了编程接口,所谓API。
系统调用执行过程:
1.传递系统调用参数
2.执行陷入指令,用用户态切换到核心态,这是因为系统调用一般都需要再核心态下执行
3.执行系统调用程序
4.返回用户态
3.3.2 用户态和内核态之间的通信
用户态->内核态,通过系统调用方式即可。
内核态->用户态,内核态根本不知道用户态程序有什么函数,参数是啥,地址在哪里。所以内核是不可能去调用用户态的函数,只能通过发送信号,比如kill 命令关闭程序就是通过发信号让用户程序优雅退出的。
既然内核态是不可能主动去调用用户态的函数,为什么还会有回调呢,只能说这个所谓回调其实就是用户态的自导自演。它既做了监听,又做了执行回调函数。
3.3.3 用实际例子验证结论
为了验证这个结论是否有说服力,举个例子,平时开发写代码用的IntelliJ IDEA,它是如何监听鼠标、键盘事件和处理事件的。
按照惯例,先打印一下线程栈,会发现鼠标、键盘等事件的监听是由"AWT-XAWT"线程负责的,处理事件则是"AWT-EventQueue"线程负责。
定位到具体的代码上,可以看到"AWT-XAWT"正在做while循环,调用waitForEvents函数等待事件返回。如果没有事件,线程就一直阻塞在那边。
4.Java AIO的本质是什么?
1、由于内核态无法直接调用用户态函数,Java AIO的本质,就是只在用户态实现异步。并没有达到理想意义上的异步。
理想中的异步
何谓理想意义上的异步?这里举个网购的例子
两个角色,消费者A,快递员B
A在网上购物时,填好家庭地址付款提交订单,这个相当于注册监听事件
商家发货,B把东西送到A家门口,这个相当于回调。
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家待着,受人委托,东西到了,人却不在现场,这有点失信于人。
所以实际的异步和理想中的异步,在互不依赖,互不干扰,这两点相违背了。保安的作用最大,这是他人生的高光时刻。
异步过程中的注册事件、监听事件、处理事件,还有开启多线程,这些过程的发起者全是用户态一手操办,所以说Java AIO只在用户态实现了异步,这个和BIO、NIO先阻塞,阻塞唤醒后开启异步线程处理的本质一致。
2、Java AIO跟NIO一样,在各个平台的底层实现方式也不同,在Linux是用EPoll,Windows是IOCP,Mac OS是KQueue。原理是大同小异,都是需要一个用户线程阻塞等待IO事件,一个线程池从队列里处理事件。
3、 Netty之所以移除掉AIO,很大的原因是在性能上AIO并没有比NIO高。Linux虽然也有一套原生的AIO实现(类似Windows上的IOCP),但Java AIO在Linux并没有采用,而是用EPoll来实现。
4、 Java AIO不支持UDP
5、 AIO编程方式略显复杂,比如“死亡回调”

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
- 上一篇
Wine 8.4 发布,带有早期的 Wayland 图形驱动程序代码
Wine 8.4 已发布,用于在 Linux 和其他平台下运行 Windows 游戏和应用程序。Wine 8.4 具有重要意义,因为它是发布最初始的 Wayland 图形驱动程序代码的版本。 目前 Wine 8.4 中“winewayland.drv”的状态尚未为最终用户和游戏玩家准备好,仍处于早期阶段,正在进行开发。距离在原生 Wayland 支持之外再补充 (X)Wayland 支持,还需要很长一段时间,可能在 2024 年初发布的 Wine 9.0 稳定版能看到。 Wine 8.4 还对 IME 支持代码进行了清理、测试修复以及总共 51 个错误修复。单个双周开发版本的 50 多个错误修复是相当多的。这些修复会影响一系列游戏、应用程序和其他核心 Wine 问题。 在发布公告中下载和了解 Wine 8.4 的更多详细信息。
- 下一篇
MRSK —— 在任何地方部署 Web 应用
MRSK 使用 Docker 将网络应用部署在从裸机到云端虚拟机的任何地方,且无停机时间。它使用动态反向代理 Traefik 来保持请求,同时启动新的应用容器并停止旧的容器。它可以跨多个主机无缝工作,使用 SSHKit 来执行命令。它是为 Rails 应用程序构建的,但也适用于任何类型的可以使用 Docker 进行容器化的 Web 应用程序。 MRSK 基本上是 Capistrano for Containers,无需提前精心准备服务器。无需确保服务器具有正确版本的 Ruby 或你需要的其他依赖项。这一切现在都存在于 Docker 镜像中。 你可以启动一个全新的 Ubuntu(或其他)服务器,将其添加到 MRSK 中的服务器列表中,然后它将使用 Docker 自动配置并立即运行。Docker 的层缓存还可以加快部署速度,减少服务器上的麻烦。为 MRSK 构建的镜像可用于 CI 或以后的自省。 MRSK 旨在使用与任何商业产品无关的开源工具来压缩投入生产的复杂性。
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
推荐阅读
最新文章
- CentOS8安装MyCat,轻松搞定数据库的读写分离、垂直分库、水平分库
- Linux系统CentOS6、CentOS7手动修改IP地址
- Eclipse初始化配置,告别卡顿、闪退、编译时间过长
- CentOS7编译安装Cmake3.16.3,解决mysql等软件编译问题
- SpringBoot2全家桶,快速入门学习开发网站教程
- MySQL8.0.19开启GTID主从同步CentOS8
- Docker使用Oracle官方镜像安装(12C,18C,19C)
- Docker安装Oracle12C,快速搭建Oracle学习环境
- CentOS7安装Docker,走上虚拟化容器引擎之路
- Docker快速安装Oracle11G,搭建oracle11g学习环境