Java NIO(六)选择器

前言

Selector选择器是Java NIO中能够检测一到多个NIO通道,并能够知晓通道是否为诸如读写事件做好准备的组件。这样使得一个单独的线程可以管理多个Channel,从而管理多个网络连接。选择器提供选择执行已经就绪的任务的能力,使得多元I/O成为可能。选择器的执行细节:

  • 创建一个或多个可选择的通道(SelectableChannel)
  • 将这些创建的通道注册到选择器对象中
  • 选择键会记住开发者关心的通道,它们也会追踪对应的通道是否就绪
  • 开发者调用选择器的select()方法,当方法从阻塞状态返回时,选择键会被更新
  • 获取选择键的集合,找到当时已经就绪的通道,通过遍历这些键,开发者可以选择对已经就绪的通道做操作

为什么使用Selector

对于操作系统来说,线程之间上下文切换的开销很大,而且每个线程都要占用系统的资源,所以使用Selector单独管理多个Channel

选择器,选择键和可选择通道

选择器(Selector)

选择器类管理着一个被注册的通道集合的信息和它们的就绪状态。通道和选择器是一起被注册的,并且使用选择器来更新通道的就绪状态

可选择通道(SelectableChannel)

这个抽象类提供了实现通道的可选择性所需的公共方法,它是所有支持就绪检查的通道类的父类,FileChannel对象是不可选择的,它没有继承SelectableChannel类,所有Socket通道都是可选择的,包括从管道(Pipe)对象中获得的通道。SelectableChannel可以被注册到Selector上,同时可以设定对哪个选择器而言哪种操作是感兴趣的。一个通道可以被注册到多个选择器上,而一个选择器只能被注册一次

选择键(SelectionKey)

选择键封装了特定的通道与特定的选择器的注册关系。调用SelectableChannel的register()方法会返回选择键并提供一个表示这种注册关系的标记。选择键包含了两个比特集(以整数形式进行编码),指示了该注册关系所关心的通道操作以及通道已经准备好的操作

Selector

Selector的创建

Selector selector = Selector.open();

向Selector注册通道

channel.configureBlocking(false);
SelectionKey selectionKey = channel.register(selector, SelectionKey.OP_READ);

与Selector一起使用时,通道必须处于非阻塞模式,这意味着FileChannel不能与Selector一起使用,套接字通道都可以

register()方法的第二个参数是一个“interest集合”,意思是通过Selector监听Channel时对什么事件感兴趣。可以监听四种不同类型的事件,用四种常量表示:

  • SelectionKey.OP_CONNECT: 连接就绪(某个Channel成功连接到另一个服务器)
  • SelectionKey.OP_ACCEPT: 接收就绪(一个ServerSocketChannel准备好接收新进入的连接)
  • SelectionKey.OP_READ: 读就绪(一个有数据可读的通道)
  • SelectionKey.OP_WRITE: 写就绪(等待写数据的通道)

通道触发了一个事件,就表示该事件已经就绪。如果对不止一种事件感兴趣,可以用位或操作符将常量连接:

int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;

注意: 并非所有的操作都在所有的可选择通道上支持,比如SocketChannel就不支持accept

SelectionKey

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 iterestOps(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();
}

 

关于这些API,总结几点:

  1. 一个键表示了一个特定的通道对象和一个特定的选择器对象之间的注册关系,channel()和selector()方法反映了这种关系
  2. 开发者可以使用cancel()方法结束这种关系,也可以使用isValid()方法检查这种有效的关系是否仍然存在,可以使用readyOps()方法来获取相关通道已经就绪的操作
  3. 使用isReadable()等四个方法判断通道的就绪状态
  4. 当通道关闭时,所有相关的键会自动取消(一个通道可以被注册到多个选择器上),当选择器关闭时,所有被注册到该选择器的通道将会被注销(通道本身并不会关闭),相关的键被立即取消

这个对象包含了一些属性:

  • interest集合
  • ready集合
  • Channel
  • Selector
  • 附加的对象(可选)

interest集合

interest集合是你所选择的感兴趣的事件集合。可以通过SelectionKey读写interest集合

int interestSet = selectionKey.interestOps();
boolean isAccept = (interest & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT;
boolean isConnect = (interest & SelectionKey.OP_CONNECT) == SelectionKey.OP_CONNECT;
boolean isRead = (interest & SelectionKey.OP_READ) == SelectionKey.OP_READ;
boolean isWrite = (interest & SelectionKey.OP_WRITE) == SelectionKey.OP_WRITE;

用位与操作interest集合和SelectionKey常量可以判断某个确定的事件是否在interest集合中

ready集合

ready集合是通道已经准备就绪的操作的集合。在一次选择(Selection)之后,会首先访问ready set。

int readySet = selectionKey.readyOps();

可以像检测interest集合一样,检测Channel中有哪些事件或操作已经准备就绪,也可以用以下方法:

boolean isAccept = selectionKey.isAcceptable();  // 这种写法等价于 (selectionKey.readyOps() & SelectionKey.OP_ACCEPT) != 0; 下面的类似
boolean isConnect = selectionKey.isConnectable();
boolean isRead = selectionKey.isReadable();
boolean isWrite = selectionKey.isWriteable();

Channel和Selector

Channel channel = selectionKey.channel();
Selector selector = selectionKey.selector();

附加对象

可以将一个对象或更多信息附加到SelectionKey上,这样能方便识别某个通道。例如,可以附加与通道一起使用的Buffer,或是包含聚集数据的某个对象

selectionKey.attach(object);
Object obj = selectionKey.attachment();

还可以用register()方法向Selector注册Channel时附加对象

SelectionKey selectionKey = channel.register(selector, SelectionKey.OP_READ, object);

通过Selector选择通道

一旦向Selector注册了一个或多个通道,就可以调用几个重载的select()方法。这些方法会返回你所感兴趣的事件已经准备就绪的通道。

  • int select()
  • int select(long timeout)
  • int selectNow()

select()方法会阻塞到至少有一个通道在你注册的事件上就绪了

select(long timeout)和select()一样,除了最长会阻塞timeout毫秒

selectNow()不会阻塞,不管什么通道就绪都立刻返回

select()方法返回的int值表示有多少通道已经就绪,即自从上次调用select()方法后有多少通道变成了就绪状态。如果调用一次select()方法,因为有一个通道变成了就绪状态,返回1,如果再次调用select()方法,如果另一个通道也就绪了,则还会返回1。即使,第一个就绪的通道没有做任何操作,但是每次select()方法调用之间,只有一个通道变成就绪状态

Selector维护的三种键

选择器维护着注册过的通道的集合,并且这些注册关系中的任意一个都是封装在SelectionKey对象中。每个Selector对象维护三种键的集合:

已注册的键的集合(Registered key set)

与选择器关联的已经注册的键的集合,并不是所有注册过的键都有效。这个集合通过key()方法返回,并且可能为空。这些键的集合不可以直接修改,试图这么做将引发java.lang.UnsupportedOperationException

已选择的键的集合(Selected key set)

已注册的键的集合的子集,这个集合的每个成员都是相关的通道被选择器判断为已经准备好的并且包含于键的interest集合中的操作,这个集合通过selectedKeys()方法返回(可能为空)

正常情况下,一旦调用了select()方法,且返回值表明有一个或多个通道就绪了,然后可以调用Selector的selectedKeys()方法,访问“已选择键集“(selected key set)中的就绪通道

Set selectedKeys = selector.selectedKeys();

当向Selector注册Channel时,Channel.register()方法会返回一个SelectionKey对象。这个对象代表了注册到该Selector的通道。可以通过Selector的selectedKeys()方法访问这些对象

已取消的键的集合(Cancelled key set)

已注册的键的集合的子集,这个集合包含了cancel()方法被调用过的键(这个键已经被无效化),但它们还没有被注销。这个集合是选择器对象的私有成员,无法直接访问

选择过程

选择器是对select(),poll(),epoll()等本地调用或类似的操作系统特定的系统调用的一个包装,但Selector所做的不仅仅是简单的向本地代码传递参数,每个操作都有特定的过程,对每个过程的理解是合理的管理键和它们所表示的状态信息的基础

选择操作是当三种形式的select()方法任意一种被调用时,由选择器执行的。不管哪一种被调用,以下过程都会被执行:

  1. 已取消的键的集合将会被检查。如果它是非空的,每个已取消的键的集合中的键都会从另外两个集合中移除,并且相关的通道将被注销。此步骤结束,已取消的键的集合将会为空
  2. 已注册的键的集合中的键的interest集合将被检查,此步骤结束,对interest集合的改动将不会影响剩余的检查过程。一旦就绪条件被定下来,底层操作系统将会进行查询,用来确定每个通道所关心的操作的真实就绪状态,依赖于特定的select()方法调用,如果没有通道已经准备好,线程可能会在这时阻塞,通常会有一个超时值
  3. 步骤二可能会花费很长时间,特别是线程处于阻塞状态时,与该选择器相关的键可能会同时被取消,当步骤二结束时,步骤一将再次被执行,用来完成任意一个在选择进行的过程中,键已经被取消的通道的注册

wakeUp()

某个线程调用select()方法之后阻塞了,即使没有通道已经就绪,也可以让其返回。只要让其他线程在第一个线程调用select()方法的对象上调用Selector.wakeUp()方法即可,阻塞在select()方法上的线程会立马返回

如果有其他线程调用了wakeUp()方法,且线程调用select()方法没有阻塞,下次调用select()方法的线程会立即醒来

close()

用完Selector后调用close()方法关闭,且是注册到Selector上的所有SelectionKey实例无效,通道本身并不会关闭

代码

public class SelectorServer{
  private static int PORT = 12345;
  
public static void main(String[] args) throws Exception{

    int port = PORT;
    // 打开一个ServerSocketChannel
    ServerSocketChannel serverChannel = ServerSocketChannel.open();
    // 获取ServerSocketChannel绑定的Socket
    ServerSocket serverSocket = serverChannel.socket();
    // 设置ServerSocket监听的端口
    serverSocket.bind(new InetSocketAddress(port));
    // 设置ServerSocketChannel为非阻塞模式
    serverChannel.configureBlocking(false);
    
    // 打开一个选择器
    Selector selector = Selector.open();
    // 将ServerSocketChannel注册到选择器上,并监听ACCEPT事件
    serverChannel.register(selector, SelectionKey.OP_ACCEPT);
    while(true){
      // 这里会发生阻塞,等待就绪的通道
      int n = selector.select();
      if(n == 0){
        continue;
      }
      // 获取SelectionKey上已经就绪的通道的集合      
      Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
      // 遍历每一个key
      while(iterator.hasNext()){
        SelectionKey key = iterator.next();
        // 通道上是否有可接收的连接
        if(key.isAcceptable()){
          ServerSocketChannel serverChannel2 = (ServerSocketChannel) key.channel();
          SocketChannel socketChannel = serverChannel2.accept();
          socketChannel.configureBlocking(false);
          socketChannel.register(selector, SelectionKey.OP_READ);
        }
        // 通道上是否有数据可读
        else if(key.isReadable()){
          readDataFromSocket(key);
        }
        iterator.remove(); 
      }
    }

  }
  private static ByteBuffer buf = ByteBuffer.allocate(1024);
  // 从通道中读取数据
  public static void readDataFromSocket(SelectionKey key){
    SocketChannel socketChannel = (SocketChannel) key.channel();
    buf.clear();
    while(socketChannel.read(buf) > 0){
      buf.flip();
      while(buf.hasRemaining()){
        System.out.println((char)buf.get());
      }
      buf.clear();
    }
  }
}

注意一: 严格意义上来说NIO并非是一种非阻塞IO,因为NIO会阻塞在Selector的select()方法上 

注意二: 满足isAcceptable()方法表示该通道上有数据到来了,此时我们做的事情不是获取该通道(创建一个线程来读取该通道上的数据),这么做就和前面一直讲的阻塞IO没有区别了,也无法发挥出NIO的优势来。我们做的事情只是简单地将对应的SocketChannel注册到选择器上,通过传入OP_READ标记,告诉选择器我们关心新的Socket通道什么时候可以准备好读数据。满足isReadable()方法则表示新注册的Socket通道已经可以读取数据了,此时调用readDataFromSocket方法读取SocketChannel中的数据

注意三: 将键移除,这一行很重要也是容易忘记的一步操作。加入不remove,将会导致socketChannel.configureBlocking(false)出现空指针异常

选择器客户端的代码没什么要求,只要向服务端发送数据就可以了

Selector可以简化用单线程同时管理多个可选择通道的实现。使用一个线程来为多个通道提供服务,通过消除管理各个线程的额外开销,可能会降低复杂性并可能大幅提升性能。

对单核CPU的系统而言这可能是一个好主意,因为在任何情况下都只有一个线程能够运行。通过消除在线程之间进行上下文切换带来的额外开销,总吞吐量可以提高。但对于一个多核CPU的系统而言呢?在一个有n个CPU的系统上,当一个单一的线程线性轮流地处理每一个线程时,可能有(n-1)个CPU处于空闲状态。

一种可行的解决办法是使用多个选择器。但是请尽量不要这么做,在大量通道上执行就绪选择并不会有很大的开销,大多数工作是由底层操作系统完成的,管理多个选择器并随机地将通道分派给它们当中的一个并不是这个问题的合理的解决方案。

一种更好的解决方案是对所有的可选择通道使用同一个选择器,并将对就绪选择通道的服务委托给其他线程。开发者只使用一个线程监控通道的就绪状态,至于通道处于就绪状态之后又如何做,有两种可行的做法:

1、使用一个协调好的工作线程池来处理接收到的数据,当然线程池的大小是可以调整的

2、通道根据功能由不同的工作线程来处理,它们可能是日志线程、命令/控制线程、状态请求线程等

posted @ 2018-02-24 21:01  羽觞醉月  阅读(306)  评论(0编辑  收藏  举报