选择器,可选择通道和选择键类
现在,您也许还对这些用于就绪选择的Java成员感到困惑。让我们来区分这些活动的零件并了解它们是如何交互的吧。图4-1的UML图使得情形看起来比真实的情况更为复杂了。看看图4-2,然后您会发现实际上只有三个有关的类API,用于执行就绪选择:
选择器(Selector)
选择器类管理着一个被注册的通道集合的信息和它们的就绪状态。通道是和选择器一起被注册的,并且使用选择器来更新通道的就绪状态。当这么做的时候,可以选择将被激发的线程挂起,直到有就绪的的通道。
可选择通道(SelectableChannel)
这个抽象类提供了实现通道的可选择性所需要的公共方法。它是所有支持就绪检查的通道类的父类。FileChannel对象不是可选择的,因为它们没有继承SelectableChannel(见图4-2)。所有socket通道都是可选择的,包括从管道(Pipe)对象的中获得的通道。SelectableChannel可以被注册到Selector对象上,同时可以指定对那个选择器而言,那种操作是感兴趣的。一个通道可以被注册到多个选择器上,但对每个选择器而言只能被注册一次。
选择键(SelectionKey)
选择键封装了特定的通道与特定的选择器的注册关系。选择键对象被SelectableChannel.register( ) 返回并提供一个表示这种注册关系的标记。选择键包含了两个比特集(以整数的形式进行编码),指示了该注册关系所关心的通道操作,以及通道已经准备好的操作。
图 4-1. 就绪选择相关类的继承关系图
让我们看看SelectableChannel的相关API方法
public abstract class SelectableChannel extends AbstractChannel implements Channel { // This is a partial API listing public abstract SelectionKey // register (Selector sel, int ops) throws // ClosedChannelException; public abstract SelectionKey // register (Selector sel, int ops, Object att) throws // ClosedChannelException; public abstract boolean // isRegistered( ); public abstract SelectionKey keyFor(Selector sel); public abstract int validOps(); public abstract void configureBlocking(boolean block) throws IOException; public abstract boolean isBlocking(); public abstract Object blockingLock(); }
非阻塞特性与多元执行特性的关系是十分密切的——以至于java.nio的架构将两者的API放到了一个类中。
我们已经探讨了如何用上面列出的SelecableChannel的最后三个方法来配置并检查通道的阻塞模式 。通道在被注册到一个选择器上之前,必须先设置为非阻塞模式(通过调用configureBlocking(false))。
图 4-2. 就绪选择相关类的关系
调用可选择通道的register( )方法会将它注册到一个选择器上。如果您试图注册一个处于阻塞状态的通道,register( )将抛出未检查的IllegalBlockingModeException异常。此外,通道一旦被注册,就不能回到阻塞状态。试图这么做的话,将在调用configureBlocking( )方法时将抛出IllegalBlockingModeException异常。
并且,理所当然地,试图注册一个已经关闭的SelectableChannel实例的话,也将抛出ClosedChannelException异常,就像方法原型指示的那样。
在我们进一步了解register( )和SelectableChannel的其他方法之前,让我们先了解一下Selector类的API,以确保我们可以更好地理解这种关系:
public abstract class Selector { public static Selector open() throws IOException; public abstract boolean isOpen(); public abstract void close() throws IOException; public abstract SelectionProvider provider(); public abstract int select() throws IOException; public abstract int select(long timeout) throws IOException; public abstract int selectNow() throws IOException; public abstract void wakeup(); public abstract Set keys(); public abstract Set selectedKeys(); }
尽管SelectableChannel类上定义了register( )方法,还是应该将通道注册到选择器上,而不是另一种方式。选择器维护了一个需要监控的通道的集合。一个给定的通道可以被注册到多于一个的选择器上,而且不需要知道它被注册了那个Selector对象上。将register( )放在SelectableChannel上而不是Selector上,这种做法看起来有点随意。它将返回一个封装了两个对象的关系的选择键对象。重要的是要记住选择器对象控制了被注册到它之上的通道的选择过程。
public abstract class SelectionKey { public static final int OP_READ ; public static final int OP_WRITE ; public static final int OP_CONNECT ; public static final int OP_ACCEPT ; public abstract SelectableChannel channel( ); public abstract Selector selector( ); public abstract void cancel( ); public abstract boolean isValid( ); public abstract int interestOps( ); public abstract void interestOps (int ops); public abstract int readyOps( ); public final boolean isReadable( ) public final boolean isWritable( ) public final boolean isConnectable( ) public final boolean isAcceptable( ) public final Object attach (Object ob) public final Object attachment( ) }
选择器才是提供管理功能的对象,而不是可选择通道对象。选择器对象对注册到它之上的通道执行就绪选择,并管理选择键。
对于键的interest(感兴趣的操作)集合和ready(已经准备好的操作)集合的解释是和特定的通道相关的。每个通道的实现,将定义它自己的选择键类。在register( )方法中构造它并将它传递给所提供的选择器对象。
在下面的章节里,我们将了解关于这三个类的方法的更多细节。
4.1.2 建立选择器
现在您可能仍然感到困惑,您在前面的三个清单中看到了大量的方法,但无法分辨出它们具体做什么,或者它们代表了什么意思。在钻研所有这一切的细节之前,让我们看看一个经典的应用实例。它可以帮助我们将所有东西放到一个特定的上下文中去理解。
为了建立监控三个Socket通道的选择器,您需要做像这样的事情(参见图4-2):
Selector selector = Selector.open(); channel1.register(selector, SelectionKey.OP_READ); channel2.register(selector, SelectionKey.OP_WRITE); channel3.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE); // Wait up to 10 seconds for a channel to become ready readyCount = selector.select(10000);
这些代码创建了一个新的选择器,然后将这三个(已经存在的)socket通道注册到选择器上,而且感兴趣的操作各不相同。select( )方法在将线程置于睡眠状态,直到这些刚兴趣的事情中的操作中的一个发生或者10秒钟的时间过去。
现在让我们看看Selector的API的细节:
public abstract class Selector { // This is a partial API listing public static Selector open( ) throws IOException public abstract boolean isOpen( ); public abstract void close( ) throws IOException; public abstract SelectionProvider provider( ); }
Selector对象是通过调用静态工厂方法open( )来实例化的。选择器不是像通道或流(stream)那样的基本I/O对象:数据从来没有通过它们进行传递。类方法open( )向SPI发出请求,通过默认的SelectorProvider对象获取一个新的实例。通过调用一个自定义的SelectorProvider对象的openSelector( )方法来创建一个Selector实例也是可行的。您可以通过调用provider( )方法来决定由哪个SelectorProvider对象来创建给定的Selector实例。
大多数情况下,您不需要关心SPI;只需要调用open( )方法来创建新的Selector对象。在那些您必须处理它们的罕见的情况下,您可以参考在附录B中总结的通道的SPI包。
继续关于将Select作为I/O对象进行处理的话题的探讨:当您不再使用它时,需要调用close( )方法来释放它可能占用的资源并将所有相关的选择键设置为无效。一旦一个选择器被关闭,试图调用它的大多数方法都将导致ClosedSelectorException。注意ClosedSelectorException是一个非检查(运行时的)错误。您可以通过isOpen( )方法来测试一个选择器是否处于被打开的状态。
我们将结束对Selector 的API的探讨,但现在先让我们看看如何将通道注册到选择器上。下面是一个之前章节中出现过的SelectableChannel 的API的简化版本:
public abstract class SelectableChannel extends AbstractChannel implements Channel { // This is a partial API listing public abstract SelectionKey register(Selector sel, int ops) throws ClosedChannelException; public abstract SelectionKey register(Selector sel, int ops, Object att) throws ClosedChannelException; public abstract boolean isRegistered( ); public abstract SelectionKey keyFor(Selector sel); public abstract int validOps( ); }
就像之前提到的那样,register( )方法位于SelectableChannel类,尽管通道实际上是被注册到选择器上的。您可以看到register( )方法接受一个Selector对象作为参数,以及一个名为ops的整数参数。第二个参数表示所关心的通道操作。这是一个表示选择器在检查通道就绪状态时需要关心的操作的比特掩码。特定的操作比特值在SelectonKey类中被定义为public static字段。
在JDK 1.4中,有四种被定义的可选择操作:读(read),写(write),连接(connect)和接受(accept)。
并非所有的操作都在所有的可选择通道上被支持。例如,SocketChannel不支持accept。试图注册不支持的操作将导致IllegalArgumentException。您可以通过调用validOps( )方法来获取特定的通道所支持的操作集合。我们可以在第三章中探讨的socket通道类中看到这些方法。
选择器包含了注册到它们之上的通道的集合。在任意给定的时间里,对于一个给定的选择器和一个给定的通道而言,只有一种注册关系是有效的。但是,将一个通道注册到多于一个的选择器上允许的。这么做的话,在更新interest集合为指定的值的同时,将返回与之前相同的选择键。实际上,后续的注册都只是简单地将与之前的注册关系相关的键进行更新.
一个例外的情形是当您试图将一个通道注册到一个相关的键已经被取消的选择器上,而通道仍然处于被注册的状态的时候。通道不会在键被取消的时候立即注销。直到下一次操作发生为止,它们仍然会处于被注册的状态(见4.3小节)。在这种情况下,未检查的CancelledKeyException将会被抛出。请务必在键可能被取消的情况下检查SelectionKey对象的状态。
在之前的清单中,您可能已经注意到了register( )的第二个版本,这个版本接受object参数。这是一个方便的方法,可以传递您提供的对象引用,在调用新生成的选择键的attach( )方法时会将这个对象引用返回给您。我们将会在下一节更进一步地了解SelectionKey的API。
一个单独的通道对象可以被注册到多个选择器上。可以调用isRegistered( )方法来检查一个通道是否被注册到任何一个选择器上。这个方法没有提供关于通道被注册到哪个选择器上的信息,而只能知道它至少被注册到了一个选择器上。此外,在一个键被取消之后,直到通道被注销为止,可能有时间上的延迟。这个方法只是一个提示,而不是确切的答案。
任何一个通道和选择器的注册关系都被封装在一个SelectionKey对象中。keyFor( )方法将返回与该通道和指定的选择器相关的键。如果通道被注册到指定的选择器上,那么相关的键将被返回。如果它们之间没有注册关系,那么将返回null。
4.2 使用选择键
让我们看看SelectionKey类的API:
package java.nio.channels; public abstract class SelectionKey { public static final int OP_READ public static final int OP_WRITE public static final int OP_CONNECT public static final int OP_ACCEPT public abstract SelectableChannel channel( ); public abstract Selector selector( ); public abstract void cancel( ); public abstract boolean isValid( ); public abstract int interestOps( ); public abstract void interestOps (int ops); public abstract int readyOps( ); public final boolean isReadable( ) public final boolean isWritable( ) public final boolean isConnectable( ) public final boolean isAcceptable( ) public final Object attach (Object ob) public final Object attachment( ) }
就像之前提到的那样,一个键表示了一个特定的通道对象和一个特定的选择器对象之间的注册关系。您可以看到前两个方法中反映了这种关系。channel( )方法返回与该键相关的SelectableChannel对象,而selector( )则返回相关的Selector对象。这没有什么令人惊奇的。
键对象表示了一种特定的注册关系。当应该终结这种关系的时候,可以调用SelectionKey对象的cancel( )方法。可以通过调用isValid( )方法来检查它是否仍然表示一种有效的关系。当键被取消时,它将被放在相关的选择器的已取消的键的集合里。注册不会立即被取消,但键会立即失效(参见4.3节)。当再次调用select( )方法时(或者一个正在进行的select()调用结束时),已取消的键的集合中的被取消的键将被清理掉,并且相应的注销也将完成。通道会被注销,而新的SelectionKey将被返回。
当通道关闭时,所有相关的键会自动取消(记住,一个通道可以被注册到多个选择器上)。当选择器关闭时,所有被注册到该选择器的通道都将被注销,并且相关的键将立即被无效化(取消)。一旦键被无效化,调用它的与选择相关的方法就将抛出CancelledKeyException。
一个SelectionKey对象包含两个以整数形式进行编码的比特掩码:一个用于指示那些通道/选择器组合体所关心的操作(instrest集合),另一个表示通道准备好要执行的操作(ready集合)。当前的interest集合可以通过调用键对象的interestOps( )方法来获取。最初,这应该是通道被注册时传进来的值。这个interset集合永远不会被选择器改变,但您可以通过调用interestOps( )方法并传入一个新的比特掩码参数来改变它。interest集合也可以通过将通道注册到选择器上来改变(实际上使用一种迂回的方式调用interestOps( )),就像4.1.2小节中描的那样。当相关的Selector上的select( )操作正在进行时改变键的interest集合,不会影响那个正在进行的选择操作。所有更改将会在select( )的下一个调用中体现出来。
可以通过调用键的readyOps( )方法来获取相关的通道的已经就绪的操作。ready集合是interest集合的子集,并且表示了interest集合中从上次调用select( )以来已经就绪的那些操作。例如,下面的代码测试了与键关联的通道是否就绪。如果就绪,就将数据读取出来,写入一个缓冲区,并将它送到一个consumer(消费者)方法中。
if ((key.readyOps( ) & SelectionKey.OP_READ) != 0) { myBuffer.clear( ); key.channel( ).read (myBuffer); doSomethingWithBuffer (myBuffer.flip( )); }
就像之前提到过的那样,有四个通道操作可以被用于测试就绪状态。您可以像上面的代码那样,通过测试比特掩码来检查这些状态,但SelectionKey类定义了四个便于使用的布尔方法来为您测试这些比特值:isReadable( ),isWritable( ),isConnectable( ), 和isAcceptable( )。每一个方法都与使用特定掩码来测试readyOps( )方法的结果的效果相同。例如:
if (key.isWritable( ))
等价于:
if ((key.readyOps( ) & SelectionKey.OP_WRITE) != 0)
这四个方法在任意一个SelectionKey对象上都能安全地调用。不能在一个通道上注册一个它不支持的操作,这种操作也永远不会出现在ready集合中。调用一个不支持的操作将总是返回false,因为这种操作在该通道上永远不会准备好。需要注意的是,通过相关的选择键的readyOps( )方法返回的就绪状态指示只是一个提示,不是保证。底层的通道在任何时候都会不断改变。其他线程可能在通道上执行操作并影响它的就绪状态。同时,操作系统的特点也总是需要考虑的。
SelectionKey对象包含的ready集合与最近一次选择器对所注册的通道所作的检查相同。而每个单独的通道的就绪状态会同时改变。
您可能会从SelectionKey的API中注意到尽管有获取ready集合的方法,但没有重新设置那个集合的成员方法。事实上,您不能直接改变键的ready集合。在下一节里,也就是描述选择过程时,我们将会看到选择器和键是如何进行交互,以提供实时更新的就绪指示的。
让我们试验一下SelectionKey的API中剩下的两个方法:
public abstract class SelectionKey { // This is a partial API listing public final Object attach (Object ob) public final Object attachment( ) }
这两个方法允许您在键上放置一个“附件”,并在后面获取它。这是一种允许您将任意对象与键关联的便捷的方法。这个对象可以引用任何对您而言有意义的对象,例如业务对象、会话句柄、其他通道等等。这将允许您遍历与选择器相关的键,使用附加在上面的对象句柄作为引用来获取相关的上下文。
attach( )方法将在键对象中保存所提供的对象的引用。SelectionKey类除了保存它之外,不会将它用于任何其他用途。任何一个之前保存在键中的附件引用都会被替换。可以使用null值来清除附件。可以通过调用attachment( )方法来获取与键关联的附件句柄。如果没有附件,或者显式地通过null方法进行过设置,这个方法将返回null。
如果选择键的存续时间很长,但您附加的对象不应该存在那么长时间,请记得在完成后清理附件。否则,您附加的对象将不能被垃圾回收,您将会面临内存泄漏问题。
SelectableChannel类的一个register( )方法的重载版本接受一个Object类型的参数。这是一个方便您在注册时附加一个对象到新生成的键上的方法。以下代码:
SelectionKey key = channel.register (selector, SelectionKey.OP_READ, myObject);
等价于:
SelectionKey key = channel.register (selector, SelectionKey.OP_READ);
key.attach (myObject);
关于SelectionKey的最后一件需要注意的事情是并发性。总体上说,SelectionKey对象是线程安全的,但知道修改interest集合的操作是通过Selector对象进行同步的是很重要的。这可能会导致interestOps( )方法的调用会阻塞不确定长的一段时间。选择器所使用的锁策略(例如是否在整个选择过程中保持这些锁)是依赖于具体实现的。幸好,这种多元处理能力被特别地设计为可以使用单线程来管理多个通道。被多个线程使用的选择器也只会在系统特别复杂时产生问题。坦白地说,如果您在多线程中共享选择器时遇到了同步的问题,也许您需要重新思考一下您的设计。
我们已经探讨了SelectionKey的API,但我们还没有谈完选择键的一切——远远没有。让我们进一步了解如何使用选择器管理键吧
4.3 使用选择器
在详细了解API之前,您需要知道一点和Selector内部工作原理相关的知识。就像上面探讨的那样,选择器维护着注册过的通道的集合,并且这些注册关系中的任意一个都是封装在SelectionKey对象中的。每一个Selector对象维护三个键的集合:
public abstract class Selector { // This is a partial API listing public abstract Set keys( ); public abstract Set selectedKeys( ); public abstract int select( ) throws IOException; public abstract int select (long timeout) throws IOException; public abstract int selectNow( ) throws IOException; public abstract void wakeup( ); }
已注册的键的集合(Registered key set)
与选择器关联的已经注册的键的集合。并不是所有注册过的键都仍然有效。这个集合通过keys( )方法返回,并且可能是空的。这个已注册的键的集合不是可以直接修改的;试图这么做的话将引java.lang.UnsupportedOperationException。
已选择的键的集合(Selected key set)
已注册的键的集合的子集。这个集合的每个成员都是相关的通道被选择器(在前一个选择操作中)判断为已经准备好的,并且包含于键的interest集合中的操作。这个集合通过selectedKeys( )方法返回(并有可能是空的)。
不要将已选择的键的集合与ready集合弄混了。这是一个键的集合,每个键都关联一个已经准备好至少一种操作的通道。每个键都有一个内嵌的ready集合,指示了所关联的通道已经准备好的操作。
键可以直接从这个集合中移除,但不能添加。试图向已选择的键的集合中添加元素将抛出java.lang.UnsupportedOperationException。
已取消的键的集合(Cancelled key set)
已注册的键的集合的子集,这个集合包含了cancel( )方法被调用过的键(这个键已经被无效化),但它们还没有被注销。这个集合是选择器对象的私有成员,因而无法直接访问。
在一个刚初始化的Selector对象中,这三个集合都是空的。
Selector类的核心是选择过程。这个名词您已经在之前看过多次了——现在应该解释一下了。基本上来说,选择器是对select( )、poll( )等本地调用(native call)或者类似的操作系统特定的系统调用的一个包装。但是Selector所作的不仅仅是简单地向本地代码传送参数。它对每个选择操作应用了特定的过程。对这个过程的理解是合理地管理键和它们所表示的状态信息的基础。
选择操作是当三种形式的select( )中的任意一种被调用时,由选择器执行的。不管是哪一种形式的调用,下面步骤将被执行:
- 1.已取消的键的集合将会被检查。如果它是非空的,每个已取消的键的集合中的键将从另外两个集合中移除,并且相关的通道将被注销。这个步骤结束后,已取消的键的集合将是空的。
- 2.已注册的键的集合中的键的interest集合将被检查。在这个步骤中的检查执行过后,对interest集合的改动不会影响剩余的检查过程。一旦就绪条件被定下来,底层操作系统将会进行查询,以确定每个通道所关心的操作的真实就绪状态。依赖于特定的select( )方法调用,如果没有通道已经准备好,线程可能会在这时阻塞,通常会有一个超时值。直到系统调用完成为止,这个过程可能会使得调用线程睡眠一段时间,然后当前每个通道的就绪状态将确定下来。对于那些还没准备好的通道将不会执行任何的操作。对于那些操作系统指示至少已经准备好interest集合中的一种操作的通道,将执行以下两种操作中的一种:
- 直到系统调用完成为止,这个过程可能会使得调用线程睡眠一段时间,然后当前每个通道的就绪状态将确定下来。对于那些还没准备好的通道将不会执行任何的操作。对于那些操作系统指示至少已经准备好interest集合中的一种操作的通道,将执行以下两种操作中的一种:
- 如果通道的键还没有处于已选择的键的集合中,那么键的ready集合将被清空,然后表示操作系统发现的当前通道已经准备好的操作的比特掩码将被设置。
- 否则,也就是键在已选择的键的集合中。键的ready集合将被表示操作系统发现的当前已经准备好的操作的比特掩码更新。所有之前的已经不再是就绪状态的操作不会被清除。事实上,所有的比特位都不会被清理。由操作系统决定的ready集合是与之前的ready集合按位分离的,一旦键被放置于选择器的已选择的键的集合中,它的ready集合将是累积的。比特位只会被设置,不会被清理。
- 3. 步骤2可能会花费很长时间,特别是所激发的线程处于休眠状态时。与该选择器相关的键可能会同时被取消。当步骤2结束时,步骤1将重新执行,以完成任意一个在选择进行的过程中,键已经被取消的通道的注销。
- 4.select操作返回的值是ready集合在步骤2中被修改的键的数量,而不是已选择的键的集合中的通道的总数。返回值不是已准备好的通道的总数,而是从上一个select( )调用之后进入就绪状态的通道的数量。之前的调用中就绪的,并且在本次调用中仍然就绪的通道不会被计入,而那些在前一次调用中已经就绪但已经不再处于就绪状态的通道也不会被计入。这些通道可能仍然在已选择的键的集合中,但不会被计入返回值中。返回值可能是0。使用内部的已取消的键的集合来延迟注销,是一种防止线程在取消键时阻塞,并防止与正在进行的选择操作冲突的优化。注销通道是一个潜在的代价很高的操作,这可能需要重新分配资源(请记住,键是与通道相关的,并且可能与它们相关的通道对象之间有复杂的交互)。清理已取消的键,并在选择操作之前和之后立即注销通道,可以消除它们可能正好在选择的过程中执行的潜在棘手问题。这是另一个兼顾健壮性的折中方案。
Selector类的select( )方法有以下三种不同的形式:
这三种select的形式,仅仅在它们在所注册的通道当前都没有就绪时,是否阻塞的方面有所不同。
最简单的没有参数的形式可以用如下方式调用:
这种调用在没有通道就绪时将无限阻塞。一旦至少有一个已注册的通道就绪,选择器的选择键就会被更新,并且每个就绪的通道的ready集合也将被更新。返回值将会是已经确定就绪的通道的数目。正常情况下,这些方法将返回一个非零的值,因为直到一个通道就绪前它都会阻塞。但是它也可以返回非0值,如果选择器的wakeup( )方法被其他线程调用。
有时您会想要限制线程等待通道就绪的时间。这种情况下,可以使用一个接受一个超时参数的select( )方法的重载形式:
这种调用与之前的例子完全相同,除了如果在您提供的超时时间(以毫秒计算)内没有通道就绪时,它将返回0。如果一个或者多个通道在时间限制终止前就绪,键的状态将会被更新,并且方法会在那时立即返回。将超时参数指定为0表示将无限期等待,那么它就在各个方面都等同于使用无参数版本的select( )了。
就绪选择的第三种也是最后一种形式是完全非阻塞的:
int n = selector.selectNow( );
selectNow()方法执行就绪检查过程,但不阻塞。如果当前没有通道就绪,它将立即返回0。
4.3.2 停止选择过程
Selector的API中的最后一个方法,wakeup( ),提供了使线程从被阻塞的select( )方法中优雅地退出的能力:
public abstract class Selector { // This is a partial API listing public abstract void wakeup( ); }
有三种方式可以唤醒在select( )方法中睡眠的线程:
调用wakeup( )
调用Selector对象的wakeup( )方法将使得选择器上的第一个还没有返回的选择操作立即返回。如果当前没有在进行中的选择,那么下一次对select( )方法的一种形式的调用将立即返回。后续的选择操作将正常进行。在选择操作之间多次调用wakeup( )方法与调用它一次没有什么不同。
有时这种延迟的唤醒行为并不是您想要的。您可能只想唤醒一个睡眠中的线程,而使得后续的选择继续正常地进行。您可以通过在调用wakeup( )方法后调用selectNow( )方法来绕过这个问题。尽管如此,如果您将您的代码构造为合理地关注于返回值和执行选择集合,那么即使下一个select( )方法的调用在没有通道就绪时就立即返回,也应该不会有什么不同。不管怎么说,您应该为可能发生的事件做好准备。
调用close( )
如果选择器的close( )方法被调用,那么任何一个在选择操作中阻塞的线程都将被唤醒,就像wakeup( )方法被调用了一样。与选择器相关的通道将被注销,而键将被取消。
调用interrupt( )
如果睡眠中的线程的interrupt( )方法被调用,它的返回状态将被设置。如果被唤醒的线程之后将试图在通道上执行I/O操作,通道将立即关闭,然后线程将捕捉到一个异常。这是由于在第三章中已经探讨过的通道的中断语义。使用wakeup( )方法将会优雅地将一个在select( )方法中睡眠的线程唤醒。如果您想让一个睡眠的线程在直接中断之后继续执行,需要执行一些步骤来清理中断状态(参见Thread.interrupted( )的相关文档)。
Selector对象将捕捉InterruptedException异常并调用wakeup( )方法。请注意这些方法中的任意一个都不会关闭任何一个相关的通道。中断一个选择器与中断一个通道是不一样的(参见3.3节)。选择器不会改变任意一个相关的通道,它只会检查它们的状态。当一个在select( )方法中睡眠的线程中断时,对于通道的状态而言,是不会产生歧义的。
4.3.3 管理选择键
既然我们已经理解了问题的各个部分是怎样结合在一起的,那么是时候看看它们在正常的使用中是如何交互的了。为了有效地利用选择器和键提供的信息,合理地管理键是非常重要的。
选择是累积的。一旦一个选择器将一个键添加到它的已选择的键的集合中,它就不会移除这个键。并且,一旦一个键处于已选择的键的集合中,这个键的ready集合将只会被设置,而不会被清理。乍一看,这好像会引起麻烦,因为选择操作可能无法表现出已注册的通道的正确状态。它提供了极大的灵活性,但把合理地管理键以确保它们表示的状态信息不会变得陈旧的任务交给了程序员。
合理地使用选择器的秘诀是理解选择器维护的选择键集合所扮演的角色。(参见4.3.1小节,特别是选择过程的第二步。)最重要的部分是当键已经不再在已选择的键的集合中时将会发生什么。当通道上的至少一个感兴趣的操作就绪时,键的ready集合就会被清空,并且当前已经就绪的操作将会被添加到ready集合中。该键之后将被添加到已选择的键的集合中。
清理一个SelectKey的ready集合的方式是将这个键从已选择的键的集合中移除。选择键的就绪状态只有在选择器对象在选择操作过程中才会修改。处理思想是只有在已选择的键的集合中的键才被认为是包含了合法的就绪信息的。这些信息将在键中长久地存在,直到键从已选择的键的集合中移除,以通知选择器您已经看到并对它进行了处理。如果下一次通道的一些感兴趣的操作发生时,键将被重新设置以反映当时通道的状态并再次被添加到已选择的键的集合中。
这种框架提供了很多灵活性。通常的做法是在选择器上调用一次select操作(这将更新已选择的键的集合),然后遍历selectKeys( )方法返回的键的集合。在按顺序进行检查每个键的过程中,相关的通道也根据键的就绪集合进行处理。然后键将从已选择的键的集合中被移除(通过在Iterator对象上调用remove( )方法),然后检查下一个键。完成后,通过再次调用select( )方法重复这个循环。
例4-1中的代码是典型的服务器的例子。
/** * */ package test.noi.select; import java.net.InetSocketAddress; import java.net.ServerSocket; import java.nio.ByteBuffer; import java.nio.channels.SelectableChannel; import java.nio.channels.SelectionKey; import java.nio.channels.Selector; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.util.Iterator; /** * * Simple echo-back server which listens for incoming stream connections and * * echoes back whatever it reads. A single Selector object is used to listen to * * the server socket (to accept new connections) and all the active socket * * channels. * * @author Ron Hitchens (ron@ronsoft.com) */ public class SelectSockets { public static int PORT_NUMBER = 1234; public static void main(String[] argv) throws Exception { new SelectSockets().go(argv); } public void go(String[] argv) throws Exception { int port = PORT_NUMBER; if (argv.length > 0) { // Override default listen port port = Integer.parseInt(argv[0]); } System.out.println("Listening on port " + port); ServerSocketChannel serverChannel = ServerSocketChannel.open(); // Get the associated ServerSocket to bind it with ServerSocket serverSocket = serverChannel.socket(); // Create a new Selector for use below Selector selector = Selector.open(); // Set the port the server channel will listen to serverSocket.bind(new InetSocketAddress(port)); // Set nonblocking mode for the listening socket serverChannel.configureBlocking(false); // Register the ServerSocketChannel with the Selector serverChannel.register(selector, SelectionKey.OP_ACCEPT); while (true) { // This may block for a long time. Upon returning, the // selected set contains keys of the ready channels. int n = selector.select(); if (n == 0) { continue; // nothing to do } // Get an iterator over the set of selected keys Iterator it = selector.selectedKeys().iterator(); // Look at each key in the selected set while (it.hasNext()) { SelectionKey key = (SelectionKey) it.next(); // Is a new connection coming in? if (key.isAcceptable()) { ServerSocketChannel server = (ServerSocketChannel) key .channel(); SocketChannel channel = server.accept(); registerChannel(selector, channel, SelectionKey.OP_READ); sayHello(channel); } // Is there data to read on this channel? if (key.isReadable()) { readDataFromSocket(key); } // Remove key from selected set; it's been handled it.remove(); } } } /** * * Register the given channel with the given selector for the given * * operations of interest */ protected void registerChannel(Selector selector, SelectableChannel channel, int ops) throws Exception { if (channel == null) { return; // could happen }// Set the new channel nonblocking channel.configureBlocking(false); // Register it with the selector channel.register(selector, ops); } // ---------------------------------------------------------- // Use the same byte buffer for all channels. A single thread is // servicing all the channels, so no danger of concurrent acccess. private ByteBuffer buffer = ByteBuffer.allocateDirect(1024); /** * * Sample data handler method for a channel with data ready to read. * * @param * key * A SelectionKey object associated with a channel determined by * the * selector to be ready for reading. If the channel returns * * * an EOF condition, it is closed here, which automatically * invalidates * the associated key. The selector will then * de-register the channel on * the next select call. */ protected void readDataFromSocket(SelectionKey key) throws Exception { SocketChannel socketChannel = (SocketChannel) key.channel(); int count; buffer.clear(); // Empty buffer // Loop while data is available;channel is nonblocking while ((count = socketChannel.read(buffer)) > 0) { buffer.flip(); // Make buffer readable // Send the data; don't assume it goes all at once while (buffer.hasRemaining()) { socketChannel.write(buffer); } // WARNING: the above loop is evil. Because // it's writing back to the same nonblocking // channel it read the data from, this code can // potentially spin in a busy loop. In real life // you'd do something more useful than this. buffer.clear(); // Empty buffer } if (count < 0) { // Close channel on EOF, invalidates the key socketChannel.close(); } } /** * * Spew a greeting to the incoming client connection. * * @param channel * * The newly connected SocketChannel to say hello to. */ private void sayHello(SocketChannel channel) throws Exception { buffer.clear(); buffer.put("Hi there! ".getBytes()); buffer.flip(); channel.write(buffer); } }
例 4-1实现了一个简单的服务器。它创建了ServerSocketChannel和Selector对象,并将通道注册到选择器上。我们不在注册的键中保存服务器socket的引用,因为它永远不会被注销。这个无限循环在最上面先调用了select( ),这可能会无限期地阻塞。当选择结束时,就遍历选择键并检查已经就绪的通道。
如果一个键指示与它相关的通道已经准备好执行一个accecpt( )操作,我们就通过键获取关联的通道,并将它转换为SeverSocketChannel对象。我们都知道这么做是安全的,因为只有ServerSocketChannel支持OP_ACCEPT操作。我们也知道我们的代码只把对一个单一的ServerSocketChannel对象的OP_ACCEPT操作进行了注册。通过对服务器socket通道的引用,我们调用了它的accept( )方法,来获取刚到达的socket的句柄。返回的对象的类型是SocketChannel,也是一个可选择的通道类型。这时,与创建一个新线程来从新的连接中读取数
据不同,我们只是简单地将socket同多注册到选择器上。我们通过传入OP_READ标记,告诉选择器我们关心新的socket通道什么时候可以准备好读取数据。
如果键指示通道还没有准备好执行accept( ),我们就检查它是否准备好执行read( )。任何一个这么指示的socket通道一定是之前ServerSocketChannel创建的SocketChannel对象之一,并且被注册为只对读操作感兴趣。对于每个有数据需要读取的socket通道,我们调用一个公共的方法来读取并处理这个带有数据的socket。需要注意的是这个公共方法需要准备好以非阻塞的方式处理socket上的不完整的数据。它需要迅速地返回,以其他带有后续输入的通道能够及时地得到处理。例4-1中只是简单地对数据进行响应,将数据写回socket,传回给发送者。
在循环的底部,我们通过调用Iterator(迭代器)对象的remove()方法,将键从已选择的键的集合中移除。键可以直接从selectKeys()返回的Set中移除,但同时需要用Iterator来检查集合,您需要使用迭代器的remove()方法来避免破坏迭代器内部的状态。