2.3.5 NIO创建的TimeClient源码分析

我们首先还是看下如何对TimeClient进行改造。

代码清单2-9 NIO时间服务器客户端 TimeClient

与之前唯一不同的地方在于通过创建TimeClientHandle线程来处理异步连接和读写操作,由于TimeClient非常简单且变更不大,这里重点分析TimeClientHandle,代码如下。

代码清单2-10 NIO时间服务器客户端 TimeClientHandle

与服务端类似,接下来我们通过对关键步骤的源码进行分析和解读,让大家深入了解如何创建NIO客户端以及如何使用NIO的API。

(1)23~34行构造函数用于初始化NIO的多路复用器和SocketChannel对象。需要注意的是,创建SocketChannel之后,需要将其设置为异步非阻塞模式。就像在2.3.3章节中所讲的,我们可以设置SocketChannel的TCP参数,例如接收和发送的TCP缓冲区大小。

(2)43~48行用于发送连接请求,作为示例,连接是成功的,所以不需要做重连操作,因此将其放到循环之前。下面我们具体看看doConnect的实现,代码跳到第116~123行,首先对SocketChannel的connect()操作进行判断,如果连接成功,则将SocketChannel注册到多路复用器Selector上,注册SelectionKey.OP_READ,如果没有直接连接成功,则说明服务端没有返回TCP握手应答消息,但这并不代表连接失败,我们需要将SocketChannel注册到多路复用器Selector上,注册SelectionKey.OP_CONNECT,当服务端返回TCP syn-ack消息后,Selector就能够轮询到这个SocketChannel处于连接就绪状态。

(3)49~72行在循环体中轮询多路复用器Selector,当有就绪的Channel时,执行第59行的handleInput(key)方法,下面我们就对handleInput方法进行分析。

(4)跳到第83行,我们首先对SelectionKey进行判断,看它处于什么状态。如果是处于连接状态,说明服务端已经返回ACK应答消息。这时我们需要对连接结果进行判断,调用SocketChannel的finishConnect()方法,如果返回值为true,说明客户端连接成功;如果返回值为false或者直接抛出IOException,说明连接失败。在本例程中,返回值为true,说明连接成功。将SocketChannel注册到多路复用器上,注册SelectionKey.OP_READ操作位,监听网络读操作,然后发送请求消息给服务端。

下面我们对doWrite(sc)进行分析。代码跳到125行,我们构造请求消息体,然后对其编码,写入到发送缓冲区中,最后调用SocketChannel的write方法进行发送。由于发送是异步的,所以会存在“半包写”问题,此处不再赘述。最后通过hasRemaining()方法对发送结果进行判断,如果缓冲区中的消息全部发送完成,打印"Send order 2 server succeed."

(5)返回代码第95行,我们继续分析客户端是如何读取时间服务器应答消息的。如果客户端接收到了服务端的应答消息,则SocketChannel是可读的,由于无法事先判断应答码流的大小,我们就预分配1M的接收缓冲区用于读取应答消息,调用SocketChannel的read()方法进行异步读取操作。由于是异步操作,所以必须对读取的结果进行判断,这部分的处理逻辑已经在2.3.3章节详细介绍过,此处不再赘述。如果读取到了消息,则对消息进行解码,最后打印结果。执行完成后将stop置为true,线程退出循环。

(6)线程退出循环后,我们需要对连接资源进行释放,以实现“优雅退出”。75~80行用于多路复用器的资源释放,由于多路复用器上可能注册成千上万的Channel或者pipe,如果一一对这些资源进行释放显然不合适。因此,JDK底层会自动释放所有跟此多路复用器关联的资源,JDK的API DOC如图2-12所示。

到此为止,我们已经通过NIO对时间服务器完成了改造,并对源码进行了分析和解读,下面分别执行时间服务器的服务端和客户端,看执行结果。

图2-12 多路复用器Selector的资源释放

服务端执行结果如图2-13所示。

图2-13 NIO时间服务器服务端执行结果

客户端执行结果如图2-14所示。

图2-14 NIO时间服务器客户端执行结果

通过源码对比分析,我们发现NIO编程难度确实比同步阻塞BIO大很多,我们的NIO例程并没有考虑“半包读”和“半包写”,如果加上这些,代码将会更加复杂。NIO代码既然这么复杂,为什么它的应用却越来越广泛呢,使用NIO编程的优点总结如下。

(1)客户端发起的连接操作是异步的,可以通过在多路复用器注册OP_CONNECT等待后续结果,不需要像之前的客户端那样被同步阻塞。

(2)SocketChannel的读写操作都是异步的,如果没有可读写的数据它不会同步等待,直接返回,这样I/O通信线程就可以处理其他的链路,不需要同步等待这个链路可用。

(3)线程模型的优化:由于JDK的Selector在Linux等主流操作系统上通过epoll实现,它没有连接句柄数的限制(只受限于操作系统的最大句柄数或者对单个进程的句柄限制),这意味着一个Selector线程可以同时处理成千上万个客户端连接,而且性能不会随着客户端的增加而线性下降,因此,它非常适合做高性能、高负载的网络服务器。

JDK1.7升级了NIO类库,升级后的NIO类库被称为NIO2.0,引人注目的是,Java正式提供了异步文件I/O操作,同时提供了与UNIX网络编程事件驱动I/O对应的AIO,下面的2.4章节我们学习下如何利用NIO2.0编写AIO程序,还是以时间服务器为例进行讲解。