5.3 Selector

如第5.1节中提到的,Selector类可用于避免使用非阻塞式客户端中很浪费资源的“忙等”方法。例如,考虑一个即时消息服务器。可能有上千个客户端同时连接到了服务器,但在任何时刻都只有非常少量的(甚至可能没有)消息需要读取和分发。这就需要一种方法阻塞等待,直到至少有一个信道可以进行I/O操作,并指出是哪个信道。NIO的选择器就实现了这样的功能。一个Selector实例可以同时检查(如果需要,也可以等待)一组信道的I/O状态。用专业术语来说,选择器就是一个多路开关选择器,因为一个选择器能够管理多个信道上的I/O操作。

要使用选择器,需要创建一个Selector实例(使用静态工厂方法open())并将其注册(register)到想要监控的信道上(注意,这要通过channel的方法实现,而不是使用selector的方法)。最后,调用选择器的select()方法。该方法会阻塞等待,直到有一个或更多的信道准备好了I/O操作或等待超时。select()方法将返回可进行I/O操作的信道数量。现在,在一个单独的线程中,通过调用select()方法就能检查多个信道是否准备好进行I/O操作。如果经过一段时间后仍然没有信道准备好,select()方法就返回0,并允许程序继续执行其他任务。

下面来看一个例子。假设我们想要使用信道和选择器来实现一个回显服务器,并且不使用多线程和忙等。为了使不同协议都能方便地使用这个基本的服务模式,我们把信道中与具体协议相关的处理各种I/O的操作(接收,读,写)分离了出来。TCPProtocol定义了通用TCPSelectorServer类与特定协议之间的接口,包括三个方法,每个方法代表了一种I/O形式。当有信道准备好I/O操作时,服务器只需要调用相应的方法即可。

TCPProtocol.java

figure_0128_0158

figure_0129_0159

在服务器端创建一个选择器,并将其与每个侦听客户端连接的套接字所对应的ServerSocketChannel注册在一起。然后进行反复循环,调用select()方法,并调用相应的操作器例程对各种类型的I/O操作进行处理。

TCPServerSelector.java

figure_0129_0160

figure_0130_0161

1.设置:第14~19行

验证至少有一个参数,创建一个Selector实例。

2.为每个端口创建一个ServerSocketChannel:第22~28行

·创建一个ServerSocketChannel实例:第23行

·使其侦听给定端口:第24行

需要获得底层的ServerSocket,并以端口号作为参数调用其bind()方法。任何超出适当数值范围的参数都将导致抛出IOException异常。

·配置为非阻塞模式:第25行

只有非阻塞信道才可以注册选择器,因此需要将其配置为适当的状态。

·为信道注册选择器:第27行

在注册过程中指出该信道可以进行“accept”操作。

3.创建协议操作器:第31行

为了访问回显协议中的操作方法,创建了一个EchoSelectorProtocol实例。该实例包含了需要用到的方法。

4.反复循环,等待I/O,调用操作器:第33~59行

·选择:第35行

这个版本的select()方法将阻塞等待,直到有准备好I/O操作的信道,或直到发生了超时。该方法将返回准备好的信道数。返回0表示超时,这时程序将打印一个点来标记经过的时间和迭代次数。

·获取所选择的键集:第41行

调用selectedKeys()方法返回一个Set实例,并从中获取一个Iterator。该集合中包含了每个准备好某一I/O操作的信道的SelectionKey(在注册时创建)。

·在键集上迭代,检测准备好的操作:第42~58行

对于每个键,检查其是否准备好进行accep()操作,是否可读或可写,并调用相应的操作器方法对每种情况进行指定的操作。

·从集合中移除键:第57行

由于select()操作只是向Selector所关联的键集合中添加元素,因此,如果不移除每个处理过的键,它就会在下次调用select()方法时仍然保留在集合中,而且可能会有无用的操作来调用它。

TCPServerSelector的大部分内容都与协议无关,只有协议赋值那一行代码是针对特定的协议。所有协议细节都包含在了TCPProtocol接口的具体实现中。EchoSelector Protocol类就实现了该回显协议的操作器。你可以轻松地为自其他协议编写自己的操作器,或在我们的回显协议操作器上进行改进。

EchoSelectorProtocol.java

figure_0131_0162

figure_0132_0163

1.声明实现TCPProtocol接口:第6行

2.成员变量和构造函数:第8~12行

每个实例都包含了将要为每个客户端信道创建的缓冲区大小。

3.handleAccept():第14~20行

·从键中获取信道,并接受连接:第15行

channel()方法返回注册时用来创建键的Channel。(我们知道该Channel是一个ServerSocketChannel,因为这是我们注册的唯一一种支持“accept”操作的信道。)accept()方法为传入的连接返回一个SocketChannel实例。

·设置为非阻塞模式:第16行

再次提醒,这里无法注册阻塞式信道。

·为信道注册选择器:第18~19行

可以通过SelectionKey类的selector()方法来获取相应的Selector。我们根据指定大小创建了一个新的ByteBuffer实例,并将其作为参数传递给register()方法。它将作为附件,与register()方法所返回的SelectionKey实例相关联。(在此我们忽略了返回的键,但当信道准备好读数据的I/O操作时,可以通过选出的键集对其进行访问。)

4.handleRead():第22~33行

·获取键关联的信道:第24行

根据其支持数据读取操作可知,这是一个SocketChannel。

·获取键关联的缓冲区:第25行

连接建立后,有一个ByteBuffer附加到该SelectionKey实例上。

·从信道中读数据:第27行

·检查数据流的结束并关闭信道:第27~28行

如果read()方法返回-1,则表示底层连接已经关闭,此时需要关闭信道。关闭信道时,将从选择器的各种集合中移除与该信道关联的键。

·如果接收完数据,将其标记为可写:第29~31行

注意,这里依然保留了信道的可读操作,虽然缓冲区中可能已经没有剩余空间了。

5.handleWrite():第35~50行

·获取包含数据的缓冲区:第41行

附加到SelectionKey上的ByteBuffer包含了之前从信道中读取的数据。

·准备缓冲区的写操作:第42行

Buffer的内部状态指示了在哪里放入下一批数据,以及缓冲区还剩多少空间。flip()方法用来修改缓冲区的内部状态,以指示write()操作从什么地方获取数据,以及还有剩余多少数据。(下一章将对其进行详细介绍。)该方法的作用是使写数据的操作开始消耗由读操作产生的数据。

·获取信道:第43行

·向信道写数据:第44行

·如果缓冲区为空,则标记为不再写数据:第45~48行

如果缓冲区中之前接收的数据已经没有剩余,则修改该键关联的操作集,指示其只能进行读操作。

·压缩缓冲区:第49行

如果缓冲区中还有剩余数据,该操作则将其移动到缓冲区的前端,以使下次迭代能够读入更多数据(第5.4.5节将对这个操作的语义进行详细介绍)。在任何情况下,该操作都将重置缓冲区的状态,因此缓冲区又变为可读。注意,除了在handleWrite()方法内部,与信道关联的缓冲区始终是设置为可读的。

现在我们已经准备好对三大NIO抽象的细节进行深入研究了。