再聊服务化基石:也有IO的事儿

2017-11-03 16:12:00 张亮 孙奇俏/编 前沿技墅

IO的阻塞与同步

IO即输入/输出(Input/Output)。每个应用系统都少不了交互,或多或少都会产生数据而它们的核心:IO,其性能的发展明显落后于 CPU 。对于高性能、高并发的应用系统来说,回避IO瓶颈进而提升性能是至关重要的。

  阻塞与非阻塞

一般来说,IO模型可以分为阻塞/非阻塞及同步/异步。先从简单的阻塞/非阻塞模型说起。

  • 阻塞IO:用户进程发起IO操作后,必须等待IO操作完成才能继续运行。通信协议中的 Socket 编程,为了简单起见,也使用的这种方式。但这种方式会造成CPU大量闲置,系统吞吐量很低。虽然可以优化为一个 Socket 使用一个独立的线程,但仍会造成线程膨胀。

  • 非阻塞IO:用户进程发起IO操作后,可以不必等待IO操作完成,即可继续做其他事情。但用户进程需要定期询问IO操作是否就绪,可以使用一个线程监听所有的 Socket ,从而极大减少线程数量。对于IO与CPU密集程度的适度操作将会极大提升系统吞吐量,但用户进程不停轮询会略微增加额外的CPU资源浪费。

因此,阻塞IO与非阻塞IO的本质是,程序是否在等待调用结果。

  同步IO与异步IO

操作系统的IO远比上文所述复杂。

Linux 内核会将所有的外部设备当作一个文件来操作,与外部设备的交互均可等同于对文件进行操作,Linux对文件的读写全是通过内核提供的系统调用。Linux内核使用 file descriptor 处理对本地文件的读写,同理,Linux内核使用socket file descriptor处理与Socket相关的网络读写,应用程序对文件的读写则通过对描述符的读写完成。

IO涉及两个系统对象,一个是调用它的用户进程,另一个是kernel,即系统内核。一次读取操作会分为以下几个步骤进行。

  1. 用户进程调用read方法向内核发起读请求并等待就绪。

  2. 内核将要读取的数据复制到文件描述符所指向的内核缓存区中。

  3. 内核将数据从内核缓存区复制至用户进程空间。

同步/异步与阻塞/非阻塞是不同的。同步 IO 和异步IO关注的是内核,而阻塞IO与非阻塞IO对应的则是调用它的函数。

阻塞/非阻塞的关注点在于——如果,系统内核中的数据还未准备完成时,用户进程是继续等待至准备完成,还是直接返回并先处理其他事情。

当系统内核将处理数据操作准备完毕后,需要等待内核将数据复制到用户进程后再进行处理,称为同步IO;而用户进程无须关心实际IO的操作过程,只需由内核在IO完成后通知既可,由内核进程来执行最终的IO操作,这种方式称为异步IO。

select、poll、epoll是Linux系统中使用最多的IO多路复用机制。IO多路复用可以监视多个描述符,一旦某个描述符读写操作就绪,便可以通知程序进行相应的读写操作。尽管实现方式不同,但 select、poll、epoll 都属于同步IO,它们都需要在读写事件就绪后自己进行读写操作,内核向用户进程复制数据的过程仍然是阻塞的,而异步IO则无须自己负责读写,它的实现将负责把数据从内核复制到用户空间。

总结一下,同步IO与异步IO的本质区别是,内核数据复制到用户空间时是否进行等待。

Java中的IO

Java对于IO的封装分为BIO、NIO和AIO,除异步阻塞IO之外,其他三种IO模型的组合与之一一对应。由于Java的IO接口相对面向底层,上手的难度不低,因此衍生出不少第三方IO处理框架,如Netty、mina等。

先看一下Java的IO原生处理框架。

  BIO

Java中的BIO对应的是同步阻塞IO。它是JDK1.4版本以前的唯一选择,程序直观、简单、易理解,但对服务器资源要求比较高,而且性能较差。在当前具备很多替代方案的前提下,已不建议大规模使用,仅适用于连接数少且并发不高的场景。

BIO的服务器实现模式为一个连接分配一个线程。客户端有连接请求时,服务器端就启动一个线程进行处理。它缺乏弹性伸缩能力,服务端的线程个数和客户端并发访问数呈正比,随着访问量的增加会迅速导致线程数量膨胀,最终导致系统性能的急剧下降。可以通过合理使用线程池的方案改进“一连接一线程”模型,实现一个线程处理多个客户端的模型,如下图所示。     

    

 NIO

JDK 1.4中的 java.nio.*包中引入了全新的Java IO类库,与之相对应的是同步非阻塞IO。相比于 BIO,NIO 在性能上实现了质的提升,适用于连接数目多且连接较短的轻量级操作架构,非常适用于后端应用系统间的调用。对于目前互联网高负载和高并发的场景,NIO有极大的用武之地。它的美中不足是编程模型比较复杂,实现一个健壮的框架并非易事。

在使用NIO之前,需要理解以下的基础知识。

  • Buffer:Buffer是包含需要读取或写入的数据的缓冲区。NIO中所有数据读写均通过缓冲区来操作。常用的Buffer实现类有ByteBuffe、ShortBuffer、IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer 、CharBuffer等。

  • Channel:Channel是数据读写的通道。与Stream不同,它是双向的,可以用于读和写的同时操作。其中对应文件操作的管道是 FileChannel,对应网络操作的管道则是  SelectableChannel。与 BIO模型中ServerSocket和Socket相对应的是NIO模型中的ServerSocketChannel和SocketChannel。它们都是 SelectableChannel的实现类,这两种通道同时支持阻塞和非阻塞模式,在NIO中更加推荐使用非阻塞模式。

  • Selector:Selector通过不断地轮询注册在其上的Channel来达到选择已经就绪的任务的目的。它可以同时轮询多个Channel,一个Selector即使接入成千上万个客户端,也不会出现明显的性能瓶颈。Selector是整个NIO的核心,理解 Selector 机制是理解NIO的关键。当 Selector 发现某个Channel有数据状态变化时,会通过 SelectorKey 触发相关事件,并由对此事件感兴趣的应用实现相关的事件处理器。

理解和学会使用 Selector,是 NIO的关键。NIO 通过非阻塞IO的编程模型,虽然在代码的编写难度方面有所增加,但对应用的性能提升却有质的帮助。因此,直到现在,使用Java原生接口编写网络通信程序时,NIO 仍然是使用最多的。

  AIO

随着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出现的时间较晚,而且并无性能提升,因此没有想象中那样普及。

  Netty

虽然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为主的云平台方向,推崇代码优雅化,对如何编写强表现力代码有深入研究。搜索并关注公众号“点亮架构”可追踪作者最新架构实践与感悟。

点击 阅读原文 张亮与众咖组豪华阵容,为你精心烹制出这样一场饕餮盛宴↓



长按二维码,关注“前沿技墅”,抢先接收新知、了解书讯、结识大咖。

任何伟大,无不起步于不经意间,你可以选择不经意错过,或不经意开始……