【NIO】Selector 详解

shadowLogo

前言:

在本人之前的博文中,本人讲解了 NIO三大核心知识点 之二 的 ChannelBuffer

相较于 BIOChannelBuffer,主要是体现在 数据传输的方面
但是,NIO非阻塞 特性,还有一个非常总要得到 组成部分 —— Selector

那么,时隔一年多,在掌握了很多的知识之后,本篇博文中,本人将继续讲解 NIO 的核心知识点 —— Selector


在讲解其原理之前,本人先来讲解下 Selector 这个组件的 作用

作用:

Selector 可以检测 多个注册的通道,是否 有事件发生
如果 有事件发生,就 获取事件,然后针对每一个事件,进行相应的处理
这样,就可以只用 单线程非阻塞式 地去 管理多个通道 的 连接和请求

总结下上面的描述,可以用一个词来概括 —— 多路复用

如下图所示:
作用


那么,看了上述的 作用 描述,我们也能总结出来,Selector 概念的提出,使得 NIO 具备了如下 优点

优点:

  • 单线程 监听事件,避免了 多线程 上下文切换导致开销
  • 只有在 有事件发生 时,才会进行 读写
    不用为了 每个连接 都创建 一个线程 去 处理读写
    大大 减少了系统开销

其实,总结来总结去,就是 多路复用 的优点


那么,讲了 Selector 的作用,并在 分析了其优点 之后,本人来讲解下 API 的使用:

API 使用:

常用API:

方法 描述
static Selector open() 获取 一个 Selector对象
Set keys() 返回 此选择器注册键集
Set selectedKeys() 返回 此选择器注册的、并且有事件发生键集
boolean isOpen() 判断 此选择器 是否已打开

那么,现在本人就来通过上述API,以及之前几篇博文的内容,来编写一个 NIO模式Server端

使用示例:

package edu.youzg.demo;

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

/**
 * @Author: Youzg
 * @CreateTime: 2021-04-30 17:45
 * @Description: 带你深究Java的本质!
 */
public class SelectorDemo {

    public static void main(String[] args) throws IOException {
        // 创建 serverSocket
        ServerSocketChannel serverSocket = ServerSocketChannel.open();
        serverSocket.socket().bind(new InetSocketAddress(9000));
        // 将 serverSocket 设置为 非阻塞
        serverSocket.configureBlocking(false);
        // 创建 selector
        Selector selector = Selector.open();
        // 为 selector 注册 监听serverSocket的“连接事件”
        serverSocket.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("服务器启动成功!开始监听客户端连接...");

        while (true) {
            // 阻塞监听 注册的事件发生
            selector.select();

            // 获取 注册过的、并且发生的事件 的键集
            Set<SelectionKey> selectedKeys = selector.selectedKeys();

            Iterator<SelectionKey> iterator = selectedKeys.iterator();
            while (iterator.hasNext()) {
                SelectionKey selectedKey = iterator.next();
                if (selectedKey.isAcceptable()) {   // 处理 “连接事件”
                    // 获取 触发“连接事件”的 serverSocketChannel
                    // (触发“连接事件”,一定是 ServerSocketChannel)
                    ServerSocketChannel server = (ServerSocketChannel) selectedKey.channel();
                    // 接收当前客户端的连接,并创建一个 socketChannel对象 与其通信
                    SocketChannel socketChannel = server.accept();
                    // 将 socketChannel 设置为 非阻塞
                    socketChannel.configureBlocking(false);

                    // 为 selector 注册 监听当前连接的socketChannel的“read事件”
                    socketChannel.register(selector, SelectionKey.OP_READ);
                    System.out.println("客户端[" + socketChannel.getRemoteAddress() + "]连接成功!");
                } else if (selectedKey.isReadable()) {  // 处理 “read事件”
                    // 获取 触发“read事件”的 socketChannel
                    // (触发“read事件”,一定是 socketChannel)
                    SocketChannel socketChannel = (SocketChannel) selectedKey.channel();
                    // 创建一个用于接收 客户端消息的 buffer
                    ByteBuffer buffer = ByteBuffer.allocate(128);
                    int msgLen = socketChannel.read(buffer);

                    if (msgLen > 0) {   // 如果 客户端消息有数据,则打印出来
                        System.out.println("客户端[" + socketChannel.getRemoteAddress() + "]消息:" + new String(buffer.array()));
                    } else if (msgLen == -1) {  // 如果 客户端请求 “断开连接”,则关闭当前socketChannel
                        System.out.println("客户端[" + socketChannel.getRemoteAddress() + "]断开连接");
                        socketChannel.close();
                    }
                }

                //从事件集合里删除本次处理的key,防止下次select重复处理
                iterator.remove();
            }
        }
    }

}

那么,我们来运行下上述代码,来看看基本功能是否编写成功:

运行展示:

首先,我们在 cmd 中,通过 telnet 的方式,连接上文所写的服务器:

telnet 127.0.0.1 9000

telnet
可以看到:

客户端连接事件,被监听并处理了!

接着,我们切换到 telnet命令界面

Ctrl + ]

进入
接着,我们来发送几条消息:

send Hello,Youzg.
send Youzg is a good man!

发送

可以看到:

客户端消息,处理成功了!

最后,我们来测试下 断开连接事件,是否也会被 监听并处理:

quit

quit
可以看到:

客户端 断开连接事件,被监听并处理了!


但是,上文所讲内容,是非常基础的
如果本文在一年前进行发布,上文可以作为全部内容
但是,在历经了一年后,阅读了很多源码,了解了更多的底层知识,甚至接手了一些项目之后,我们就要来了解下其 多路复用 特性,是如何实现的:

核心源码:

在上文中,我们也能发现:

我们使用 Selector,主要是调用了其如下几个方法:

Selector.open()  //创建多路复用器
socketChannel.register(selector, SelectionKey.OP_READ)  //将channel和对应事件 注册到多路复用器上
selector.select()  // 阻塞等待 注册的事件发生

那么,本人就来讲解下上述三个方法的底层源码实现:

Selector.open:

分析流程:

1
我们跟进去 provider()方法,看看是如何执行的:
2
那么,我们继续跟进去,看看是如何 获取selector提供器 的:
3


我们可以看到:

创建了一个 Windows系统的selector提供器

那么,有 Windows版本的提供器,就一定有 Linux版本的提供器

但是,如果我们在IDEA这种Java编译器中跟踪源码,只能跟踪到上图那些 Windows系统的JDK源码
(因为在我们下载JDK时,就根据 系统版本,选择了 不同的JDK)

那么,本人就用其它编译器,打开下载好的 OpenJDK源码,看看 Linux版本 下,是如何获取 selector提供器 的:
linux
我们可以看到:

如果是 Linux系统,创建的 selector提供器类型 就会是 EPollSelectorProvider


由于 Windows操作系统 的源码,是 闭源 的,会阻碍之后内容的讲解
那么,现在本人就来通过Linux版本的实现,讲解下 Selector的 多路复用 功能 的 具体实现
1

可以看到:

provider() 方法,在Linux操作系统上,创建了一个 EPollSelectorProvider 对象

5

而之后的 openServerSocketChannel()方法,在Linux操作系统上,则调用了 EPollSelectorProvider 对象 的 openSelector()方法,创建了一个 EPollSelectorImpl对象


那么, 我们再来看看 EPollSelectorImpl对象 的 单参构造 具体执行了什么:
单参

可以看到:

单参构造内部 创建了一个 EPollArrayWrapper对象
这也是 Selector 实现 多路复用 功能的 核心


那么,我们继续跟进去,看看底层是如何实现的:
核心
我们可以看到:

其内部调用了 一个 epollCreate()方法

而好巧不巧,epollCreate()方法 是一个 native的本地方法
本地


那么,为了秉持 一究到底 的态度,本人来展示下 这个本地方法具体实现
cpp

那么,我们来看看,epoll_create函数 的功能是什么:
man

int epoll_create(int size);

我们可以看到:

根据 Linux官方描述
epoll_create函数作用是:
创建一个epoll对象,并且返回其 文件描述符(用于定位该对象)

参数size 代表可能会容纳 size个描述符,但 size 不是 一个 最大值,只是提示操作系统它的 数量级,现在这个参数基本上已经弃用

到这里,就基本上到了最底层 —— Linux内核函数


总结:

Selector.open()方法,其本质上,是创建了一个 epoll对象


那么,我们再来看看 socketChannel.register(arg1, arg2) 方法,其底层是如何实现的:

socketChannel.register:

分析流程:

1
我们可以看到:

这是一个 包装方法,我们继续跟进


2
我们继续跟进 212行代码
3

那么,我们来看看,是如何进行 注册 的:
4
我们可以看到:

调用了 Windows系统版本的选择器实现类implRegister()方法

与之前分析同理,我们来看看在 Linux操作系统 下,JDK 是如何进行实现的:
核心实现
从上图中,我们可以看出:

implRegister()方法本质,是:
Selector.open 这一步时创建的 pollWrapper对象内部维护的集合 中,存放 目标socketChannel对象


总结:

socketChannel.register 方法,其本质上,是 向 Selector.open 这一步时创建的 pollWrapper对象内部维护的集合 中,存放 目标socketChannel对象


那么,最后就是Selector最最最重要的 selector.select方法

selector.select:

分析流程:

1
我们继续跟进:
2
我们可以看到:

其内部调用了一个方法,阻塞式监听 注册的事件

3
我们可以看到:

其内部通过加锁方式,保证了 同步

我们继续跟进去,看看是如何进行 事件监听 的:
4
我们可以看到:

调用了 Windows系统版本的selector实现类doSelected()方法

那么,我们还是来看看 Linux版本 下,是如何实现 事件监听 的:
5
我们可以看到:

又调用了 pollWrapper对象poll()方法,实现了 监听事件 的功能


那么,我们来看看,poll()方法 底层是如何实现的:
6


我们首先来看看 更新注册事件 是如何实现的:
7
我们跟进 299行 代码:

8
可以看到:

又是调用了 本地方法

那我们来 Linux系统 上,看看其是如何介绍的:
linux

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

我们可以看到:

根据 Linux官方描述
epoll_ctl函数作用是:
使用 文件描述符epfd 引用的 epoll实例,对 目标文件描述符fd 执行 op操作

  • 参数epfd 表示 epoll对应的 文件描述符
  • 参数fd 表示 socket对应的文件描述符
  • 参数op 有以下几个值:
    EPOLL_CTL_ADD注册 新的fd到epfd中,并关联事件event;
    EPOLL_CTL_MOD修改 已经注册的fd的监听事件;
    EPOLL_CTL_DEL:从epfd中 移除fd,并且忽略掉绑定的event,这时event可以为null;
  • 参数event 是一个 结构体

简而言之,就是 真正地 将事件注册到selector的结构体上


那么,我们再来看看之后的 epoll_wait()方法 在底层是如何实现的:
wait
可以看到:

又是调用了 本地方法

那我们来 Linux系统 上,看看其是如何介绍的:

10

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

我们可以看到:

根据 Linux官方描述
epoll_wait函数作用是:
等待 文件描述符epfd上的事件

  • 参数epfd 表示 Epoll对应的文件描述符
  • 参数events 表示 调用者所有可用事件的集合
  • 参数maxevents 表示 最多等到多少个事件就返回
  • 参数timeout 表示 超时时间

总结:

selector.select方法,其本质上,是 阻塞并监听 在socketChannel.register 这一步 注册的事件,并将 监听到的事件 注册到 epoll对象 的 rdlist 中去


那么,本人最后,通过一张 流程图,来总结下上面三个方法的 底层执行逻辑

流程图:

流程图


那么,至于为什么 epoll对象 能实现 多路复用,就是 操作系统 的知识

本人在这里通过比较,来为同学们普及一下:

select、poll 与 epoll:

| | select | poll | epoll(jdk 1.5及以上) |
|--|--|--|--|--|
| 操作方式 | 遍历 | 遍历| 回调|
| 底层实现 | 数组 | 链表| 哈希表|
| IO效率 | 每次调用 都进行 线性遍历
时间复杂度 为 O(n) | 每次调用 都进行 线性遍历
时间复杂度为 O(n)| 事件通知 方式
每当有 IO事件就绪,系统注册的回调函数 就会被调用
时间复杂度 为 O(1) |
| 最大连接 |有上限 |无上限 | 无上限 |

那么,至此,相信同学们基本上能了解 NIOSelector 的 作用以及实现原理 了!
(码到这里,也到了5.1的凌晨2:55了,睡了睡了😴)
结束

posted @ 2021-05-01 03:01  在下右转,有何贵干  阅读(852)  评论(1编辑  收藏  举报