NIO 选择器 Selector

Posted heapStark

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了NIO 选择器 Selector相关的知识,希望对你有一定的参考价值。

  

  选择器提供选择执行已经就绪的任务的能力,这使得多元 I/O 成为可能。就像在第一章中描述的那样,就绪选择和多元执行使得单线程能够有效率地同时管理多个 I/O 通道(Channels)。C/C++代码的工具箱中,许多年前就已经有 select()和 poll()这两个POSIX(可移植性操作系统接口)系统调用可供使用了。许过操作系统也提供相似的功能,但对Java 程序员来说,就绪选择功能直到 JDK 1.4 才成为可行的方案。

  从最基础的层面来看,选择器提供了询问通道是否已经准备好执行每个I/0操作的能力。例如,我们需要了解一个SocketChannel对象是否还有更多的字节需要读取,或者我们需要知道ServerSocketChannel是否有需要准备接受的连接。在与SelectableChannel联合使用时,选择器提供了这种服务,但这里面有更多的事情需要去了解。就绪选择的真正价值在于潜在的大量的通道可以同时进行就绪状态的检查。调用者可以轻松地决定多个通道中的哪一个准备好要运行。有两种方式可以选择:被激发的线程可以处于休眠状态,直到一个或者多个注册到选择器的通道就绪,或者它也可以周期性地轮询选择器,看看从上次检查之后,是否有通道处于就绪状态。  乍一看,好像只要非阻塞模式就可以模拟就绪检查功能,但实际上还不够。非阻塞模式同时还会执行您请求的任务,或指出它无法执行这项任务。这与检查它是否能够执行某种类型的操作是不同的。举个例子,如果您试图执行非阻塞操作,并且也执行成功了,您将不仅仅发现read( )是可以执行的,同时您也已经读入了一些数据。就下来您就需要处理这些数据了。效率上的要求需要将检查就绪的代码和处理数据的代码分离开来,至少这么做会很复杂。即使简单地询问每个通道是否已经就绪的方法是可行的,代码需要遍历每一个候选的通道并按顺序进行检查的时候,仍然是有问题的。这会使得在检查每个通道是否就绪时都至少进行一次系统调用,这种代价是十分昂贵的,但是主要的问题是,这种检查不是原子性的。列表中的一个通道都有可能在它被检查之后就绪,但直到下一次轮询为止,您并不会觉察到这种情况。最糟糕的是,除了不断地遍历列表之外将别无选择。因为无法在某个您感兴趣的通道就绪时得到通知

 

  真正的就绪选择必须由操作系统来做。操作系统的一项最重要的功能就是处理I/O请求并通知各个线程它们的数据已经准备好了。选择器类提供了这种抽象,使得Java代码能够以可移植的方式,请求底层的操作系统提供就绪选择服务

执行就绪选择的三个核心类:可选择通道(SelectableChannel) ,选择器(Selector),选择键(SelectionKey)

  可选择通道(SelectableChannel):

    核心方法:

configureBlocking(),isBlocking(),blockingLock()三个方法来配置并检查通道的阻塞模式,通道在被注册到一个选择器上之前,必须先设置为非阻塞模式(通过调用configureBlocking(false))。

  调用可选择通道的register( )方法会将它注册到一个选择器上。如果您试图注册一个处于阻塞状态的通道,register( )将抛出未检查的IllegalBlockingModeException异常。

异常检查代码如下:

            if (!isOpen())
                throw new ClosedChannelException();
            if ((ops & ~validOps()) != 0)
                throw new IllegalArgumentException();
            if (blocking)
                throw new IllegalBlockingModeException();

 

此外,通道一旦被注册,就不能回到阻塞状态。试图这么做的话,将在调用configureBlocking( )方法时将抛出IllegalBlockingModeException异常,configureBlocking代码如下:

 

            if (block && haveValidKeys())
                throw new IllegalBlockingModeException();

选择器(Selector):

Selector类的API如下:

 建立选择器的步骤:

Selector selector = Selector.open();
socketChannel.register(selector, SelectionKey.OP_CONNECT | SelectionKey.OP_READ);
long readyCount = selector.select();

   调用SelectableChannel的register( )方法会将它注册到一个选择器,选择器维护了一个需要监控的通道的集合。一个给定的通道可以被注册到多于一个的选择器上,而且不需要知道它被注册了那个Selector对象上。将register( )它将返回一个封装了两个对象(SelectableChannel和Selector)的关系的选择键(SelectionKey)对象。

  register( )方法详细代码如下:

    public final SelectionKey register(Selector sel, int ops,
                                       Object att)
        throws ClosedChannelException
    {
        synchronized (regLock) {
            if (!isOpen())
                throw new ClosedChannelException();
            if ((ops & ~validOps()) != 0)
                throw new IllegalArgumentException();
            if (blocking)
                throw new IllegalBlockingModeException();
            SelectionKey k = findKey(sel);
            if (k != null) {
                k.interestOps(ops);
                k.attach(att);
            }
            if (k == null) {
                // New registration
                synchronized (keyLock) {
                    if (!isOpen())
                        throw new ClosedChannelException();
                    // 新注册一个通道到选择器对象上,调用Selector的register()方法
                    k = ((AbstractSelector)sel).register(this, ops, att);
                    addKey(k);
                }
            }
            return k;
        }
    }    
AbstractSelector的register()方法如下:
    protected final SelectionKey register(AbstractSelectableChannel var1, int var2, Object var3) {
        if(!(var1 instanceof SelChImpl)) {
            throw new IllegalSelectorException();
        } else {
             //返回绑定当前Selector和SelectableChannel的SelectionKey
            SelectionKeyImpl var4 = new SelectionKeyImpl((SelChImpl)var1, this);
            var4.attach(var3);
            Set var5 = this.publicKeys;
            synchronized(this.publicKeys) {
                this.implRegister(var4);
            }
             //注册感兴趣操作
            var4.interestOps(var2);
            return var4;
        }
    }    

 

选择键(SelectionKey):

  任何一个通道和选择器的注册关系都被封装在一个SelectionKey对象中,上面的AbstractSelector的register()方法即返回对应的Key。对应的SelectionKeyImpl(SelChImpl var1, SelectorImpl var2)方法如下:

    SelectionKeyImpl(SelChImpl var1, SelectorImpl var2) {
        this.channel = var1;
        this.selector = var2;
    }

   键对象表示了一种特定的注册关系。当应该终结这种关系的时候,可以调用SelectionKey对象的cancel( )方法。可以通过调用isValid( )方法来检查它是否仍然表示一种有效的关系。当键被取消时,它将被放在相关的选择器的已取消的键的集合里。注册不会立即被取消,但键会立即失效。当再次调用select( )方法时(或者一个正在进行的select()调用结束时),已取消的键的集合中的被取消的键将被清理掉,并且相应的注销也将完成。

  当通道关闭时,所有相关的键会自动取消(记住,一个通道可以被注册到多个选择器上)。当选择器关闭时,所有被注册到该选择器的通道都将被注销,并且相关的键将立即被无效化(取消)。一旦键被无效化,调用它的与选择相关的方法就将抛出CancelledKeyException。

  一个SelectionKey对象包含两个以整数形式进行编码的比特掩码:一个用于指示那些通道/选择器组合体所关心的操作(instrest集合),另一个表示通道准备好要执行的操作(ready集合)。当前的interest集合可以通过调用键对象的interestOps( )方法来获取。最初,这应该是通道被注册时传进来的值。这个interset集合永远不会被选择器改变,但您可以通过调用interestOps( )方法并传入一个新的比特掩码参数来改变它。interest集合也可以通过将通道注册到选择器上来改变(实际上使用一种迂回的方式调用interestOps( )),就像4.1.2小节中描的那样。当相关的Selector上的select( )操作正在进行时改变键的interest集合,不会影响那个正在进行的选择操作。所有更改将会在select( )的下一个调用中体现出来。可以通过调用键的readyOps( )方法来获取相关的通道的已经就绪的操作。ready集合是interest集合的子集,并且表示了interest集合中从上次调用select( )以来已经就绪的那些操作。

其他方法:

  attach( )方法将在键对象中保存所提供的对象的引用。SelectionKey类除了保存它之外,不会将它用于任何其他用途。任何一个之前保存在键中的附件引用都会被替换。可以使用null值来清除附件。可以通过调用attachment( )方法来获取与键关联的附件句柄。

  SelectableChannel类的一个register( )方法的重载版本接受一个Object类型的参数。这是一个方便您在注册时附加一个对象到新生成的键上的方法,

serverSocketChannel.register(selector1, SelectionKey.OP_ACCEPT,new Date());

使用选择器:

   Selector具有如下核心方法:

        selector.keys();
        selector.select();
        selector.selectedKeys();

  Selector维护三个关于SelectionKey的集合,分别为(Registered key set),(Selected key set),(Cancelled key set)。

  Selector类的核心是选择过程,基本上来说,选择器是对select( )、poll( )等本地调用(native call)或者类似的操作系统特定的系统调用的一个包装。但是Selector所作的不仅仅是简单地向本地代码传送参数。它对每个选择操作应用了特定的过程。对这个过程的理解是合理地管理键和它们所表示的状态信息的基础。选择操作是当三种形式的select( )中的任意一种被调用时,由选择器执行的。不管是哪一种形式的调用,下面步骤将被执行:

  1.已取消的键的集合将会被检查。如果它是非空的,每个已取消的键的集合中的键将从另外两个集合中移除,并且相关的通道将被注销。这个步骤结束后,已取消的键的集合将是空的。
  2.已注册的键的集合中的键的interest集合将被检查。在这个步骤中的检查执行过后,对interest集合的改动不会影响剩余的检查过程。

  一旦就绪条件被定下来,底层操作系统将会进行查询,以确定每个通道所关心的操作(interestOps)的真实就绪状态。依赖于特定的select( )方法调用,如果没有通道已经准备好,线程可能会在这时阻塞,直到系统调用完成为止,这个过程可能会使得调用线程睡眠一段时间,通常会有一个超时值。然后当前每个通道的就绪状态将确定下来。对于那些还没准备好的通道将不会执行任何的操作。对于那些操作系统指示至少已经准备好interest集合中的一种操作的通道,将执行以下两种操作中的一种:

  • a.如果通道的键还没有处于已选择的键的集合中,那么键的readyOps集合将被清空,然后表示操作系统发现的当前通道已经准备好的操作的比特掩码将被设置。
  • b.否则,也就是键在已选择的键的集合中。键的readyOps集合将被表示操作系统发现的当前已经准备好的操作的比特掩码更新(增加readyOps)。所有之前的已经不再是就绪状态的操作不会被清除。事实上,所有的比特位都不会被清理。由操作系统决定的ready集合是与之前的ready集合按位分离的,一旦键被放置于选择器的已选择的键的集合中,它的ready集合将是累积的。比特位只会被设置,不会被清理(键在已选择的键的集合中)。

  3.步骤2可能会花费很长时间,特别是所激发的线程处于休眠状态时。与该选择器相关的键可能会同时被取消。当步骤2结束时,步骤1将重新执行,以完成任意一个在选择进行的过程中,键已经被取消的通道的注销。

  4.select操作返回的值是ready集合在步骤2中被修改的键的数量,而不是已选择的键的集合中的通道的总数。返回值不是已准备好的通道的总数,而是从上一个select( )调用之后进入就绪状态的通道的数量。之前的调用中就绪的,并且在本次调用中仍然就绪的通道不会被计入,而那些在前一次调用中已经就绪但已经不再处于就绪状态的通道也不会被计入。这些通道可能仍然在已选择的键的集合中,但不会被计入返回值中。返回值可能是0。

  使用内部的已取消的键的集合来延迟注销,是一种防止线程在取消键时阻塞,并防止与正在进行的选择操作冲突的优化。注销通道是一个潜在的代价很高的操作,这可能需要重新分配资源(请记住,键是与通道相关的,并且可能与它们相关的通道对象之间有复杂的交互)。清理已取消的键,并在选择操作之前和之后立即注销通道,可以消除它们可能正好在选择的过程中执行的潜在棘手问题。这是另一个兼顾健壮性的折中方案。

  Selector类的select( )方法有以下三种不同的形式:

        selector.selectNow();//执行就绪检查过程,但不阻塞。如果当前没有通道就绪,它将立即返回0
        selector.select(1000);//阻塞十秒
        selector.select();//在没有通道就绪时将无限阻塞

  正常情况下,两种阻塞方法将返回一个非零的值,因为直到一个通道就绪前它都会阻塞。但是它也可以返回非0值,如果选择器的wakeup( )方法被其他线程调用。

三种中断selector()的方法:

  1. 调用Selector对象的wakeup( )方法将使得选择器上的第一个还没有返回的选择操作立即返回。如果当前没有在进行中的选择,那么下一次对select( )方法的一种形式的调用将立即返回。后续的选择操作将正常进行。在选择操作之间多次调用wakeup( )方法与调用它一次没有什么不同。
  2. 调用close( ),如果选择器的close( )方法被调用,那么任何一个在选择操作中阻塞的线程都将被唤醒,就像wakeup( )方法被调用了一样。与选择器相关的通道将被注销,而键将被取消。
  3. 调用interrupt( ),如果睡眠中的线程的interrupt( )方法被调用,它的返回状态将被设置。如果被唤醒的线程之后将试图在通道上执行I/O操作,通道将立即关闭,然后线程将捕捉到一个异常。这是由于在第三章中已经探讨过的通道的中断语义。使用wakeup( )方法将会优雅地将一个在select( )方法中睡眠的线程唤醒。如果您想让一个睡眠的线程在直接中断之后继续执行,需要执行一些步骤来清理中断状态(参见Thread.interrupted( )的相关文档)。Selector对象将捕捉InterruptedException异常并调用wakeup( )方法。请注意这些方法中的任意一个都不会关闭任何一个相关的通道。中断一个选择器与中断一个通道是不一样的。选择器不会改变任意一个相关的通道,它只会检查它们的状态。当一个在select( )方法中睡眠的线程中断时,对于通道的状态而言,是不会产生歧义的。

管理选择键:

  选择是累积的。一旦一个选择器将一个键添加到它的已选择的键的集合中,它就不会移除这个键。并且,一旦一个键处于已选择的键的集合中,这个键的readyOps集合将只会被设置,而不会被清理。乍一看,这好像会引起麻烦,因为选择操作可能无法表现出已注册的通道的正确状态。它提供了极大的灵活性,但把合理地管理键以确保它们表示的状态信息不会变得陈旧的任务交给了程序员。

  合理地使用选择器的秘诀是理解选择器维护的选择键集合所扮演的角色。最重要的部分是当键已经不再在已选择的键的集合中时将会发生什么。当通道上的至少一个感兴趣的操作就绪时,键的readyOps集合就会被清空,并且当前已经就绪的操作将会被添加到readyOps集合中。该键之后将被添加到已选择的键的集合中。

  清理一个SelectKey的readyOps集合的方式是将这个键从已选择的键的集合中移除。选择键的就绪状态只有在选择器对象在选择操作过程中才会修改。处理思想是只有在已选择的键的集合中的键才被认为是包含了合法的就绪信息的。这些信息将在键中长久地存在,直到键从已选择的键的集合中移除,以通知选择器您已经看到并对它进行了处理。如果下一次通道的一些感兴趣的操作发生时,键将被重新设置以反映当时通道的状态并再次被添加到已选择的键的集合中。这种框架提供了很多灵活性。通常的做法是在选择器上调用一次select操作(这将更新已选择的键的集合),然后遍历selectKeys( )方法返回的键的集合。在按顺序进行检查每个键的过程中,相关的通道也根据键的就绪集合进行处理。然后键将从已选择的键的集合中被移除(通过在Iterator对象上调用remove( )方法),然后检查下一个键。完成后,通过再次调用select( )方法重复这个循环。

总结:通过在Iterator对象上调用remove( )方法清空readyOps。

 

并发问题:

  选择器对象是线程安全的,但它们包含的键集合不是。通过keys( )和selectKeys( )返回的键的集合是Selector对象内部的私有的Set对象集合的直接引用。这些集合可能在任意时间被改变。已注册的键的集合是只读的。如果试图修改,那么将会产生java.lang.UnsupportedOperationException。Iterator对象是快速失败的(fail-fast):如果底层的Set被改变了,它们将会抛出java.util.ConcurrentModificationException,因此如果您期望在多个线程间共享选择器和/或键,同样可能导致异常。您可以直接修改选择键,但请注意您这么做时可能会彻底破坏另一个线程的Iterator。

  如果在多个线程并发地访问一个选择器的键的集合的时候存在问题,可以采取一些步骤来合理地同步访问。在执行选择操作时,选择器在Selector对象上进行同步,然后是已注册的键的集合,最后是已选择的键的集合,按照这样的顺序。已取消的键的集合也在选择过程的的第1步和第3步之间保持同步(当与已取消的键的集合相关的通道被注销时)。在多线程的场景中,如果您需要对任何一个键的集合进行更改,不管是直接更改还是其他操作带来的副作用,您都需要首先以相同的顺序,在同一对象上进行同步。锁的过程是非常重要的。如果竞争的线程没有以相同的顺序请求锁,就将会有死锁的潜在隐患。如果您可以确保否其他线程不会同时访问选择器,那么就不必要进行同步了。Selector类的close( )方法与slect( )方法的同步方式是一样的,因此也有一直阻塞的可能性。在选择过程还在进行的过程中,所有对close( )的调用都会被阻塞,直到选择过程结束,或者执行选择的线程进入睡眠。在后面的情况下,执行选择的线程将会在执行关闭的线程获得锁是立即被唤醒,并关闭选择器。

异步关闭:

  关闭通道的过程不应该是一个耗时的操作。NIO的设计者们特别想要阻止这样的可能性:一个线程在关闭一个处于选择操作中的通道时,被阻塞于无限期的等待。当一个通道关闭时,它相关的键也就都被取消了。这并不会影响正在进行的select( ),但这意味着在您调用select( )之前仍然是有效的键,在返回时可能会变为无效。您总是可以使用由选择器的selectKeys( )方法返回的已选择的键的集合:请不要自己维护键的集合。如果您试图使用一个已经失效的键,大多数方法将抛出CancelledKeyException。但是,您可以安全地从从已取消的键中获取通道的句柄。如果通道已经关闭时,仍然试图使用它的话,在大多数情况下将引发ClosedChannelException。

扩展性:

  在多线程场景下为处理多个通道,通常不建议使用多个选择器,一个更好的策略是对所有的可选择通道使用一个选择器,并将对就绪通道的服务委托给其他线程。您只用一个线程监控通道的就绪状态并使用一个协调好的工作线程池来处理共接收到的数据,在大量通道上执行就绪选择并不会有很大的开销。根据部署的条件,线程池的大小是可以调整的(或者它自己进行动态的调整)。对可选择通道的管理仍然是简单的,而简单的就是好的。

 

以上是关于NIO 选择器 Selector的主要内容,如果未能解决你的问题,请参考以下文章

NIO之五Selector

NIO Selector(选择器)

Java NIO 之 Selector

Java NIO之Selector(选择器)

Java NIO之Selector(选择器)

Java NIO之Selector(选择器)