Fork me on GitHub

Java-NIO之Selector

前言:

关于Java的Selector,其实也没什么好说的。
说高级点就是就是多路复用。而多路复用是由于操作系统的支持,才能得以实现。

体悟:
Java代码只是进行native 方法的调用。
核心代码在C/C++写的jdk源码中。
而多路复用是OS系统(Linux/Windows/MacOS)内核得以支持的。
如果喜欢研究计算机内核、计算机组成原理就不要学Java了,C/C++ 更贴近操作系统(苦笑)。


我也不知道写什么,只能把这段时间看过的、学习过的东西记录下来。


前篇

Selector API 浅谈
①:创建selector

        selector = Selector.open();

②:创建channel

        channel = SocketChannel.open();

③:建立连接
服务端:

        // 绑定端口
        channel.socket().bind(new InetSocketAddress(this.ip, port));

客户端:

        // 连接服务端(不是真正开始创建连接,要由selector进行事件消费)
        channel.connect(new InetSocketAddress(ip, port));

④:注册事件
服务端:

        selectionKey = channel.register(selector, SelectionKey.OP_ACCEPT);

客户端:

        selectionKey = channel.register(selector, SelectionKey.OP_CONNECT);

⑤:查询就绪通道

        // 会持续阻塞,直至存在已就绪的channel
        int select = selector.select();
        Iterator<SelectionKey> keyIterator = selector.selectedKeys().iterator();
  • selectNow():不阻塞,无论是否有就绪的通道。
  • select(long timeout):阻塞一定时间,无论是否有就绪的通道。
  • select():阻塞至必须有一个及以上就绪的通道。

⑥:wakeup()

        /*
         * 若 selector 处于 select 阻塞中,
         * 此时新 register 一个事件是无法扫描到的,需要 wakeUp 一下阻塞线程,或重新进行 select 操作。
         */
        selector.wakeup();

场景:线程A进行调用select()进行阻塞,线程B注册了一个写事件,此时线程A是不能通过select立即拿到线程B注册的写事件的,需要线程B调用wakeUp使A重新进入下一轮的select()调用,下一轮线程A才能拿到新注册的写事件并进行消费。

⑦:close()
selector.close() 操作只会关闭selector,原本注册的通道产生的SelectionKey会失效,但通道本身并不会关闭。

inode
什么是inode(索引节点)?

理解inode,要从文件储存说起。

文件储存在硬盘上,硬盘的最小存储单位叫做"扇区"(Sector)。每个扇区储存512字节(相当于0.5KB)。
操作系统读取硬盘的时候,不会一个个扇区地读取,这样效率太低,而是一次性连续读取多个扇区,即一次性读取一个"块"(block)。这种由多个扇区组成的"块",是文件存取的最小单位。"块"的大小,最常见的是4KB,即连续八个 sector组成一个 block。

文件数据都储存在"块"中,那么很显然,我们还必须找到一个地方储存文件的元信息,比如文件的创建者、文件的创建日期、文件的大小等等。这种储存文件元信息的区域就叫做inode,中文译名为"索引节点"。

每一个文件都有对应的inode,里面包含了与该文件有关的一些信息。

image

参考:理解inode

file descriptor

什么是file descriptor(文件描述符)?

Linux 系统中,把一切都看做是文件,当进程打开现有文件或创建新文件时,内核向进程返回一个文件描述符,文件描述符就是内核为了高效管理已被打开的文件所创建的索引,用来指向被打开的文件,所有执行I/O操作的系统调用都会通过文件描述符。

文件描述符和socket的关系:

socket的设计采用了和UNIXI/O一样的思路,即把socket看成一个文件,socket创建完成后,返回文件描述符,然后使用read、write发送和接收数据,最后关闭socket。

文件描述符和inode的关系:
image
最终是一个映射关系。

参考:文件描述符fd(File Descriptor)简介

IO multiplexing

常见的IO模型有四种:

  • Blocking IO
    既传统的同步阻塞IO。

  • NON-Blocking IO
    默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为NONBLOCK。注意这里所说的NIO并非Java的NIO(New IO)库。

  • IO Multiplexing
    即经典的Reactor设计模式,有时也称为异步阻塞IO,Java中的Selector和Linux中的epoll都是这种模型。

  • Asynchronous IO
    即经典的Proactor设计模式,也称为异步非阻塞IO。


不过有大佬说把IO分成上述四种并不好。

drawing

参考:深入理解 epoll


论channel、socket、selector和文件描述符的关系

1:无论SocketChannel还是ServerSocketChannel,进行连接都需要打开套接字(socket)并获取文件描述符(file descriptor)。
以ServerSocketChannel的实现类为例:

class ServerSocketChannelImpl extends ServerSocketChannel implements SelChImpl {
    private static NativeDispatcher nd;
    // 文件描述符
    private final FileDescriptor fd;
    private int fdVal;
    // 省略部分属性 ...

    // 套接字
    ServerSocket socket;
}

2:每个selector上可以注册多个 channel并绑定读、写、连接、接收事件。
3:channel也可以在多个selector上进行注册。

    @Test
    public void selector() throws IOException {
        Selector selector = Selector.open();
        for (int i = 0; i < 3; i++) {
            ServerSocketChannel ssc = ServerSocketChannel.open();
            ssc.configureBlocking(false);
            ssc.bind(new InetSocketAddress("127.0.0.1", 8360 + i));
            ssc.register(selector, SelectionKey.OP_ACCEPT);
        }
        System.out.println("over");
    }

    @Test
    public void channel() throws IOException {
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.configureBlocking(false);
        ssc.bind(new InetSocketAddress("127.0.0.1", 8366));
        for (int i = 0; i < 3; i++) {
            Selector selector = Selector.open();
            ssc.register(selector, SelectionKey.OP_ACCEPT);
        }
        System.out.println("over");
    }

根据操作系统的不同,最终选择的Selector也会有不同,这决定了该使用select、poll、epoll、kqueue中的哪种作为最终实现。

  • Windows

selector的实现类为:WindowsSelectorProvider。最终实现看源码应该是poll。
drawing

  • MacOS

selector的实现类为:KQueueSelectorProvider。最终实现看源码应该是kqueue/kevent。
drawing

  • Linux/SunOS

Linux选择的selector的实现类为:EPollSelectorProvider。最终实现看源码应该是epoll。
drawing

看得出来,其他操作系统默认使用poll来实现多路复用。

后篇

IO multiplexing(多路复用)的发展史:

1983,socket 发布在 Unix(4.2 BSD)

1983,select 发布在 Unix(4.2 BSD)

1994,Linux的1.0,已经支持socket和select

1997,poll 发布在 Linux 2.1.23

2000,FreeBSD 4.1 中首次引入了 kqueue。
随后也被 NetBSD、OpenBSD、macOS 等操作系统支持。kqueue 是一种可扩展的事件通知接口。

2002,epoll发布在 Linux 2.5.44

select、poll、epoll、kqueue 都是操作系统对多路复用的一种实现,并提供一个外部接口供操作系统以外的程序进行调用。


论IO multiplexing(多路复用)的优势
我们先要知道传统的BIO会有什么劣势,才能更好的理解这个问题。
传统的BIO 无论是读、写、连接、监听 都是阻塞的,这意味这我们打开一个socket如果要同时监听读、写事件,我们需要同时分别为读、写开启一个线程去进行监听。

比如客户端开启一个socket,要先进行 connect,当然connect我们可以同步,等connect处理完毕后需要为 read 和 write 分别新开一个线程去监听它们。
image
这种模式在客户端可能还好,但是如果是服务端那就是灾难性的了,服务端哪有那么多资源来开启这么多线程?
那么如果此时服务端有个东西能把创建连接的客户端socket给“收集起来”,用户程序只要调用一个api就可以知道是否有可读或可写的socket,那么就可以解决这个问题,于是IO多路复用这个“好玩意”就诞生了。


论IO multiplexing(多路复用)的原理

以windows为例:
drawing

  • select
  1. 应用程序调用select接口,select线程会阻塞调用它的线程。
  2. kernel 内核使用 轮询 的方式检查所有select关注的文件描述符。
  3. 当存在数据准备好的文件描述符,select 可以拿到就绪的文件描述符个数,但无法知道哪些文件描述符已就绪,因此需要将之前关注的fd_set从内核拷贝到进程的缓冲区(内核态到用户态)。
  4. 应用程序通过遍历fd_set(结构相当于bitset),拿到准备就绪的fd。

时间复杂度:O(n)。
缺点:

  1. 进行可打开的fd有限制(32操作系统是1024个,64位是64个)(因为存储fd是一个固定大小的数据)。
  2. 轮询的效率较低。
  3. 从内核态拷贝到用户态开销较大。
  • poll

poll和select的工作机制长不多,主要有以下几点 不同:

  1. 采用链表结构存储fd,因此可监听的文件描述符数量为系统可以打开的最大文件描述符数量(65535),且poll相比select不会修改文件描述符。
  2. poll相比于select 提供了更多事件类型,且对文件描述符的利用率比select高。
  • epoll

在jdk/src/solaris/classes/sun/nio/ch/EPollArrayWrapper.java文件中epoll提供了三个api:

    private native int epollCreate();
    private native void epollCtl(int epfd, int opcode, int fd, int events);
    private native int epollWait(long pollAddress, int numfds, long timeout, int epfd) throws IOException;

然而上述三个api并不是我们要的,哈哈哈哈~~~~
即使是jdk的c++代码,仍然不是我们想要的:
drawing

我们要的代码应该是EPollArrayWrapper.c文件头中声明的epoll.h文件的代码:
drawing

那我们应该如何找到这个epoll.h代码呢,我在openjdk8的源代码中并没有找到这个文件。
沮丧的百度了一圈后,发现epoll.h文件应该存在于glibc中。
地址:https://github.com/bminor/glibc/blob/glibc-2.4/sysdeps/unix/sysv/linux/sys/epoll.h

    // 创建一个epoll示例,并返回这个新实例的文件描述符(实际是文件描述符的指针)。
    //(通过文件描述符找到被打开的文件(套接字/socket))。
    extern int epoll_create (int __size) __THROW;

    // 将需要监听的fd和事件event交给epoll。成功返回1,异常返回-1。
    extern int epoll_ctl (int __epfd, int __op, int __fd, struct epoll_event *__event) __THROW;

    // 阻塞以等待注册的是事件被触发(存在已就绪的文件描述符)或直至timeout发生。
    // 返回触发的事件数量,异常则返回-1。
    extern int epoll_wait (int __epfd, struct epoll_event *__events, int __maxevents, int __timeout);

以上代码来自于GitHub开源的 openJDK8glibc2.4 代码库。


epoll的工作流程:

  1. 调用 epoll_create 会创建一个epoll实例,然后创建并维护 一颗红黑树rbr 和 一条就绪队列rbllist(链表结构)。
  2. 调用 epoll_ctl 将感兴趣的event 和 fd 传给epoll实例,然后进行事件挂载、注册回调函数。内核会在红黑树上添加相应的节点。文件描述符(fd)就绪的时候会触发回调函数,然后回调函数会将该就绪的fd假如当就绪队列rbllist中。
  3. 调用 epoll_wait 会持续检查就绪队列中是否有准备就绪的fd,直至超时。若就绪队列rbllist存在就绪的fd就复制到用户和内核的 共享空间(减少不必要的拷贝),共享空间是 mmap结构(零拷贝的一种实现)。
  4. 用户获取到就绪的fd_set(不需要自己判断哪些fd就绪,用户空间拿到的就是准备就绪的fd_est)。

时序图:
drawing

流程图:
drawing


相比于select和poll、epoll 做了哪些优化?

  1. epoll采用 回调机制,弃用 轮询 的方式去检测就绪的文件描述符。用户程序拿到的fd_set全部都是准备就绪的fd,不用像select一样自己去轮询fd_set,减少了对文件描述符的遍历。时间复杂度O(1)

  2. 减少了 用户态(user space)和 内核态(kernel space) 之间文件描述符的拷贝。

  3. select和poll 只支持 LT模式,而epoll还支持更高级的 ET模式,并且还支持EPOLLONESHOT事件。


epoll为什么用红黑树?
先看看红黑树节点属性:

struct epitem {
  ...
  //红黑树节点
  struct rb_node rbn;
  //双向链表节点
  struct list_head rdllink;
  //事件句柄等信息
  struct epoll_filefd ffd;
  //指向其所属的eventepoll对象
  struct eventpoll *ep;
  //期待的事件类型
    /* The structure that describe the interested events and the source fd */
  struct epoll_event event;
   //下一个epitem实例
   struct epitem *next;  ...
}; // 这里包含每一个事件对应着的信息。

代码地址:https://github.com/torvalds/linux/blob/master/fs/eventpoll.c

参考:linux源码解读(十七):红黑树在内核的应用——epoll

我的个人小结:
事件注册时,需要插入到 红黑树中(中间观察者)。而节点保存了fd信息和epoll_event信息,当注册事件发生变化时,可以通过fd找到应该响应的socket。
这个过程包含插入(事件挂载)、查询(事件回调)的过程,而红黑树的特性,查询上略逊色于AVL树,而插入上因为不需要保证绝对平衡所以又略优于AVL树,因此是一个比较折中的方案。

我们也可以不关心具体用什么数据结构实现这个“中间观察者”角色:

epoll_create 底层实现,到底是不是红黑树,其实也不太重要(完全可以换成 hashtable)。重要的是 efd 是个指针,其数据结构完全可以对外透明的修改成任意其他数据结构。


LT模式和ET模式

LT模式:
当epoll_wait()检查到描述符事件到达时,将此事件通知进行,进程可以不立即处理该事件,下次调用epoll_wait()会再次通知进程。
是默认的一种模式,并且同时支持Blocking和No-Blocking。

ET模型:
和LT模式不同的是,通知之后进程必须立即处理事件,下次再调用epoll_wait()时不会再得到事件到达的通知。
ET模式很大程度上减少了epoll事件被重复触发的次数,因此效率比LT模式要高。只支持No-Blocking,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。


select、poll及epoll分别在什么场景下比较合适?

epoll虽然优势最多,但也不是每个场景下都能胜任的。

  • select
    select的timeout参数精度为1 ns,比poll和epoll的1ms 实时性更高,因此适合实时性要求高的场景,且select的可移植性比较好。

  • poll
    poll 没有最大描述符数量限制(取决于系统可支持开启的最大数量),因此实时性要求不高的场景应该使用poll。

  • epoll
    适合于 linux平台、开启描述符的数量较大、描述符活跃度低的场景(如长连接、事件响应率低)下最适合。
    原因如下:
    如果描述符开启数量小、活跃度高,那么轮询也能有比较好的效果,体现不出epoll的优势。
    ①:epoll依赖于系统回调机制,如果连接活跃度高会造成epoll_ctl() 调用频繁,频繁的系统调用降低了效率。
    ②:epoll的fd_set存储的内核中,不易于调试。


本文参考:

  1. select、poll、epoll的原理与区别20
  2. select,poll及epoll区别
  3. epoll 为什么用红黑树?

本篇文章权属个人理解所谈,完全是站在巨人的肩膀上看世界。若有不对的地方欢迎交流指正,感谢!

posted @ 2022-06-23 17:41  竹根七  阅读(883)  评论(0编辑  收藏  举报