IO即输入/输出(Input/Output)。每个应用系统都少不了交互,或多或少都会产生数据,而它们的核心:IO,其性能的发展明显落后于 CPU 。对于高性能、高并发的应用系统来说,回避IO瓶颈进而提升性能是至关重要的。
一般来说,IO模型可以分为阻塞/非阻塞及同步/异步。先从简单的阻塞/非阻塞模型说起。
因此,阻塞IO与非阻塞IO的本质是,程序是否在等待调用结果。
操作系统的IO远比上文所述复杂。
Linux 内核会将所有的外部设备当作一个文件来操作,与外部设备的交互均可等同于对文件进行操作,Linux对文件的读写全是通过内核提供的系统调用。Linux内核使用 file descriptor 处理对本地文件的读写,同理,Linux内核使用socket file descriptor处理与Socket相关的网络读写,应用程序对文件的读写则通过对描述符的读写完成。
IO涉及两个系统对象,一个是调用它的用户进程,另一个是kernel,即系统内核。一次读取操作会分为以下几个步骤进行。
同步/异步与阻塞/非阻塞是不同的。同步 IO 和异步IO关注的是内核,而阻塞IO与非阻塞IO对应的则是调用它的函数。
阻塞/非阻塞的关注点在于——如果,系统内核中的数据还未准备完成时,用户进程是继续等待至准备完成,还是直接返回并先处理其他事情。
当系统内核将处理数据操作准备完毕后,需要等待内核将数据复制到用户进程后再进行处理,称为同步IO;而用户进程无须关心实际IO的操作过程,只需由内核在IO完成后通知既可,由内核进程来执行最终的IO操作,这种方式称为异步IO。
select、poll、epoll是Linux系统中使用最多的IO多路复用机制。IO多路复用可以监视多个描述符,一旦某个描述符读写操作就绪,便可以通知程序进行相应的读写操作。尽管实现方式不同,但 select、poll、epoll 都属于同步IO,它们都需要在读写事件就绪后自己进行读写操作,内核向用户进程复制数据的过程仍然是阻塞的,而异步IO则无须自己负责读写,它的实现将负责把数据从内核复制到用户空间。
总结一下,同步IO与异步IO的本质区别是,内核数据复制到用户空间时是否进行等待。
Java对于IO的封装分为BIO、NIO和AIO,除异步阻塞IO之外,其他三种IO模型的组合与之一一对应。由于Java的IO接口相对面向底层,上手的难度不低,因此衍生出不少第三方IO处理框架,如Netty、mina等。
先看一下Java的IO原生处理框架。
Java中的BIO对应的是同步阻塞IO。它是JDK1.4版本以前的唯一选择,程序直观、简单、易理解,但对服务器资源要求比较高,而且性能较差。在当前具备很多替代方案的前提下,已不建议大规模使用,仅适用于连接数少且并发不高的场景。
BIO的服务器实现模式为一个连接分配一个线程。客户端有连接请求时,服务器端就启动一个线程进行处理。它缺乏弹性伸缩能力,服务端的线程个数和客户端并发访问数呈正比,随着访问量的增加会迅速导致线程数量膨胀,最终导致系统性能的急剧下降。可以通过合理使用线程池的方案改进“一连接一线程”模型,实现一个线程处理多个客户端的模型,如下图所示。
NIO
JDK 1.4中的 java.nio.*包中引入了全新的Java IO类库,与之相对应的是同步非阻塞IO。相比于 BIO,NIO 在性能上实现了质的提升,适用于连接数目多且连接较短的轻量级操作架构,非常适用于后端应用系统间的调用。对于目前互联网高负载和高并发的场景,NIO有极大的用武之地。它的美中不足是编程模型比较复杂,实现一个健壮的框架并非易事。
在使用NIO之前,需要理解以下的基础知识。
理解和学会使用 Selector,是 NIO的关键。NIO 通过非阻塞IO的编程模型,虽然在代码的编写难度方面有所增加,但对应用的性能提升却有质的帮助。因此,直到现在,使用Java原生接口编写网络通信程序时,NIO 仍然是使用最多的。
随着Java7的推出,NIO.2也进入了人们的视野。NIO.2虽然在2003年的 JSR 203就已被提出,但直到2011年才于 JDK 7中实现并一同发布。NIO.2提供了更多的文件系统操作API以及文件的异步IO操作,即AIO。
由于每个操作系统 AIO 对应的实现方式都不同,因此Java做了封装。Linux系统中2.6内核及其以上版本对应的是epoll,低版本仍然对应poll。Windows系统也有相应的 IOCP 的系统级支持。由于Java的服务端程序很少将Windows系统作为生产服务器,因此Linux系统的IO模型更加受到关注。
我们看到,在Linux系统上,Java实际并未真正使用异步IO,而是非阻塞IO。AIO虽然封装效果更好,模拟成为了异步IO的样子,但其本质仍然是poll或epoll这样的同步IO。
使用AIO有两种方式,一种是较为简单的将来式,另一种是使用较为复杂的回调式。
将来式即使用java.util.concurrent.Future对结果进行访问。在提交一个IO请求之后即返回一个Future对象,通过检查Future的状态可以得知操作完成还是失败,或者在进行中,然后调用 Future 的 get 阻塞当前进程或获取消息。但由于 Future的 get方法是同步并阻塞的,与完全同步的编程模式无异,导致异步操作仅作为摆设,因此并不推荐使用这种方法。
回调式是 AIO 推荐使用的首选方式。对此,NIO.2 提供 java.nio.channels.CompletionHandler 作为回调接口,该接口定义了 completed 和 failed 方法,用于让应用开发者自行覆盖并实现业务逻辑。当 I/O 操作结束后,系统将会调用CompletionHandler的completed或failed方法来结束一次调用。
AIO虽然在编程接口上比 NIO 更加简单,但是由于其使用的系统级别的IO模型与NIO是一样的,因此两者在性能方面并没有明显差距。由于AIO出现的时间较晚,而且并无性能提升,因此没有想象中那样普及。
虽然AIO的出现简化了NIO的开发,但是使用AIO的应用其实并不多。主要是由于Java语言本身的发展远远落后其丰富的第三方开源产品。
AIO 不但没有成为主流的网络通信应用开发利器,而恰相反。在 AIO 没有出现时,由于NIO的API过于底层,导致编写一个健壮的网络通信程序十分复杂,因此出现了一系列第三方通信框架,Mina和Netty就是其中的佼佼者。发展至今,Netty 由于优雅的编程模型以及健壮的异常处理方式,渐渐成为网络通信应用开发的首选框架。
Netty是由JBOSS提供的一个Java开源框架,是一个由事件驱动的异步高性能网络应用开发框架。Netty 基于Java NIO,在 AIO 出现后,Netty也进行了尝试,但由于AIO的性能并未有本质提升,因此Netty在其4.0的其中一个版本中将AIO移除。
如上图所示,Netty分为核心、传输和协议三个模块。
核心模块提供了性能极高的零拷贝能力。前文提到IO是需要将数据从系统内核复制到用户进程中,再进行下一步操作的。所谓的零拷贝是指无须为数据在内存之间的复制消耗资源,即不需要将数据内容复制到用户空间,而直接在内核空间中传输至网络,从而提升系统的整体性能。Linux的sendfile函数实现了零拷贝的能力,而使用 Linux 函数的Java NIO同样也通过其 FileChannel 的transfer方法实现了该功能。Netty同样通过封装NIO实现了零拷贝功能。而且 Netty 还提供了各种便利的缓冲区对象,在操作系统层面之外的Java应用层面进行数据操作优化已达到更优的效果。
Netty的核心模块还提供了统一的通信API和可高度扩展的事件驱动模型。
传输模块和协议模块是Netty的有力补充。传输模块支持TCP和UDP等Socket通信,以及HTTP和同一JVM内的通信通道。
Netty分离了业务处理,序列化/反序列化与服务端主进程的耦合,使得代码更加清晰易懂,并且以非常简单优雅的方式提供了异步处理的框架。Netty 的出现极大简化了 NIO 的开发,对于非遗留代码,建议使用 Netty 构建网络程序。
本文摘自博文视点2018年开年大戏——张亮作品:《云原生分布式中间件架构》,深度揭秘开源产品Elastic-Job&Sharding-JDBC! 张亮 当当架构部负责人。主要负责分布式中间件及私有云平台的搭建。乐于分享,拥抱开源,主导两个自研项目Elastic-Job和Sharding-JDBC都已正式开源。擅长以Java为主的分布式架构以及以Mesos为主的云平台方向,推崇代码优雅化,对如何编写强表现力代码有深入研究。搜索并关注公众号“点亮架构”可追踪作者最新架构实践与感悟。