购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

2.1 通信方式

OSI是Open System Interconnection的缩写,中文翻译为开放式系统互联。国际标准化组织(ISO)制定了OSI模型,定义了不同计算机之间实现互联的标准,是网络通信的基本框架。OSI模型将网络通信分为七个层次,分别是物理层、数据链路层、网络层、传输层、会话层、表示层和应用层。

由于复杂度过高,OSI 模型并没有 TCP/IP 模型应用广泛。TCP/IP 模型可以被理解为 OSI模型的浓缩版本,它将OSI模型的七层抽象为四层:原OSI模型中的物理层和数据链路层对应网络接口层;原OSI模型中的网络层和传输层仍然保留;原OSI模型中的会话层、表示层和应用层则合并为应用层。

TCP/IP模型和OSI模型的对比关系如图2-1所示。

图2-1 TCP/IP模型和OSI模型

2.1.1 通信协议

位于传输层和应用层的通信协议,是软件开发工程师需要重点关注的,下面我们具体来看一下。

传输层协议

传输层的作用是使源端和目的端的计算机以对等的方式进行会话,实现端到端的传输。位于传输层的通信协议有TCP和UDP。采用TCP作为应用程序间的远程通信传输方案十分常见, UDP也有其特定的使用场景,下面重点介绍这两种协议。

1.TCP

TCP是Transmission Control Protocol的缩写,中文译法为传输控制协议。它是一种面向连接的协议,提供可靠的双向字节流。TCP通过三次握手的连接创建机制确保连接的可靠性。一个简明的三次握手流程如图2-2所示,具体描述如下。

SYN:客户端发送包含同步序列号的SYN报文,并且同时传递一个随机数作为顺序号。

为了方便描述,我们将该顺序号设为x。

SYN-ACK:服务端在接收到请求之后,返回SYN-ACK报文作为应答,并且同时传递一个值为x+1的应答号以及另一个随机数作为服务端的序列号。同样,为了方便描述,我们将服务端序列号设为y。

ACK:客户端在接收到服务端的应答后,分别将y+1与x+1作为应答号和序列号再次发送至服务端。

图2-2 一个简明的三次握手流程

在三次握手的流程以及序列号与应答号都校验无误后,才会完成连接的创建,同时发送数据。因此TCP的连接创建过程是较为“昂贵”的。

在开启连接和三次握手之后,客户端和服务端即可在网络间双向传递消息,图2-3展示了TCP建立连接和发送数据的过程。

TCP通过显式方式确认连接的创建和终止,因此也被称为面向连接的协议。通信时存在必要的创建连接开销,TCP的开销高于UDP,性能低于UDP。但使用TCP可以保证数据的正确性、顺序性和不可重复性,对于业务应用间的通信而言,TCP是更合适的选择。

图2-3 TCP建立连接和发送数据的过程

下面我们来看一下在Java中使用Socket开发TCP的过程。

Socket是用于连通应用层与传输层之间的抽象层接口。Socket可翻译为套接字,这个译法并不易于理解,因此下文还是统一称其为Socket。Socket通过IP地址和端口确定一个网络环境中唯一的通信句柄(handler),应用通过句柄向网络中的其他服务发送请求,同时处理接收的请求。

Java的网络编程基础是从Socket的TCP编程开始的,TCP采用C/S模式,即客户端/服务器模式,这与服务化中的消费者/提供者模式概念等同,只不过服务化中的应用可以既是服务的消费者,同时也是其他服务的提供者。Java的Socket编程API封装了TCP的三次握手等复杂交互,通过以下六个步骤可以简单实现一个基于TCP的Socket编程的处理流程。

服务端程序绑定一个未占用的端口用于监听客户端程序的连接请求。

客户端程序向服务端发起连接请求,请求过程中附带自身的主机 IP 地址和通信端口号。

服务端接受客户端的连接请求。

客户端与服务端通过Socket进行I/O通信。

建立通信管道后,可以考虑使用多线程机制增加服务端的吞吐量。

完成通信,客户端断开与服务端的连接。

使用Java实现基于TCP的服务端的关键代码如下。

上述代码用于创建ServerSocket,并将每一次接收的客户端处理请求放入线程池,已达到最大吞吐量。

在读/写消息时,需要对Java的I/O类库有一个基本的理解。使用Java I/O进行消息读取的关键代码如下。

通过Socket,我们可以简单地获取相关的输入流和输出流。Java I/O 的API在读取客户端发送的消息时,先使用read(byte [] b)方法读取消息,然后将结果放入buffer的字节数组。这里定义的字节数组大小是1024B,如果传输的消息大小大于这个值,则需要反复读取,并在每次读取时将中间结果放入buffer。

当read方法的返回值等于-1时,即表示客户端传输消息的过程已经结束,可以结束读取。如果 read 方法的返回值不为-1,则表示上次读取的字节数。因此在循环读取时,需要使用read(byte [] b, int offset, int length)方法,避免在最后一次读取消息时,由于并未使用全部的buffer字节数组而导致字节数组中length之后的数据仍然是上一次读取的脏数据。

使用Java实现基于TCP的客户端的关键代码如下。

客户端通过IP地址和端口连接服务端,并获取Socket的输入流和输出流,至于I/O操作也与服务端的操作一致。使用Java开发TCP,屏蔽了三次握手等众多协议上的细节,降低了开发难度。

2.UDP

UDP是User Datagram Protocol的缩写,中文译法为用户数据报文协议。

UDP是一种无连接的、面向数据报文的协议。每个数据报文都是一个独立的信息,包括完整的源地址或目的地址,在网络上可以通过任何可能的路径被传往目的地,因此,数据报文能否到达目的地,到达目的地的时间是否准确,传送的内容是否正确,这些都是不能被保证的。

UDP 不要求通信时保持统一的连接,也不会由于接收方确认收到报文而产生开销。图2-4展示了UDP传输数据的过程,通过与图2-3进行对比,我们可以看到,UDP中没有复杂的建立连接的过程,它关注的仅是数据报文本身。

图2-4 UDP传输数据的过程

虽然UDP可能产生网络丢包,并且无法保证传输的原有顺序,但在性能方面更占优势,更加适用于允许数据被部分丢弃的业务场景,如系统调用追踪日志、视频会议流等。

下面我们来看一下在Java中使用Socket开发UDP的过程。

Java的Socket编程API同样封装了UDP,使其发送端和接收端的开发也变得更加简单。与开发TCP相比,除了接口不同,处理流程没有区别。

使用Java实现基于UDP的消息发送机制的关键代码如下。

使用Java实现基于UDP的消息接收机制的关键代码如下。

相比于开发TCP,开发UDP更加简单,Socket的接收端和发送端以对等的形式存在,无须建立连接。但正如示例所示,开发一个健壮的UDP交互程序,需要考虑报文无法及时发送的场景。

应用层协议

应用层包含所有的高层协议,例如FTP(File Transfer Protocol,文件传输协议)、SMTP(Simple Mail Transfer Protocol,简单电子邮件传输协议)、DNS(Domain Name Service,域名服务)和HTTP(HyperText Transfer Protocol,超文本传送协议)等。其中HTTP是当今互联网应用中使用最广泛的应用层协议,也是应用程序间进行远程通信时采用比较多的协议。

1.HTTP

HTTP 是互联网中应用最为广泛的协议,基于浏览器的 HTML、XML、JSON 等格式的文本都是通过 HTTP 进行传输的。HTTP 的使用非常便捷,当客户端向服务端请求服务时,只发送路径、参数以及请求方法即可。常用的请求方法有GET、POST、UPDATE、DELETE等,它们是组成RESTful架构不可或缺的部分。

HTTP/1.1自1999年起正式被标准化,到目前为止已被广泛使用。它是无连接且无状态的。无连接是指每次连接只处理一个请求,服务端处理完该请求且收到应答后,便断开连接。无状态是指协议对于业务事务处理并没有记忆能力,如果后续处理需要之前的信息,则本次请求必须一次性包含之前的全部信息。

在HTTP/1.1时代,由于无法在同一个连接上并发请求,浏览器需要花费大量的时间等待每一个资源被响应,因此浏览器通常需要开启多个连接来加速请求资源的过程。但开启过多连接的代价是十分昂贵的,所以现代浏览器通常都会被限制最多开启6~8个HTTP/1.1连接。也正因为如此,才会产生各种CSS、JavaScript以及图片合并技术,用于将众多小文件合并成一个完整的大文件,减少文件的个数,提升浏览器加载文件的性能。但不幸的是,单一的大文件会阻塞后续的请求,极度影响用户体验。总之,连接的限制逐渐成了整个Web 系统的性能瓶颈。

直到2015年,HTTP才进行了首次重大升级,由HTTP/1.1变为HTTP/2。HTTP/2 的目标是,在与 HTTP/1.1语义完全兼容的前提下进一步减少网络延迟。也就是说,HTTP/2 是在不改变原有 Web 体系的同时提升性能的,是通过多路复用机制实现的。

HTTP/2 的多路复用机制允许通过单一的连接同时发起多个请求和响应消息,这极大地提升了网络传输的性能。图2-5清晰展示了HTTP/1.1和HTTP/2的差别。

要想在HTTP/1.1中展示一个包含CSS和JavaScript的HTML页面,需要以下九个步骤。

浏览器和服务器创建连接。

客户端通过GET方法请求index.html来获取页面内容。

服务器返回index.html的内容。

客户端通过GET方法请求style.css来获取页面样式表。

服务器返回style.css的内容。

客户端通过GET方法请求script.js来获取JavaScript脚本渲染页面。

图2-5 HTTP/1.1和HTTP/2的差别

服务器返回script.js的内容。

浏览器加载完毕,开始渲染页面。

关闭连接。

可以看到,渲染每个页面时都需要加载一个页面的HTML、CSS和JavaScript文件,这些文件是同步等待的,虽然可以通过开启多个连接来加速加载,但会增加服务端的负荷。并且在每次请求结束之后,浏览器和服务器之间的连接便会关闭,下次请求还需要进行握手并建立连接。

HTTP/2将展现一个页面的过程进行了很大的优化,只包含七个步骤,具体如下。

浏览器和服务器创建连接。由于 HTTP/2支持长连接,因此如果之前创建的连接仍然存在,则此步骤可以省略。

客户端通过GET方法请求index.html来获取页面内容。因为必须先获取HTML的内容才能知道该页面中还包含哪些需要加载的资源,因此获取页面内容是同步的。

服务器返回index.html的内容。

客户端通过GET方法请求style.css和script.js来获取页面样式表和JavaScript脚本。通过一个连接的多路复用可以同时请求多个文件。

服务器通过连接的多路复用返回style.css和script.js的内容。

浏览器加载完毕,开始渲染页面。

保留连接,以便下次请求时使用。可以通过设置连接保留时间和最大连接限制以避免用户离开网站以及服务端持有连接过多的问题。

关于HTTP/2和HTTP/1.1的性能差异,感兴趣的读者可以通过网络资料进行深入了解,例如,大家可以访问https://http2.akamai.com/demo,查看Akamai公司建立的一个官方演示示例。这个示例同时请求379张图片,用于展示HTTP/1.1和HTTP/2的性能差异。在电脑配置不同、网络情况不同、服务器负载情况不同时,得到的结果肯定也不同。图2-6是笔者使用自己的电脑进行演示时的截图,我们可以从中看到HTTP/1.1和HTTP/2在加载时间上的差异。

HTTP/2 通过数据流(stream)的方式支持连接的多路复用。一个连接可以包含多个数据流,多个数据流发送的数据互不影响,将请求和响应在同一个连接中分成不同的数据流可以进一步提升交互的性能。

HTTP/2将每次的请求和响应以帧(frame)为单位进行了更细粒度的划分,所有的帧都在数据流上进行传输,数据流会确定好帧的发送顺序,另一端会按照接收的顺序来处理。除了多路复用,HTTP/2还提供服务器推送和请求头压缩等功能。

随着服务化的发展,HTTP 不再仅被用于浏览器或移动端与后端服务的交互,而是越来越多地被用于后端应用之间的交互。与微服务配套使用的“HTTP/1.1+RESTful API”组合已经非常成熟,由Google开源的基于HTTP/2的异构语言高性能RPC框架gRPC也受到了广泛的关注。

图2-6 HTTP/1.1和HTTP/2在加载时间上的差异

2.长连接与短连接

长连接与短连接都是客户端连接服务端的方式。

长连接是指客户端与服务端长期保持连接,连接不会在一次业务操作结束后断开,连接一旦创建成功便可以最大限度地复用,以降低资源开销、提升性能。长连接的维护成本较高,需要实时监控检查,以保持连接的连通性。

短连接是指客户端和服务端在处理完一次请求之后即断开连接,下次处理请求时则需要重新建立连接。虽然每次建立连接的消耗都比较大,但短连接无须维护连接的状态,相比长连接,其实现复杂度大幅降低。

对于长连接和短连接的认识,有以下两个常见的误区。

第一个误区:区分TCP和HTTP的关键在于,TCP使用长连接方式,HTTP使用短连接方式。通过前面的介绍,我们知道TCP与HTTP处于不同的网络层次,而HTTP是基于TCP的,因此TCP和HTTP的区别并不在于使用长连接还是短连接。

第二个误区:HTTP只能使用短连接。前面的章节也介绍过,HTTP自HTTP/2以来,已经全面支持长连接,而TCP也可以使用短连接。

那么,对于长连接和短连接,使用时究竟应该如何选择呢?

长连接更加适用于端对端的频繁通信。每个基于TCP的连接都需要经过三次握手,高频率的通信如果将时间都浪费在连接的建立上,就很不划算了。但是,由于维护连接会产生消耗,因此连接的数量不能无限制增加。综上所述,长连接更加适用于面向后端的系统之间的交互。例如,应用系统之间的交互,数据库访问服务与数据库的交互等。它们的共同特点是,交互频率高且连接个数有限。

基于 B/S 模式的浏览器与服务器交互的情况,更加适合使用短连接。HTTP 是无状态的,浏览器和服务器每进行一次交互便会建立一次连接,任务结束后便直接关闭连接。面向互联网海量用户的网站为每一个用户维持一个连接,这是无法承受的成本,而且相对于服务之间的交互,人为操作的频率与之完全不是一个数量级。除了面向用户的连接,面向服务的后端场景也有可能使用短连接,由于基于HTTP的短连接实现起来非常便捷,因此如果服务间交互的性能不是系统瓶颈,那么使用短连接也是可以的。

总之,选择长连接还是短连接不能一概而论,而是应该视情况而定。

2.1.2 I/O模型

I/O即输入/输出(Input/Output)。每个应用系统间相互的依赖调用都无法完全避免,我们将这样的系统间调用称为远程通信。每个应用系统自身也将或多或少地产生数据,我们称这种本地调用为本地读/写。I/O便是远程通信和本地读/写的核心。

虽然地位重要,但I/O的性能发展明显落后于 CPU。对于高性能、高并发的应用系统来说,如何回避I/O瓶颈从而提升性能,这一点是至关重要的。

一般来说,I/O模型可以分为阻塞与非阻塞、同步与异步,下面我们分别进行介绍。

阻塞与非阻塞

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

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

因此,判断阻塞I/O与非阻塞I/O时应关注程序是否在等待调用结果——如果系统内核中的数据还未准备完成,用户进程是继续等待直至准备完成,还是直接返回并先处理其他事情。

同步与异步

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

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

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

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

阻塞与非阻塞、同步与异步都是I/O的不同维度。

同步 I/O 是指,在系统内核准备好处理数据后,还需要等待内核将数据复制到用户进程,才能进行处理。

异步I/O是指,用户进程无须关心实际I/O的操作过程,只需在I/O完成后由内核接收通知,I/O操作全部由内核进程来执行。

由此可见,同步I/O和异步I/O针对的是内核,而阻塞I/O与非阻塞I/O针对的则是调用它的函数。

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

总结来说,判断是同步I/O还是异步I/O,主要关注内核数据复制到用户空间时是否需要等待。

2.1.3 Java中的I/O

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

BIO

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

BIO 服务器实现模式为每一个连接都分配了一个线程,即客户端有连接请求时,服务端就需要启动一个线程进行处理。它缺乏弹性伸缩能力,服务端的线程个数和客户端并发访问数呈正比,随着访问量的增加,线程数量会迅速膨胀,最终导致系统性能急剧下降。可以通过合理使用线程池来改进“一连接一线程”模型,实现一个线程处理多个客户端,但开启线程的数量终归会受到系统资源的限制,而且频繁进行线程上下文切换也会导致CPU的利用率降低。BIO的处理架构如图2-7所示。

BIO编程难度不高,前面介绍的Socket编程的例子使用的就是BIO模式,所以这里不再赘述。BIO已经不足以应对当前的互联网场景,这一点要格外注意。

图2-7 BIO的处理架构

NIO

JDK 1.4的java.nio.*包中引入了全新的Java I/O类库。它最初使用select/poll模型,JDK 1.5之后又增加了对epoll的支持,不过只有Linux系统内核版本在2.6及以上时才能生效。相比于BIO,NIO 的性能有了质的提升,它适用于连接数多且连接比较短的轻量级操作架构,后端应用系统间的调用使用NIO会非常合适。在目前互联网高负载、高并发的场景下,NIO有极大的用武之地。它的美中不足是编程模型比较复杂,使用它实现一个健壮的框架并非易事。

NIO通过事件模型的异步通知机制去处理输入/输出的相关操作。在客户端的连接建立完毕且读取准备就绪后,位于服务端的连接接收器便会触发相关事件。与BIO不同,NIO的一切处理都是通过事件驱动的,客户端连接到服务端并创建通信管道,服务端会将通信管道注册到事件选择器,由事件选择器接管事件的监听,并派发至工作线程进行读取、编解码、计算以及发送。图2-8展示了NIO的处理架构。

在使用NIO之前,我们需要理解其中的一些核心概念,下面具体来看一下。

1.Buffer

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

图2-8 NIO的处理架构

所有类型的Buffer实现类都包含3个基本属性:capacity、limit和position。

capacity 是缓冲区可容纳的最大数据量,在缓冲区创建时被设置且不能在运行时被改变。limit 是缓冲区当前数据量的边界。position 是下一个将要被读或写的元素索引位置。这3个属性的关系是capacity≥limit ≥ position≥0。

图2-9展示了向缓冲区写入数据和从缓冲区读取数据时,这3个属性的状态。

图2-9 缓冲区标志位状态

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

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

在 NIO 中,有两种不同的缓冲区,分别是直接缓冲区(direct buffer)和非直接缓冲区(non-direct buffer)。直接缓冲区可以直接操作JVM的堆外内存,即系统内核缓存中分配的缓冲区;非直接缓冲区则只能操作JVM的堆中内存。

创建直接缓冲区的代码如下。

创建非直接缓冲区的代码如下。

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

2.Channel

Channel是一个双向的数据读/写通道。与只能用于数据流的单向操作不同,Channel可以实现读和写同时操作。Channel同时支持阻塞和非阻塞模式,在NIO中当然更加推荐使用非阻塞模式。

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

3.Selector

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

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

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

一个Selector可以同时注册、监听和轮询成百上千个Channel,一个用于处理I/O的线程可以同时并发处理多个客户端连接,具体数量取决于进程可用的最大文件句柄数。由于处理 I/O的线程数大幅减少,因此CPU用于处理线程切换和竞争的时间也相应减少,即NIO中的CPU利用率比BIO中的CPU利用率大幅提高。

最常见的Selector监听事件有以下几种。

客户端连接服务端事件:对应的SelectorKey为OP_CONNECT。

服务端接收客户端连接事件:对应的SelectorKey为OP_ACCEPT。

读事件:对应的SelectorKey为OP_READ。

写事件:对应的SelectorKey为OP_WRITE。

介绍完NIO中的核心概念,我们再来介绍一下NIO的Reactor模式。

I/O多路复用机制采用事件分离器将I/O事件从事件源中分离,并分发至相应的读写事件处理器,开发者只需要注册待处理的事件及其回调方法即可。

NIO采用Reactor模式来实现I/O操作。Reactor模式是I/O多路复用技术的一种常见模式,主要用于同步I/O。在Reactor模式中,事件分离器在Socket读写操作准备就绪后,会将就绪事件传递给相应的处理器并由其完成实际的读写工作。事件分离器由一个不断循环的独立线程来实现,在NIO中,事件分离器的角色由Selector担任。它负责查询I/O是否就绪,并在I/O就绪后调用预先注册的相关处理器进行处理。

以读取数据操作为例,Reactor模式的流程如下。

Selector阻塞并等待读事件发生。

Selector被读事件唤醒,发送读就绪事件至预先注册的事件处理器。

应用程序读取数据。

应用程序处理相关业务逻辑。

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

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

下面是使用NIO处理同步非阻塞I/O请求的服务端核心代码。

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

下面是使用NIO初始化一个同步非阻塞I/O客户端的核心代码。

下面是使用NIO处理同步非阻塞I/O请求的客户端核心代码。

理解和学会使用Selector是NIO的关键。采取I/O多路复用可以在同一时间处理多客户端的接入请求,该项技术能够将多个I/O阻塞复用至同一个Selector阻塞,让应用具有通过单线程同时处理多客户端请求的能力。与传统的多线程模型相比,I/O多路复用比传统的多线程模型更能降低系统开销。

NIO通过非阻塞I/O实现编程模型,虽然大大增加了代码的编写难度,但给应用的性能带来了质的提升。因此,直到现在,在使用Java原生接口编写网络通信程序时,NIO仍然使用得最多。

AIO

随着Java 7的推出,NIO.2也进入了人们的视野。虽然NIO.2在2003年的JSR203(JSR为Java Specification Requests的缩写,即Java规范提案,因此JSR203指Java的第203号规范提案)中就已经被提出,但直到2011年才于JDK 7中实现并一同发布。NIO.2提供了更多的文件系统操作API以及文件的异步I/O操作(即AIO)。

AIO采用Proactor模式实现I/O操作。Proactor模式是I/O多路复用技术的另一种常见模式,它主要用于异步 I/O 处理。Proactor 模式与 Reactor 模式类似,它们都使用事件分离器分离读/写与任务派发,但它比 Reactor 模式更进一步,它不关心如何处理读/写事件,而是由操作系统将读/写操作执行完后再通知回调方法,回调方法只关心自己需要处理的业务逻辑。

Reactor 模式的回调方法是在读/写操作执行之前被调用的,由应用开发者负责处理读/写事件,而 Proactor 模式的回调方法则是在读/写操作完毕后被调用的,应用开发者无须关心与读/写相关的事情。因此Reactor模式用于同步I/O,而Proactor模式则面向异步I/O。

以读取数据操作为例,Proactor模式的流程如下。

事件分离器阻塞并等待读事件发生。

事件分离器被读事件唤醒,并发送读事件至操作系统进行异步I/O处理。

事件分离器将数据准备完毕的消息发送至预先注册的事件处理器。

应用程序处理相关业务逻辑。

不同的操作系统都对 I/O 操作提供了系统级的支持,Java 作为跨平台的开发语言,在 I/O操作时需要对不同的操作系统进行统一封装。

AIO 实现时分别对 Linux 与 Windows 平台进行了不同的封装。在 Linux 操作系统中,2.6及以上版本的内核对应的是epoll,低版本则对应select/poll,Windows系统使用iocp的系统级支持。由于Java的服务端程序很少将Windows作为生产服务器,因此Linux的I/O模型更加受到关注。虽然Windows中的iocp支持真正的异步I/O,但在Linux中,AIO并未真正使用操作系统所提供的异步I/O,它仍然使用poll或epoll,并将API封装为异步I/O的样子,但是其本质仍然是同步非阻塞I/O。

AIO有两种使用方式:一种是较为简单的将来式;另一种是稍为复杂的回调式。

将来式使用java.util.concurrent.Future对结果进行访问,在提交一个I/O请求之后即返回一个Future对象,然后通过检查Future的状态得到“操作完成”“失败”或“正在进行中”的状态,调用Future的get阻塞当前进程或获取消息。但由于Future的get方法是同步并阻塞的,与完全同步的编程模式无异,导致异步操作仅为摆设,因此并不推荐使用。

回调式是AIO的推荐使用方式。NIO.2提供java.nio.channels.CompletionHandler作为回调接口,该接口定义了completed和failed方法,用于让应用开发者自行覆盖并实现业务逻辑。当I/O操作结束后,系统将会调用CompletionHandler的completed或failed方法来结束回调。

下面是使用AIO处理同步非阻塞I/O请求的服务端核心代码。

以上代码比起 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过于底层,导致编写一个健壮的网络通信程序十分复杂,因此一系列的第三方通信框架诞生并快速成长, Netty和Mina就是其中的佼佼者。

发展至今,Netty由于具有优雅的编程模型以及健壮的异常处理方式,渐渐成为了网络通信应用开发的首选框架。

Netty最初是由Jboss提供的一个Java开源框架,目前已独立发展。它基于 Java NIO开发,是通过异步非阻塞和事件驱动来实现的一个高性能、高可靠、高可定制的通信框架。

相比于直接使用 NIO,Netty 的 API 使用更简单,并且内置了各种协议和序列化的支持。Netty还能够通过ChannelHandler对通信框架进行灵活扩展。在AIO出现后,Netty也在切换内核方面进行了尝试,但由于AIO的性能并未比基于epoll的NIO有本质提升,并且还引入了不必要的线程模型增加了编码的复杂度,因此Netty在4.x版本中将AIO移除了。Netty官方网站提供的Netty逻辑模型如图2-10所示。

图2-10 Netty逻辑模型

Netty由核心(Core)、传输服务(Transport Servies)以及协议支持(Protocol Support)这几个模块组成。核心模块提供了性能极高的零拷贝能力,还提供了统一的通信API和可高度扩展的事件驱动模型。传输服务模块和协议支持模块是对 Netty 的有力补充。传输服务模块支持了TCP和UDP等Socket通信,以及HTTP和同一JVM内的通信通道。协议支持模块则对常见的序列化协议进行支持,如Protobuf、gzip等。我们在讲解序列化协议时会重点介绍这部分。

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

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

前面介绍过,NIO 中使用了 Reactor 模式进行事件轮询和派发。对于如何合理将各种事件从Selector中分离处理,NIO并未提供实现方案,而需要开发人员自行解决。Netty建议将用于处理客户端连接的Selector与用于处理消息读写的Selector分离,以便将一些比较耗时的I/O操作隔离至不同的线程中执行,从而减少I/O等待时间。Netty将Selector封装为NioEventLoop,用于处理客户端连接的EventLoop称为boss,用于处理读写操作的EventLoop称为worker。

处理EventLoop的线程模型可以分为单线程Reactor、worker多线程Reactor以及全多线程Reactor三种。

单线程 Reactor 是不分离 boss 与 worker 的事件选择器,统一使用单线程处理。Netty已经将boss与worker分离,因此不推荐此模式。

worker多线程Reactor是使用独立线程处理boss EventLoop的,并且使用线程池来维持多个worker EventLoop。这种模式可以满足大部分场景。

全多线程Reactor是使用独立线程池分别处理boss EventLoop和worker EventLoop的。对于需要安全验证等比较耗时的场景,可以考虑使用此模式。

Netty对于这三种不同的线程模型都能够轻松支持,只需动态调配EventLoopGroup的线程数量即可。

截止到目前,Netty的稳定版本是4.1.x,虽然不久前Netty 5.x的版本已经被开发出来,但由于它使用了ForkJoinPool,导致代码的复杂度增加,同时没有明显的性能改善,因此Netty的作者直接删除了Netty 5的代码分支。本书中的所有例子均基于Netty 4.1.x版本。

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

以上代码的大致流程如下。

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

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

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

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

将处理I/O的通道设置为使用NIO。

添加事件回调方法处理器,即相应的事件触发后的监听处理器,通过自定义的回调处理器处理业务逻辑。这段代码中添加了3个回调处理器。

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

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

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

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

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

主线程等待,直到服务端进程结束(Socket关闭)才停止等待。

优雅关闭线程组。

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

由于Netty已经将大量的技术细节屏蔽和隔离,因此NettyServerHandler看起来非常简单,它只需要由EventLoopGroup监听相应事件,并在接收到事件后分别调用相关的回调方法即可,这个例子中只对读取客户端输入以及错误处理有响应。

channelRead方法在客户端发送消息到服务端时触发。这里可以定制化实现业务逻辑,最后将对象写入缓冲区并刷新缓冲区至客户端。这里如果不调用writeAndFlush 方法而是调用write方法,则消息只会写入缓冲区,而不会真正写入客户端。但由使用者合理地多次调用write之后再调用flush方法,便可以合并缓冲区向客户端写入的次数,达到通过减少交互次数来提升性能的目的。

值得注意的是,这里直接将Java的对象写入了缓冲区,而无须将其转换为ByteBuf对象。这是因为之前在NettyServer中配置了ObjectEncoder,它可以自动对Java对象进行序列化,当网络出现错误时会调用这个方法。为了简单起见,这里只是将异常信息打印至标准输出(stdout),并未做出额外处理。

客户端代码与服务端较为相似,这里不再赘述。

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

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

点击中间区域
呼出菜单
上一章
目录
下一章
×