阅读 289

详解NIO Selector

1 选择器与注册

1.1 选择器和通道的关系

选择器的使命是完成IO的多路复用,其主要工作是通道的注册、监听、事件查询。

  1. 一个通道代表一条连接通路,通过选择器可以同时监控多个通道的IO(输入输出)状况。
  2. 选择器和通道的关系是监控和被监控的关系。

在NIO编程中,一般是一个单线程处理一个选择器,一个选择器可以监控很多通道。所以,通过选择器,一个单线程可以处理数百、数千、数万甚至更多的通道。 在极端情况下(数万个连接),只用一个线程就可以处理所有的通道,这样会大量地减少线程之间上下文切换的开销。

1.2 注册选择器

通道和选择器之间的关联通过register(注册)的方式完成。调用通道的Channel.register(Selector sel,int ops)方法,可以将通道实例注册到一个选择器中。

// 第一个参数指定通道注册到的选择器实例;
// 第二个参数指定选择器要监控的IO事件类型。
public final SelectionKey register(Selector sel, int ops)
    throws ClosedChannelException;
复制代码

可供选择器监控的通道IO事件类型包括以下四种:

1)可读:SelectionKey.OP_READ。
(2)可写:SelectionKey.OP_WRITE。
(3)连接:SelectionKey.OP_CONNECT。
(4)接收:SelectionKey.OP_ACCEPT。
复制代码

以上事件类型常量定义在SelectionKey类中。如果选择器要监控通道的多种事件,可以用“|”运算符来实现

int key = SelectionKey.OP_READ | SelectionKey.OP_WRITE ;
复制代码

1.3 IO事件

IO事件不是对通道的IO操作,而是通道处于某个IO操作的就绪状态,表示通道具备执行某个IO操作的条件。

例如,

  1. 某个SocketChannel传输通道如果完成了和对端的三次握手过程,就会发生“连接就绪”(OP_CONNECT)事件;
  2. 某个ServerSocketChannel服务器连接监听通道,在监听到一个新连接到来时,则会发生“接收就绪”(OP_ACCEPT)事件;
  3. 一个SocketChannel通道有数据可读,就会发生“读就绪”(OP_READ)事件;
  4. 一个SocketChannel通道等待数据写入,就会发生“写就绪”(OP_WRITE)事件。

2 SelectableChannel和SelectionKey

并不是所有的通道都是可以被选择器监控或选择的。例如,FileChannel就不能被选择器复用。判断一个通道能否被选择器监控或选择有一个前提:判断它是否继承了抽象类SelectableChannel(可选择通道),如果是,就可以被选择,否则不能被选择。

SelectableChannel类提供了实现通道可选择性所需要的公共方法。Java NIO中所有网络连接socket通道都继承了SelectableChannel类,都是可选择的。FileChannel并没有继承SelectableChannel,因此不是可选择通道。

通道和选择器的监控关系注册成功后就可以选择就绪事件,具体的选择工作可调用Selector的select()方法来完成。通过select()方法,选择器可以不断地选择通道中所发生操作的就绪状态,返回注册过的那些感兴趣的IO事件。

select()方法是个阻塞方法,当有事件就绪时候,就会返回

SelectionKey就是那些被选择器选中的IO事件。前面讲到,一个IO事件发生(就绪状态达成)后,如果之前在选择器中注册过,就会被选择器选中,并放入SelectionKey中;如果之前没有注册过,那么即使发生了IO事件,也不会被选择器选中。SelectionKey和IO的关系可以简单地理解为SelectionKey就是被选中了的IO事件。

在实际编程时,SelectionKey的功能是很强大的。通过SelectionKey,不仅可以获得通道的IO事件类型(比如SelectionKey.OP_READ),还可以获得发生IO事件所在的通道。另外,还可以获得选择器实例。

3 选择器使用流程

  1. 获取选择器实例。选择器实例是通过调用静态工厂方法open()来获取的
//调用静态工厂方法open()来获取Selector实例
Selector selector = Selector.open();
复制代码

Selector的类方法open()的内部是向选择器SPI发出请求,通过默认的SelectorProvider(选择器提供者)对象获取一个新的选择器实例。Java中的SPI(Service Provider Interface,服务提供者接口)是一种可以扩展的服务提供和发现机制。Java通过SPI的方式提供选择器的默认实现版本。也就是说,其他的服务提供者可以通过SPI的方式提供定制化版本的选择器的动态替换或者扩展。

  1. 将通道注册到选择器实例。要实现选择器管理通道,需要将通道注册到相应的选择器上
//获取通道
ServerSocketChannelserverSocketChannel = ServerSocketChannel.open();
//设置为非阻塞
serverSocketChannel.configureBlocking(false);
//绑定连接
serverSocketChannel.bind(new InetSocketAddress(18899));
//将通道注册到选择器上,并指定监听事件为“接收连接”
serverSocketChannel.register(selector,SelectionKey.OP_ACCEPT);
复制代码

注意:

1. 注册到选择器的通道必须处于非阻塞模式下,否则将抛出IllegalBlockingModeException异常。
这意味着,FileChannel不能与选择器一起使用,因为FileChannel只有阻塞模式,不能切换到非阻塞模式;
而socket相关的所有通道都可以。

2. 其次,一个通道并不一定支持所有的四种IO事件。
例如,服务器监听通道ServerSocketChannel仅支持Accept(接收到新连接)IO事件,
而传输通道SocketChannel则不同,它不支持Accept类型的IO事件。
复制代码
  1. 选出感兴趣的IO就绪事件(选择键集合)。通过Selector的select()方法,选出已经注册的、已经就绪的IO事件,并且保存到SelectionKey集合中。SelectionKey集合保存在选择器实例内部,其元素为SelectionKey类型实例。调用选择器的selectedKeys()方法,可以取得选择键集合。迭代集合的每一个选择键,根据具体IO事件类型执行对应的业务操作
//轮询,选择感兴趣的IO就绪事件(选择键集合)
while (selector.select() > 0) {
        Set selectedKeys = selector.selectedKeys();
        Iterator keyIterator = selectedKeys.iterator();
        while(keyIterator.hasNext()) {
               SelectionKey key = keyIterator.next();
//根据具体的IO事件类型执行对应的业务操作
                if(key.isAcceptable()) {
                  //IO事件:ServerSocketChannel服务器监听通道有新连接
                } else if (key.isConnectable()) {
                  //IO事件:传输通道连接成功
                } else if (key.isReadable()) {
                  //IO事件:传输通道可读
                } else if (key.isWritable()) {
                  //IO事件:传输通道可写
                }
                //处理完成后,移除选择键
                keyIterator.remove();
        }
}
复制代码

处理完成后,需要将选择键从SelectionKey集合中移除,以防止下一次循环时被重复处理。SelectionKey集合不能添加元素,如果试图向SelectionKey中添加元素,则将抛出java.lang.UnsupportedOperationException异常。

用于选择就绪的IO事件的select()方法有多个重载的实现版本,具体如下:

1)select():阻塞调用,直到至少有一个通道发生了注册的IO事件。
(2)select(long timeout):和select()一样,但最长阻塞时间为timeout指定的毫秒数。
(3)selectNow():非阻塞,不管有没有IO事件都会立刻返回。
复制代码

select()方法的返回值是整数类型(int),表示发生了IO事件的数量,即从上一次select到这一次select之间有多少通道发生了IO事件,更加准确地说是发生了选择器感兴趣(注册过)的IO事件数。

4 小Demo: NIO实现Discard服务器

Discard服务器的功能很简单:仅读取客户端通道的输入数据,读取完成后直接关闭客户端通道,并且直接抛弃掉(Discard)读取到的数据。

1.服务端

import lombok.extern.slf4j.Slf4j;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.charset.Charset;
import java.util.Iterator;
import java.util.Set;

/**
 * @author wyaoyao
 * @date 2021/6/21 18:04
 */
@Slf4j
public class NioDiscardServer {

    private final int port;

    private Selector selector;

    private ServerSocketChannel serverChannel;

    private final SocketAddress socketAddress;

    private final Charset charset = Charset.forName("utf-8");

    public NioDiscardServer(int port) throws IOException {
        this.port = port;
        this.socketAddress = new InetSocketAddress("localhost", port);
    }


    public void startServer() throws IOException {
        // 创建一个选择器
        this.selector = Selector.open();
        // 获取一个通道
        this.serverChannel = ServerSocketChannel.open();
        // 设置为非阻塞
        serverChannel.configureBlocking(false);
        // 绑定端口
        this.serverChannel.bind(this.socketAddress);
        log.info("NIO discard server start success; the port is [{}]. ", this.port);
        // 注册连接事件
        this.serverChannel.register(this.selector, SelectionKey.OP_ACCEPT);

        // 轮询感兴趣的时间
        while (this.selector.select() > 0) {
            // 获取选择键集合
            Set<SelectionKey> selectionKeys = this.selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();
            while (iterator.hasNext()) {
                SelectionKey selectionKey = iterator.next();
                // 判断是什么事件
                if (selectionKey.isAcceptable()) {
                    // 连接就绪事件,那就获取连接
                    handleAccept(selectionKey);
                } else if (selectionKey.isReadable()) {
                    // IO事件是“可读”,则读取数据
                    handleRead(selectionKey);
                }
                // 移除key
                selectionKeys.remove(selectionKey);
            }
        }
    }

    public void close() throws IOException {
        if (this.selector != null) {
            this.selector.close();
        }
        if (this.serverChannel != null) {
            this.serverChannel.close();
        }
    }

    private void handleRead(SelectionKey selectionKey) throws IOException {
        // 获取当前的通道
        SocketChannel channel = (SocketChannel) selectionKey.channel();
        // 读取数据
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        int len;
        StringBuilder content = new StringBuilder();
        while ((len = channel.read(buffer)) != -1) {
            // 切换buffer为读取模式
            buffer.flip();
            content.append(new String(buffer.array(), 0, len, charset));
            buffer.clear();
        }
        channel.close();
        // 打印数据
        log.info("read data is [{}]", content.toString());
    }

    private void handleAccept(SelectionKey selectionKey) throws IOException {
        // 获取连接
        SocketChannel socketChannel = this.serverChannel.accept();
        // 设置为非阻塞
        socketChannel.configureBlocking(false);
        // 将新连接的通道的可读事件注册到选择器上
        // 新建立的socketChannel客户端传输通道,也要注册到同一个选择器上,
        // 这样就能使用同一个选择线程不断地对所有的注册通道进行选择键的查询。
        socketChannel.register(this.selector, SelectionKey.OP_READ);
    }

    public static void main(String[] args) throws IOException {
        NioDiscardServer server = new NioDiscardServer(10010);
        server.startServer();
    }
}
复制代码

在事件处理过程中,对于新建立的socketChannel客户端传输通道,也要注册到同一个选择器上,这样就能使用同一个选择线程不断地对所有的注册通道进行选择键的查询。

  1. 写一个简单的客户端测试

import lombok.extern.slf4j.Slf4j;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
import java.util.concurrent.TimeoutException;

/**
 * @author wyaoyao
 * @date 2021/6/22 13:36
 */
@Slf4j
public class NioDiscardClient {

    private final String serverHost;

    private final int serverPort;

    private final SocketAddress serverSocketAddress;

    private final SocketChannel socketChannel;

    private final Charset charset = Charset.forName("utf-8");

    public NioDiscardClient(String serverHost, int serverPort) throws IOException {
        this.serverHost = serverHost;
        this.serverPort = serverPort;
        this.serverSocketAddress = new InetSocketAddress(serverHost, serverPort);
        this.socketChannel = SocketChannel.open();
        // 设置为非阻塞
        this.socketChannel.configureBlocking(false);
    }

    public void connect(long timeout) throws IOException, TimeoutException {
        this.socketChannel.connect(this.serverSocketAddress);
        long end = System.currentTimeMillis() + timeout;
        while (!this.socketChannel.finishConnect()) {
            if (System.currentTimeMillis() >= end) {
                throw new TimeoutException("connect.time.out");
            }
        }
        log.info("client connect server success");
    }

    public void sendMessage(String message) throws IOException {
        ByteBuffer encode = this.charset.encode(message);
        this.socketChannel.write(encode);
        this.socketChannel.shutdownOutput();
    }

    public static void main(String[] args) throws IOException, TimeoutException {
        NioDiscardClient client = new NioDiscardClient("localhost", 10010);
        client.connect(1000);
        client.sendMessage("hello world");
    }
}
复制代码
文章分类
后端
文章标签