首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

服务化基石之远程通信系列三:I/O模型

远程通信系列 I/O模型

I/O即输入/输出(Input/Output)。每个应用系统间都无法完全避免相互的依赖调用,称之为远程通信;每个应用系统自身也将或多或少的产生数据,称之为本地读写。I/O则是远程通信和本地读写的核心。虽然地位重要,但I/O的性能发展是明显落后于 CPU 的。对于高性能、高并发的应用系统来说,如何回避I/O瓶颈从而提升性能是至关重要的。

阻塞与非阻塞

一般来说,I/O模型可以分为阻塞/非阻塞和同步/异步,我们先从阻塞/非阻塞模型说起。

阻塞IO

用户进程发起I/O操作后,需要等待其操作完成才能继续运行。我们前文列举的Socket编程,使用的就是这种方式。阻塞IO的编程模型非常易于理解,但性能却并不理想,它会造成CPU的大量闲置。使用阻塞IO开发的系统吞吐量会比较低。虽然可以优化为每一次 Socket 请求使用独立的线程,但会造成线程膨胀,使系统越来越慢,并最终宕机。通过线程池可以控制系统创建线程的数量,但仍然无法做到系统性能的最优。

非阻塞IO

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

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

同步与异步

操作系统的I/O远远比之前所讲述的复杂。Linux内核会将所有的外部设备当做一个文件来操作,与外部设备的交互均可等同于对文件进行操作,Linux对文件的读写全是通过内核提供的系统调用。Linux内核使用file descriptor处理对本地文件的读写;同理,Linux内核使用socket file descriptor处理与Socket相关的网络读写,应用程序对文件的读写就通过对描述符的读写完成。 I/O涉及两个系统对象,一个是调用它的用户进程,另一个是系统内核(kernel)。

一次读取操作会进行以下几个步骤:

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

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

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

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

当系统内核将处理数据操作准备完毕之后,需要等待内核将数据复制到用户进程之后,再进行处理,称为同步IO;而用户进程无需关心实际I/O的操作过程,只需由内核在I/O完成后通知既可,由内核进程来执行最终的IO操作,称为异步IO。由此可见,同步IO和异步IO所针对的是内核,而阻塞IO与非阻塞IO对应的则是调用它的函数。

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

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

Java中的I/O

Java对于IO的封装分为BIO、NIO和AIO。Java目前并不支持异步IO,BIO对应的是阻塞同步IO,NIO和AIO对应的都是非阻塞同步IO。由于Java的I/O接口较为面向底层,让开发工程师上手的难度并不低,因此衍生出不少第三方的I/O处理框架,如Netty,mina等,它们能够更加容易的开发出健壮的通信类程序。我们首先看一下Java的I/O原生处理框架。

BIO

Java中的BIO对应的是同步阻塞IO。它是JDK 1.4以前的唯一选择,程序直观简单易理解。BIO的操作每次从数据流中读取字节直至完成,数据不会被缓存,效率较低,对服务器资源占用也较高,在当前有很多替代方案的前提下,已不建议大规模使用,它仅适用于连接数小且并发不高的场景。

BIO的服务器实现模式为一个连接分配一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理。它缺乏弹性伸缩能力,服务端的线程个数和客户端并发访问数呈正比,随着访问量的增加会迅速导致线程数量膨胀,最终导致系统性能的急剧下降。可以通过合理使用线程池的方案改进一连接一线程的模型,实现一个线程处理多个客户端的模型,但开启线程的数量终归会受到系统资源的限制,而且频繁的线程上下文切换也会导致CPU的利用率不高。BIO已经不足以适用于互联网当前的场景。

NIO

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

NIO通过事件模型的异步通知机制去处理输入输出的相关操作。当客户端的连接建立完毕并读取准备就绪后,位于服务端的连接接受器即触发相关事件。与BIO不同,NIO的一切处理都是以事件驱动的,客户端连接到服务器端并创建通信管道之后,服务端会将通信管道注册到事件选择器,由事件选择器接管事件的监听,并派发至工作线程处理读写、编解码以及业务计算。

在使用NIO之前,需要理解一些基础知识,下面进行简单的介绍。

01

Buffer

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

所有类型的Buffer实现类都包含3个基本属性,它们是capacity、limit和position。Capacity是缓冲区可容纳的最大数据量,在缓冲区创建时被设置且不能在运行时改变。Limit是缓冲区当前的数据量的边界位置。Position是下一个将要被读或写的元素索引位置。这3个属性的关系是:capacity >= limit >= position >= 0。下图用于展示向缓冲区写入数据和从缓冲区读取数据时,这3个属性的状态。

写入数据时,limit和capacity相同,每写入一组数据,position会加1,直至position到达capacity的位置,或数据写入完毕,那么limit则指向最后position的数值。读取数据时,每读取一组数据,position会加1,读取到limit所在的位置即结束,如果缓冲区完全被数据充满,那么limit则等于capacity。

除了上述的3个基本属性,还有一个mark属性,它用于标记操作的位置,通过调用mark()方法将mark赋值为position,再通过调用reset()方法将position恢复为mark记录的值。

在NIO中,有两种不同的缓冲区,它们是direct buffer和non-direct buffer。Direct buffer是直接在操作系统内核缓存中分配的缓冲区;non-direct buffer是在JVM的堆中分配的缓冲区。

分别使用如下代码创建direct buffer和non-direct buffer:

创建和释放direct buffer比non-direct buffer的代价要高一些。但使用direct buffer可以减少从系统内核进程到用户进程间的数据拷贝,因此I/O的性能会有所提升。应尽量将direct buffer用于I/O传输的字节数较大且无需反复创建缓冲区的场景。

02

Channel

Channel是一个双向的数据读写的通道。与只能用于数据流的单向操作不同,通道可以用于读和写的同时操作。通道同时支持阻塞和非阻塞模式,在NIO中当然更加推荐使用非阻塞模式。通道中的数据操作完全通过缓存,进一步的提升了读写效率。

对应于文件操作的通道是FileChannel,对用于网络操作的通道则是SelectableChannel。NIO与BIO模型中的ServerSocket和Socket相对应的是ServerSocketChannel和SocketChannel。它们都是SelectableChannel的实现类。

03

Selector

Selector通过不断轮询注册在其之上的channel,来选择并分发已处理就绪的事件。它可以同时轮询多个channel,一个selector即使接入成千上万的客户端也不会产生明显的性能瓶颈。Selector是整个NIO的核心,理解selector机制是理解整个NIO的关键所在。

Selector是所有通道的管理者,当selector发现某个channel有数据状态有变化时,会通过SelectorKey触发相关事件,并由对此事件感兴趣的应用实现相关的事件处理器。使用单线程来处理多channel可以极大的减少多个线程对系统资源的占用,以及它们之间的上下文切换带来的开销。

Selector可以被认为是NIO中的管家。举例说明,在一个宅邸中,管家所负责的工作就是不停的检查各个工作人员的状态,如:仆人出门买东西、仆人回到宅邸、厨师做好饭等事件。这样宅邸中所有的状态,只需要询问管家就可以了。

一个selector可以同时注册、监听和轮询成百上千个 channel,一个用于处理IO的线程可以同时并发处理多个客户端连接,处理的客户端连接梳理取决于进程可用的最大文件句柄数。由于 IO 线程数量有限,不会存在频繁的 IO 线程间的上下文切换和竞争,CPU 利用率比BIO大幅提高。

最常见的selector监听事件有:客户端连接服务端事件,对应的SelectorKey为OP_CONNECT;服务端接受客户端连接事件,对应的SelectorKey为OP_ACCEPT;读事件,对应的SelectorKey为OP_READ和写事件,对应的SelectorKey为OP_WRITE。

服务端关键代码

下面是使用NIO初始化一个同步非阻塞IO服务端的核心代码:

值得注意的是,在服务端初始化时,只需要向通道注册SelectionKey.OP_ACCEPT事件,当OP_ACCEPT事件未到达时,selector.select()将一直阻塞。OP_ACCEPT事件表示服务端已就绪,可以开始处理客户端的连接。

下面是使用NIO处理同步非阻塞IO请求的服务端的核心代码:

当selector.select()被调用时,如果没有已经注册的事件到达,将会一直处于阻塞状态;直至有注册事件到达,则结束阻塞状态继续处理。因此selector.select()非常适合用于循环的开始。 这里处理了建立连接和读取消息这两个最常见的场景。当OP_ACCEPT事件未到达时, selector.select()将一直阻塞。server.accept()用于与客户端连接的初始化。主要步骤是与客户端建立连接、设置非阻塞模型以及注册管道读取事件。只有在与客户端建立连接的时候注册了消息读取,在后续有消息从客户端发送过来时,selector.select()才会响应,由于在初始化的start方法中只注册了OP_ACCEPT事件,不在这里注册OP_READ事件的话,程序是不会处理消息读取事件的。因此需要在接受连接创建之后,注册OP_READ事件,用于处理读数据操作。

客户端关键代码

下面是使用NIO初始化一个同步非阻塞IO客户端的关键代码:

下面是使用NIO处理同步非阻塞IO请求的客户端的核心代码:

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

AIO

随着Java7的推出,NIO.2也进入了人们的视野。NIO.2虽然在2003年的JSR 203就已经提出,但直到2011年才于JDK 7中实现并一同发布。它提供了更多的文件系统操作API以及文件的异步IO操作,即AIO。由于每个操作系统AIO对应的实现方式都不同,因此Java做了封装。Linux系统中2.6内核及其以上对应的是epoll,低版本仍然对应poll,Windows系统也有相应的IOCP的系统级支持。由于Java的服务端程序很少将Windows系统作为生产服务器,因此Linux系统的I/O模型更加受到关注。我们看到,在Linux系统上,Java实际并未真正使用异步IO,而是非阻塞IO,AIO虽然封装的更好,模拟成为了异步IO的样子,但是其本质仍然是poll或epoll这样的同步IO。

下面是使用AIO处理同步非阻塞IO请求的服务端的核心代码:

代码比NIO要精简不少,至少没有selector的轮询需要处理。

AIO采用 AsynchronousChannelGroup的线程池来处理事务,这些事务主要包括等待I/O事件、处理数据以及分发至各个注册的回调函数。通过匿名内部类的方式注册事件回调方法。覆盖的completed方法,用于处理I/O结束后的后续业务逻辑,方法最后需要再调用accept方法用于接受下一次请求。覆盖的failed方法,用于处理I/O中产生的错误。

AIO的客户端代码则更加简单,下面是AIO的客户端的核心代码:

AIO虽然在编程接口上比起NIO更加简单,但是由于其使用的I/O模型与NIO是一样的,因此两者在性能方面并未有明显差距。由于AIO出现的时间较晚,而且并无实质性的性能提升,因此并未达到预想中的普及效果。

Netty

虽然AIO的出现简化了NIO的开发,但是使用AIO的应用其实并不很多。主要原因是Java语言本身的发展远远落后其丰富的第三方开源产品。AIO不但没有成为主流的网络通信应用的主流开发利器,而恰恰相反,在AIO没有出现时,由于NIO的API过于底层,导致编写一个健壮的网络通信程序过于复杂,因而出现了一系列的第三方通信框架,Mina和Netty就是其中的佼佼者。发展至今,Netty由于其优雅的编程模型以及健壮的异常处理方式,渐渐的成为网络通信应用开发的首选框架。

Netty是最初是由Jboss提供的一个Java开源框架,目前已独立发展。它基于 Java NIO开发,是通过异步非阻塞和事件驱动来实现的一个高性能、高可靠和高可定制化的通信框架。在AIO出现后,Netty也进行了尝试,但由于AIO的性能并未有本质提升,因此Netty在其4.0的其中一个版本中将AIO移除。

它分为核心模块,传输模块和协议模块。

它的核心模块提供了性能极高的零拷贝能力。

前文谈到I/O是需要将数据从系统内核复制到用户进程中,在进行下一步操作的。所谓的零拷贝是指无需为数据在内存之间的复制消耗资源,即不需要将数据内容复制到用户空间,而直接在内核空间中传输至网络,从而提升系统的整体性能。Linux的sendfile函数实现了零拷贝的能力,

而使用Linux函数的Java NIO同样也通过其FileChannel的transfer方法实现了该功能。Netty同样通过封装了NIO实现了零拷贝功能。而且Netty还提供了各种便利的缓冲区对象,在操作系统层面之外的Java应用层面进行数据操作优化已达到更优的效果。

Netty的核心模块还提供了统一的通信API和可高度扩展的事件驱动模型。传输模块和协议模块是Netty的有力补充。传输模块支持了TCP和UDP等Socket通信,以及Http和同一JVM内的通信通道。协议模块则对常见的序列化协议进行支持,如Protobuf,gzip等。我们在下一节序列化协议时会重点谈这块。

Netty截止至今的稳定版本是4.1.x,虽然不久前Netty 5.x的版本已经开发,但由于它使用了ForkJoinPool导致了代码的复杂度增加的同时,没有明显的性能改善,因此作者直接删除了Netty 5的代码分支。因此本书举例都是以Netty 4.1.x的版本为准。

下面是使用Netty创建服务端启动程序的核心代码:

这段代码的大致流程如下:

1. 初始化分发与监听事件的轮询线程组。Netty使用的是与NIO相同的selector方式,这里通过EventLoopGroup初始化线程池,这个线程池只需要1个线程用于监听事件到达以及触发事件监听回调方法即可。EventLoopGroup有多种实现,这里的NioEventLoopGroup是使用NIO的实现方式作为其实现类,这也是最常用的实现类。

2. 初始化工作线程组。同样EventLoopGroup的NIO线程组,它用于处理I/O的工作线程,可以指定合理的线程池大小,默认值为当前服务器CPU核数 * 2。

3. 初始化服务端的Netty启动类。Netty通过ServerBootstrap简化服务端的繁琐启动流程。

4. 设置监听线程组与工作线程组。

5. 设置处理I/O的通道是使用NIO的形式。

6. 添加事件回调方法处理器。即当相应的事件触发后的监听处理器。通过自定义的回调处理器处理业务逻辑。这里加了3个回调处理器。

7. 添加解码回调处理器。用于负责将客户端通过网络传递过来的二进制字节数组解码成为服务端所需要的对象。使用weakCachingConcurrentResolver 创建线程安全的 WeakReferenceMap,对类加载器进行缓存。这里使用了Netty内置的ObjectDecoder,它使用的Java原生的序列化方式将二进制字节数组反序列化为正确的对象。关于序列化的更多知识,将在下一节中详细说明。

8. 添加编码回调处理器。用于负责将服务端回写至客户端的对象编码为二进制字节数组以便于通过网络传递。这里使用了Netty内置的ObjectEncoder,它同样使用的Java原生的序列化方式将对象序列化为二进制字节数组。

9. 添加定制化业务的回调处理器。

10. 设置网络通道相关的参数。

11.绑定提供服务的端口并且开始准备接受客户端发送过来的请求。

12. 主线程等待直到服务端进程结束,即直到Socket关闭。

13. 优雅的关闭线程组。

服务端的主启动程序还是非常简单和清晰的。而真正的自定制业务处理流程在回调的处理函数中。下面是服务端的业务回调处理类NettyServerHandler的核心代码:

由于Netty已经将大量的技术细节屏蔽和隔离,因此NettyServerHandler看起来非常简单,它的原理是在事件由EventLoopGroup监听的相应事件到达后会分别调用相关的回调方法,这个例子中只对读取客户端输入以及错误处理有响应。channelRead方法是当有客户端发送消息到服务端时触发。在这里可以定制化实现业务逻辑。最后将对象写入缓冲区并刷新缓冲区至客户端。这里如果不调用writeAndFlush方法而是调用write方法的话,消息只会写入缓冲区,而不会真正的写入客户端。但由使用者合理的多次调用write之后再调用flush方法可以合并缓冲区向客户端写入的次数,来达到以减少交互次数来提升性能的目的。值得注意的是,这里直接将Java的对象写入了缓冲区,而无需将其转换为ByteBuf对象。这是因为之前在NettyServer中配置了ObjectEncoder,它可以自动对Java对象进行序列化。当网络出现错误时会回调这个方法。为了简单起见,这里只是将异常信息打印至标准输出,并未做额外处理。

客户端代码与服务端较为相似,这里不再赘述。通过上述代码的示例分析,可以看出,Netty分离了业务处理,序列化、反序列化与服务端主进程的耦合,使得代码更加清晰易懂。并且已非常简单优雅的方式提供了异步处理的框架。Netty的出现极大的简化了NIO的开发,因此对于非遗留代码,建议使用Netty构建网络程序。

相比于Mina,Netty在内存管理和综合性能方面更胜一筹。它的缺点是向前兼容性不够友好,Netty 3.x与4.x的API并不兼容。笔者认为Netty 4.x的API和架构设计更加合理,因此建议新开发的程序使用Netty 4.x。

以上内容节选自

《 Java云原生新一代分布式中间件架构》

内容简介

【互联网架构不断演化,经历了从集中式架构到分布式架构,再到云原生架构的过程。云原生因能解决传统应用升级缓慢、架构臃肿、不能快速迭代等问题而成为未来云端应用的目标。本书首先介绍了架构演化及云原生的概念,让读者对基础概念有一个准确的了解。接着阐述容器调度、服务化、分布式等体系的原理,讲解分布式中间件设计方法。最后辅以实战,以中心化和平台化角度切入,深度揭秘两大开源项目Elastic-Job和Sharding-JDBC的实现】

尽请期待

《Java云原生 新一代分布式中间件架构》

2018年将与您见面

书名尚未完全确定,欢迎您宝贵建议。

感谢大家关注“点亮架构”,欢迎对公众号文章的内容批评指正,如果有其他想要了解的技术问题,也可以留言提出。

‘点亮架构’的火炬,燃烧云原生‘

  • 发表于:
  • 原文链接http://kuaibao.qq.com/s/20180105G0I8VL00?refer=cp_1026
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券