1.3 AIO,大道至简的设计与苦涩的现实
AIO是I/O模型里一个很高的层次,体现了大道至简的软件美学理念。与NIO相比,AIO的框架和使用方法相对简单很多。
AIO包括两大部分:AIO Files解决了文件的异步处理问题,AIO Sockets解决了Socket的异步处理问题。AIO的核心概念为应用发起非阻塞方式的I/O操作,在I/O操作完成时通知应用,同时,应用程序的职责很明确,比如什么时候发起I/O操作请求,在I/O操作完成时通知谁来处理。
下图给出了AIO Sockets对读请求的处理流程(写请求同理),应用程序在有读请求时就向Kenel注册此请求,应用的线程就可以继续执行其他操作而无须等待。与此同时,Kernel在发现有数据到达Socket以后,就将数据从内核复制到应用程序的Buffer里,在复制完成后,回调应用程序的通知接口,应用程序就可以处理此数据了。
在编写AIO Socket程序时,我们所要掌握的最关键的一个类是回调接口——CompletionHandler<V,A>,它包括两个方法:void completed(V result,A attachment);void failed(Throwable exc,attachment)。
其中,completed方法是异步请求完成时的通知接口,result是返回的结果;attachment则是应用程序捆绑在这个回调接口上的任意对象,可以记录客户端连接对象,或者用来保存Session会话的状态数据,例如已读取的字节信息等。failed方法则表明I/O事件异常,通常是不可恢复的故障。completed方法中的V与A具体是什么对象呢?这取决于调用者,比如在Asynchronous ServerSocketChannel对象中异步接收客户端连接请求的方法签名如下:
在上述方法中,attachment参数被作为CompletionHandler<V,A>的A参数传递到completed与failed回调方法中。下面的代码创建了一个AsynchronousServerSocketChannel,并调用accept方法等待客户端异步连接建立完成:
AioAcceptHandler这个CompletionHandler的代码则如下:
在上述代码中,completed方法先调用AsynchronousServerSocketChannel的accept方法,注册下一次的异步连接请求。这个调用很重要,否则AsynchronousServerSocketChannel就不会再接收新连接的请求了;随后调用startRead(socket)方法发起一个异步读取数据的请求。在说明这个方法之前,我们看看AsynchronousSocketChannel的异步读方法的签名:
上述方法类似于之前分析的accept方法,attachment参数被作为CompletionHandler<V,A>的A参数传递到completed与failed回调方法里,V参数则是一个整数,用于表明此次读到的字节总数。
下面是startRead(socket)方法的逻辑,它调用了AioReadHandler来处理读到的数据,注意传递到AioReadHandler里的Attachment是此次读到的数据——ByteBuffer,最多1024个字节:
AioReadHandler负责处理异步读响应事件,下面是其Complete方法的源码:
如上所示的代码的总体逻辑类似于accept的处理逻辑,它针对每个客户端Socket都使用了一个ByteBuffer作为Session级别的变量,用来保存客户端发送的数据,并且通过Attachment变量传递到CompletionHandler的下一次读取事件上。
AIO异步写的操作类似于异步读的处理,这里不做分析。从AIO的代码来看,我们发现AIO也有类似于NIO的一面,即如果还有I/O事件要操作,则仍然需要把它们“注册”到系统里。不同的是,在AIO框架下,客户端收到反馈事件时,数据已经准备好了,应用程序可以直接处理,在NIO框架下则还需要应用调用底层的读写API完成具体的I/O操作。
AIO框架不仅仅止步于此,我们知道,在JDK的NIO模型下,多路复用的Reactor模型及多线程的Reactor模型都不是官方JDK提供的,这也大大增加了应用编程的复杂度。AIO框架则将复杂的多线程处理机制融入JDK的AIO框架中,让我们可以轻松写出高级又优雅的AIO程序。
从之前的NIO经验来看,在处理很多个Socket的I/O事件时,多线程(线程池)成为必然的选择,很直观的推理就是CompletionHandler需要一个线程池来实现高性能并发回调机制,于是就有了AsynchronousChannelGroup对象,它内部包括一个线程池:
AsynchronousServerSocketChannel可以被绑定到某个ChannelGroup上,以便共用其线程池:
注意到ChannelGroup后面捆绑的线程池可以有多种选择,例如固定大小的线程池、弹性扩展的线程池、缓存的线程池等,于是编程的灵活性很大。此外,如果是每个CPU核心都对应一个ChannelGroup,这就接近多线程Reactor模型的设计了。
从上面的分析来看,JDK里的AIO框架设计的确很优雅,而且很妥善地解决了JDK里NIO框架没有考虑到的复杂问题。从诞生的那天开始,Java AIO的一切看上去都很美,但是现在,“它美丽而晴朗的天空却被一朵乌云笼罩了”,这朵“乌云”就是Linux的AIO泥潭。
早在2003年,Linux kernel AIO项目就启动并且制定了设计方案。2004年,IBM觉得异步状态机的实现跟已存在的代码不协调并且太复杂,于是做出了Retry模型,但Retry模型的阻塞问题(block point)始终无法得到解决。Oracle负责OSS的部门接管了Retry模型,后来又觉得IBM的Retry模型有很多问题,发现Retry&Exit在他们的一个产品上会有很大的性能问题,于是重起炉灶,开发了一个Syslet方案,却以失败告终。直到2016年5月,还有人发现在Linux 3.13内核里有AIO内存溢出的严重漏洞(Ubuntu Kylin 14.04 LTS版本就采用了这个内核),Docker则要求使用AIO的宿主机所安装的版本不低于Linux 3.19(在这个版本里又有好几处AIO代码的修复)。
目前Linux上的AIO实现主要有两种:Posix AIO与Kernel Native AIO,前者是以用户态实现的,而后者是以内核态实现的,所以Kernel Native AIO的性能及前景要好于它的前辈Posix AIO。比较知名的软件如Nginx、MySQL、InnoDB等的高版本都支持Kernel Native AIO,但基本上都只将文件传输到Socket中,即AIO Files的特性。Netty后来也实现了AIO,但又取消了,这个做法与Mycat的尝试过程殊途同归。其原因其实很简单,Linux下AIO的实现充斥着各种Bug,并且AIO Socket还不是真正的异步I/O机制,性能的改进并不明显和可靠;而另外一种值得重视的观点是:AIO是为了未来的高带宽大数据传输而准备的技术,还不适应当前的硬件和软件环境。