Java NIO(6): Selector

讲了一周的NIO,今天才讲到重点。本节课是小密圈《进击的Java新人》第十七周第二节课。今天我们聊一下selector。

经过前边几节课的介绍,我相信大家都掌握了IO多路复用的核心思想了。昨天我给了一个使用C语言进行 linux 编程的IO多路复用的例子。那个例子使用的是 poll 这个系统调用,今天我会给出一个使用Java实现与之完全对等的例子。

Selector

在Java中,Selector这个类是select/epoll/poll的外包类,在不同的平台上,底层的实现可能有所不同,但其基本原理是一样的,其原理图如下所示:

所有的Channel都归Selector管理,这些channel中只要有至少一个有IO动作,就可以通过Selector.select方法检测到,并且使用selectedKeys得到这些有IO的channel,然后对它们调用相应的IO操作。

我这里有一个服务端的例子,这个例子与昨天使用 poll 的 C 语言的例子是完全对应的:

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

public class EpollServer {
    public static void main(String[] args) {
        try {
            ServerSocketChannel ssc = ServerSocketChannel.open();
            ssc.socket().bind(new InetSocketAddress("127.0.0.1", 8000));
            ssc.configureBlocking(false);

            Selector selector = Selector.open();
            // 注册 channel,并且指定感兴趣的事件是 Accept
            ssc.register(selector, SelectionKey.OP_ACCEPT);

            ByteBuffer readBuff = ByteBuffer.allocate(1024);
            ByteBuffer writeBuff = ByteBuffer.allocate(128);
            writeBuff.put("received".getBytes());
            writeBuff.flip();

            while (true) {
                int nReady = selector.select();
                Set<SelectionKey> keys = selector.selectedKeys();
                Iterator<SelectionKey> it = keys.iterator();

                while (it.hasNext()) {
                    SelectionKey key = it.next();
                    it.remove();

                    if (key.isAcceptable()) {
                        // 创建新的连接,并且把连接注册到selector上,而且,
                        // 声明这个channel只对读操作感兴趣。
                        SocketChannel socketChannel = ssc.accept();
                        socketChannel.configureBlocking(false);
                        socketChannel.register(selector, SelectionKey.OP_READ);
                    }
                    else if (key.isReadable()) {
                        SocketChannel socketChannel = (SocketChannel) key.channel();
                        readBuff.clear();
                        socketChannel.read(readBuff);

                        readBuff.flip();
                        System.out.println("received : " + new String(readBuff.array()));
                        key.interestOps(SelectionKey.OP_WRITE);
                    }
                    else if (key.isWritable()) {
                        writeBuff.rewind();
                        SocketChannel socketChannel = (SocketChannel) key.channel();
                        socketChannel.write(writeBuff);
                        key.interestOps(SelectionKey.OP_READ);
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

这个例子的关键点:

  1. 创建一个ServerSocketChannel,和一个Selector,并且把这个server channel 注册到 selector上,注册的时间指定,这个channel 所感觉兴趣的事件是 SelectionKey.OP_ACCEPT,这个事件代表的是有客户端发起TCP连接请求。
  2. 使用 select 方法阻塞住线程,当select 返回的时候,线程被唤醒。再通过selectedKeys方法得到所有可用channel的集合。
  3. 遍历这个集合,如果其中channel 上有连接到达,就接受新的连接,然后把这个新的连接也注册到selector中去。
  4. 如果有channel是读,那就把数据读出来,并且把它感兴趣的事件改成写。如果是写,就把数据写出去,并且把感兴趣的事件改成读。

这个程序的核心骨架就这么点,很简单。

为了测试这个服务端,我提供了一个客户端的小例子:

public class EpollClient {
    public static void main(String[] args) {
        try {
            SocketChannel socketChannel = SocketChannel.open();
            socketChannel.connect(new InetSocketAddress("127.0.0.1", 8000));

            ByteBuffer writeBuffer = ByteBuffer.allocate(32);
            ByteBuffer readBuffer = ByteBuffer.allocate(32);

            writeBuffer.put("hello".getBytes());
            writeBuffer.flip();

            while (true) {
                writeBuffer.rewind();
                socketChannel.write(writeBuffer);
                readBuffer.clear();
                socketChannel.read(readBuffer);
            }
        } catch (IOException e) {
        }
    }
}

这个客户端不断地向服务端发送"hello",并且从服务端接收"received"。

大家多启几个客户端程序,去连接同一个服务端,观察一下现象,动手改一下客户端代码,让它发送的更丰富一点,不要让每一个客户端发的都一样。

原理和实现

我们来分析一下,具体的实现,先从创建入手,看一下Selector.open的实现
    public static Selector open() throws IOException {
        return SelectorProvider.provider().openSelector();
    }

这个Provider在不同的机器上会有不同的默认实现,例如在linux平台上,就是这样的:

代码位于jdk/src/solaris/classes/sun/nio/ch/DefaultSelectorProvider.java

    public static SelectorProvider create() {
        String osname = AccessController
            .doPrivileged(new GetPropertyAction("os.name"));
        if (osname.equals("SunOS"))
            return createProvider("sun.nio.ch.DevPollSelectorProvider");
        if (osname.equals("Linux"))
            return createProvider("sun.nio.ch.EPollSelectorProvider");
        return new sun.nio.ch.PollSelectorProvider();
    }

可以看到,如果是高版本的Linux,就会使用EPollSelectorProvider,而默认则使用PollSelectorProvider, epoll相关的内容,我们下节课介绍,今天先不管它,继续看poll的实现。

    public AbstractSelector openSelector() throws IOException {
        return new PollSelectorImpl(this);
    }

OK,找到这个地方了。至于register的过程,我就不分析了,留作今天的作业。我今天只分析一下select 的过程。通过查看select的实现,我们最终可以找到这个位置:

solaris/classes/sun/nio/ch/PollSelectorImpl.java中的doSelect

    protected int doSelect(long timeout)
        throws IOException
    {   
        if (channelArray == null)
            throw new ClosedSelectorException();
        processDeregisterQueue();
        try {
            begin();
            pollWrapper.poll(totalChannels, 0, timeout);
        } finally {
            end();
        }   
        processDeregisterQueue();
        int numKeysUpdated = updateSelectedKeys();
        if (pollWrapper.getReventOps(0) != 0) {
            // Clear the wakeup pipe
            pollWrapper.putReventOps(0, 0); 
            synchronized (interruptLock) {
                IOUtil.drain(fd0);
                interruptTriggered = false;
            }   
        }   
        return numKeysUpdated;
    }

这里最重要的是pollWrapper.poll,OK,我们去看这个定义:

    int poll(int numfds, int offset, long timeout) {
        return poll0(pollArrayAddress + (offset * SIZE_POLLFD),
                     numfds, timeout);
    }   

    private native int poll0(long pollAddress, int numfds, long timeout);

又到了native方法了。大家可以猜一下这个poll0是怎么实现的,然后我们来看答案:

JNIEXPORT jint JNICALL
Java_sun_nio_ch_PollArrayWrapper_poll0(JNIEnv *env, jobject this,
                                       jlong address, jint numfds,
                                       jlong timeout)
{
    struct pollfd *a;
    int err = 0;

    a = (struct pollfd *) jlong_to_ptr(address);

    if (timeout <= 0) {           /* Indefinite or no wait */
        RESTARTABLE (poll(a, numfds, timeout), err);
    } else {                     /* Bounded wait; bounded restarts */
        err = ipoll(a, numfds, timeout);
    }

    if (err < 0) {
        JNU_ThrowIOExceptionWithLastError(env, "Poll failed");
    }
    return (jint)err;
}

如何,你是不是已经猜到了?绕了这么久,到最后,原来是我们的大熟人 poll 啊。到此为止,我们终于把selector前前后后的事情都搞明白了。

一个同学问我,NIO里引入channel 的是干嘛的?好像它只是对socket的一层封装,看不出来什么意义嘛。到这里,终于可以完整地回答这位同学了,channel 是与 selector 适配用的。selector 直接管理 Java Socket 很难实现,所以使用channel做一次封装与之适配。这一切都是为了 selector 而存在的。selector 是Java NIO的基石,是根本之所在。

OK,终于把NIO的核心知识讲完了,长舒一口气。

今天不留作业,把文章中的例子多跑一跑,调一调就可以了。

上一节课:Java NIO(5): IO多路复用

下一节课:Java NIO(7): Epoll版的Selector

目录:课程目录

编辑于 2017-06-18

文章被以下专栏收录