Loading

NIO——选择器

Selector用于将一组Channel的事件发生聚合在一个(确切的说不是一个,但不妨先这样理解)线程中进行处理,还是先介绍它相关组件的抽象结构。

通道和选择器

通道和选择器的关系是,通道注册一部分事件到选择器中,然后用户可以通过选择器对其中所有的通道注册事件进行监听,事件发生,选择器就会接收到。

选择器和通道配合,主要为了提供非阻塞IO,传统的阻塞IO为了避免由于IO等待引起的CPU时间浪费都采用多线程技术,一个线程处理一个任务。但这只是把阻塞移到其他线程中了,一个线程的阻塞不会影响其它线程的正常运行,但是阻塞还是在,这个线程还是在白白等待IO事件完成。非阻塞IO不再需要等待IO事件发生,调用非阻塞IO操作后,立即得到返回,等到对应的事件发生,程序会得到通知。

选择器的作用就是对正在进行的非阻塞IO事件的发生进行监听。

SelectableChannel

一个事实是,不是也不需要所有通道都可以注册到选择器中,比如FileChannel就不行,因为文件操作在本地,通常比较快,不会有太多的CPU闲置情况。可以注册到选择器中的通道是一个SelectableChannel实例。

public abstract class SelectableChannel
    extends AbstractInterruptibleChannel
    implements Channel

SelectableChannel继承AbstractInterruptibleChannel,并提供了一些新的和选择器相关的功能,比如如下方法把自己注册到一个Selector

public abstract SelectionKey register(Selector sel, int ops, Object att)
    throws ClosedChannelException;

SelectableChannel的子类如下:

isRegistered方法返回该Channel当前是否注册到任何一个Selector中

public abstract boolean isRegistered();

不能直接通过SelectableChannel或者Selector取消二者的注册关系,而是要通过SelectionKeycancel方法取消,调用后将会在选择器的下一次select时注销。

如果通过调用通道的close或者中断阻塞在该通道上IO操作中的线程来关闭该通道,都会隐式取消该通道的所有SelectionKey。而如果选择器关闭了,上面的通道和SelectionKey立即无效。

一个通道对于一个特定选择器只能注册一次。

SelectableChannel默认属于阻塞模式,如果使用Selector,必须通过configureBlocking(false)来设置非阻塞模式。

AbstractSelectableChannel

public abstract class AbstractSelectableChannel
    extends SelectableChannel

维护通道的阻塞模式和SelectionKey集合,实现SelectableChannel接口的所有约定。

ServerSocketChannel和NetworkChannel

ServerSocketChannel继承AbstractSelectableChannel并且实现NetworkChannel

public abstract class ServerSocketChannel
    extends AbstractSelectableChannel
    implements NetworkChannel

ServerSocketChannel并不包含一个套接字实现,而是通过它的socket方法返回一个ServerSocket对象,然后对该对象进行设置。不能通过ServerSocket来创建通道,不能指定与ServerSocketChannel关联的ServerSocket对象。

通过ServerSocketChannelopen方法可以创建一个该类的实例,你需要通过获取它其中的socket并进行绑定。

下面是该类所有的方法

ServerSocketChannel、Selector和SelectionKey的使用

@Test
void testSelectableChannel() throws IOException {
    Selector selector = Selector.open();
    ServerSocketChannel ssChannel = ServerSocketChannel.open();
    ssChannel.configureBlocking(false);
    ssChannel.socket().bind(new InetSocketAddress("0.0.0.0", 11441));
    SelectionKey key = ssChannel.register(selector, SelectionKey.OP_ACCEPT);

    Assertions.assertTrue(ssChannel.isRegistered());

    System.out.printf("selector: %s\nssChannel: %s\nkey: %s\n", selector, ssChannel, key);
    ssChannel.close();
}

SelectorProvider

Selector可以向系统注册Channel的IO事件通知并在事件发生时接到通知。这个功能需要操作系统的支持,而且不同操作系统中的实现方式是不一样的。所以SelectorProvider用来提供一个Selector,屏蔽底层带来的差异,Selector.open方法使用的就是这个。

Selector的使用

Selector维护了三种键集

  1. 键集:每个注册到其中的通道都会有一个绑定的SelectionKey对象,键集返回所有这些对象,此集合由keys方法返回
  2. 已选择键集:一个通道可能在一个选择器上监听多种事件,但一个通道只绑定一个SelectionKey对象,已选择键集代表至少该通道在该选择器上有一个准备就绪的事件。selectedKeys方法返回这些键集。已选择键集是所有键集的一个子集。
  3. 已取消键集:已被取消但是通道尚未注销的键集合,是所有键集的自己,不可访问,并且在select方法执行期间会将已取消的键从所有键集中移除。

selectselect(long)selectNow方法执行涉及以下三个步骤:

  1. 将所有已取消键集从所有键集中移除,并注销其通道
  2. 通过操作系统来获得通道的准备就绪信息
    1. 如果该通道的键尚未在就绪键集中,添加到就绪键集中,并检查就绪键集,丢弃以往过期的就绪键
  3. 如果在步骤2进行时将任何键添加到已取消键集,按步骤1进行处理

select操作是阻塞的,直到键集中有正在等待的事件发生,该阻塞可以通过以下手段中断:

  1. 调用Selector.wakeup
  2. 调用Selector.close

select返回值的含义

select返回已更新为就绪键的键的数目,所以它可能是0,并且它是0不代表没有事件可以处理。因为可能同一个事件连续发生,一直在就绪键集中没有被清出。

@Test
void testSelectReturn0() throws IOException {
    ServerSocketChannel ssc = ServerSocketChannel.open();
    ssc.configureBlocking(false);
    ssc.bind(new InetSocketAddress("localhost", 11441));

    Selector selector = Selector.open();
    ssc.register(selector, SelectionKey.OP_ACCEPT);

    while (true) {
        int nAddToSelectedSet = selector.select();
        int selectedKeysCount = selector.selectedKeys().size();

        System.out.printf("nAddToSelectedSet => %d, selectedKeysCount: %d\n", nAddToSelectedSet, selectedKeysCount);

        Iterator<SelectionKey> keys = selector.selectedKeys().iterator();
        while (keys.hasNext()) {
            keys.next();
            ssc.accept();
            // 注释这行,导致该键一直在就绪键集中
//                keys.remove();
        }
    }
}

下面随便运行点什么,两次连接到这个端口:

nAddToSelectedSet => 1, selectedKeysCount: 1
nAddToSelectedSet => 0, selectedKeysCount: 1

第二次select返回0。

读写文件的例子

先运行server,再运行client,当然classpath中要有个文件

public class TransferFileTest {
    @Test
    void server() throws IOException, URISyntaxException {
        ServerSocketChannel channel = ServerSocketChannel.open();
        channel.configureBlocking(false);
        channel.bind(new InetSocketAddress("localhost", 11441));

        Selector selector = Selector.open();
        channel.register(selector, SelectionKey.OP_ACCEPT);
        boolean isRun = true;
        while (isRun) {
            selector.select();
            Set<SelectionKey> set = selector.selectedKeys();
            Iterator<SelectionKey> iterator = set.iterator();
            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                iterator.remove();

                if (key.isAcceptable()) {
                    SocketChannel connChannel = channel.accept();
                    connChannel.configureBlocking(false);
                    connChannel.register(selector, SelectionKey.OP_WRITE);
                }

                if (key.isWritable()) {
                    System.out.println("开始写入");
                    FileChannel fc = FileChannel.open(
                            Path.of(
                                    getClass().getResource("../../../../sc.png").toURI()
                            ),
                            StandardOpenOption.READ
                    );

                    SocketChannel connChannel = (SocketChannel) key.channel();
                    fc.transferTo(0, fc.size(), connChannel);

                    System.out.println("完成写入");
                    fc.close();
                    connChannel.close();
                }
            }
        }
        channel.close();
    }

    @Test
    void client() throws IOException {
        SocketChannel channel = SocketChannel.open();
        channel.configureBlocking(false);
        channel.connect(new InetSocketAddress("localhost", 11441));

        Selector selector = Selector.open();
        channel.register(selector, SelectionKey.OP_CONNECT);

        boolean isRun = true;
        while (isRun) {
            selector.select();
            Iterator<SelectionKey> keys = selector.selectedKeys().iterator();
            while (keys.hasNext()) {
                SelectionKey key = keys.next();
                keys.remove();

                if (key.isConnectable()) {
                    while (!channel.finishConnect()) {}
                    channel.register(selector, SelectionKey.OP_READ);
                }

                if (key.isReadable()) {
                    FileChannel fc = FileChannel.open(
                            Path.of("D:\\tmp\\sc-cp.png"),
                            StandardOpenOption.CREATE, StandardOpenOption.WRITE
                    );
                    ByteBuffer buf = ByteBuffer.allocate(1024);
                    System.out.println("开始读取");
                    while (channel.read(buf) != -1) {
                        buf.flip();
                        fc.write(buf);
                        buf.clear();
                    }
                    System.out.println("读取完毕");

                    fc.close();
                    channel.close();
                    break;
                }
            }

        }
    }
}

wakeup

使尚未返回的第一个选择操作立即返回,如果当前没有尚未返回的选择操作,之后的第一次选择操作将立即返回。

@Test
void testWakeup() throws IOException {
    ServerSocketChannel ssc = ServerSocketChannel.open();
    ssc.configureBlocking(false);
    ssc.bind(new InetSocketAddress("localhost", 11441));

    Selector selector = Selector.open();
    ssc.register(selector, SelectionKey.OP_ACCEPT);

    new Thread(() -> {
        try {
            Thread.sleep(3000);
            selector.wakeup();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();

    int n = selector.select();
    System.out.println("select返回 n is " + n);

    selector.close();
    ssc.close();

}

若干需要注意的细节

  1. SelectionKey.cancel()取消后,该通道将在下一次select时取消注册
  2. 通道close后,下一次select时取消该通道的注册
  3. 新创建的选择器中三个键集都是空的
  4. 尝试删除键集中的键会导致UnsupportedOperationException
  5. 多线程环境下删除键集中的键会导致ConcurrentModificationException
  6. 阻塞在select中的线程在选择器close时会被中断,并抛出ClosedSelectorException
  7. 阻塞在select的线程在interrupt时,select会正常返回,你可以通过interrupted方法清除中断状态
  8. Selector.close删除全部键,注销全部通道

第八点的测试

@Test
void testInterrupt() throws IOException {

    ServerSocketChannel ssc = ServerSocketChannel.open();
    ssc.configureBlocking(false);
    ssc.bind(new InetSocketAddress("localhost", 11441));

    Selector selector = Selector.open();
    ssc.register(selector, SelectionKey.OP_ACCEPT);

    Thread selectorThread = Thread.currentThread();
    new Thread(() -> {
        try {
            Thread.sleep(3000);
            selectorThread.interrupt();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();

    int n = selector.select();
    System.out.println("select返回 n is " + n);

    selector.close();
    ssc.close();

  }

SelectionKey

代表一个通道和一个选择器的绑定。

在调用某个SelectionKey的cancel方法、close通道、close选择器,该通道与选择器的绑定一直有效。

SelectionKey包含两个集合,都是整数表示的操作集,每一位都表示该通道支持的一类可选操作

  1. interest集:选择器select关心的操作集合。创建键时使用给定的值初始化该集合,之后可以通过interestOps(int)方法对其进行更改
  2. ready集interest集中那些已就绪的操作的集合,初始为0,select方法中选择器会对它进行更新,不能手动更新

SelectionKey定义了所有已知的操作集位,可以通过通道的validOps方法判断是否支持某个操作集位。

四种测试方法

  1. isAcceptable:测试此键的通道是否已经准备接收新的套接字连接
  2. isConnectable:测试此键的通道是否已经完成其套接字连接操作
  3. isReadable:测试此键的通道是否已经准备好读取
  4. isWritable:测试此键的通道是否已经准备好写入

channel

返回创建此键的通道

selector

返回关联的选择器

附件

attach方法设置SelectionKey的附件,attachment方法返回设置的附件

这是书上的一个小例子,可能是为了方便吧,还没太理解。

interestOps

该方法可以设置SelectionKey关注的操作位集

DatagramChannel

posted @ 2022-03-22 20:37  yudoge  阅读(71)  评论(0编辑  收藏  举报