select、poll、epoll的底层原理剖析

select、poll、epoll的底层原理剖析在这个过程中,最最最关键点在于其中的一行代码:在这里调用了 poll 方法,执行具体的事件拉取逻辑,进一步往下走:最后会调用 WindowsSe

欢迎大家来到IT世界,在知识的湖畔探索吧!

引言

select/poll、epoll 这些词汇相信诸位都不陌生,因为在 Redis/Nginx/Netty 等一些高性能技术栈的底层原理中,大家应该都见过它们的身影,接下来重点讲解这块内容,不过在此之前,先上一张图概述 Java-NIO 的整体结构:

select、poll、epoll的底层原理剖析

观察上述结构,其实 Buffer、Channel 的定义并不算复杂,仅是单纯的三层结构,因此对于源码这块不再去剖析,有兴趣的根据给出的目录结构去调试源码,自然也能摸透其原理实现。

而最关键的是 Selector 选择器,它是整个 NIO 体系中较为复杂的一块内容,同时它也作为 Java-NIO 与内核多路复用模型的“中间者”,但在上述体系中,却出现了之前未曾提及过的 SelectorProvider 系定义,那么它的作用是干嘛的呢?主要目的是用于创建选择器,在Java中创建一般是通过如下方式:

// 创建Selector选择器
Selector selector = Selector.open();

// Selector类 → open()方法
public static Selector open() throws IOException {
    return SelectorProvider.provider().openSelector();
}
复制代码

欢迎大家来到IT世界,在知识的湖畔探索吧!

从源码中可明显得知,选择器最终是由 SelectorProvider 去进行实例化,不过值得一提的是: Selector 的实现是基于工厂模式与 SPI 机制构建的。对于不同 OS 而言,其对应的具体实现并不相同,因此在 Windows 系统下,我们只能观测到 WindowsSelectorXXX 这一系列的实现,而在 Linux 系统时,对于的则是 EPollSelectorXXX 这一系列的实现,所以要牢记的是, Java-NIO 在不同操作系统的环境中,提供了不同的实现 ,如下:

  • Windowsselect
  • Unixpoll
  • Mackqueue
  • Linuxepoll

当然,本次则重点剖析Linux系统下的 select、poll、epoll 的具体实现,对于其他系统而言,原理大致相同。

一、JDK层面的源码入口

简单的对于 Java-NIO 体系有了全面认知后,接下来以 JDK 源码作为入口进行剖析。在Java中,会通过 Selector.select() 方法去监听事件是否被触发,如下:

欢迎大家来到IT世界,在知识的湖畔探索吧!// 轮询监听选择器上注册的通道是否有事件被触发
while (selector.select() > 0){}

// Selector抽象类 → select()抽象方法
public abstract int select() throws IOException;

// SelectorImpl类 → select()方法
public int select() throws IOException {
    return this.select(0L);
}
// SelectorImpl类 → select()完整方法
public int select(long var1) throws IOException {
    if (var1 < 0L) {
        throw new IllegalArgumentException("Negative timeout");
    } else {
        return this.lockAndDoSelect(var1 == 0L ? -1L : var1);
    }
}
复制代码

当调用 Selector.select() 方法后,最终会调用到 SelectorImpl 类的 select(long var1) 方法,而在该方法中,又会调用 lockAndDoSelect() 方法,如下:

// SelectorImpl类 → lockAndDoSelect()方法
private int lockAndDoSelect(long var1) throws IOException {
    // 先获取锁确保线程安全
    synchronized(this) {
        // 在判断当前选择是否处于开启状态
        if (!this.isOpen()) {
            // 如果已关闭则抛出异常
            throw new ClosedSelectorException();
        } else { // 如若处于开启状态
            // 获取所有注册在当前选择器上的事件
            Set var4 = this.publicKeys;
            int var10000;
            // 再次加锁
            synchronized(this.publicKeys) {
                // 获取所有已就绪的事件
                Set var5 = this.publicSelectedKeys;
                // 再次加锁
                synchronized(this.publicSelectedKeys) {
                    // 真正的调用select逻辑,获取已就绪的事件
                    var10000 = this.doSelect(var1);
                }
            }
            // 返回就绪事件的数量
            return var10000;
        }
    }
}
复制代码

在该方法中,对于其他逻辑不必太过在意,重点可注意:最终会调用 doSelect() 触发真正的逻辑操作,接下来再看看这个方法:

欢迎大家来到IT世界,在知识的湖畔探索吧!// SelectorImpl类 → doSelect()方法
protected abstract int doSelect(long var1) throws IOException;

// WindowsSelectorImpl类 → doSelect()方法
protected int doSelect(long var1) throws IOException {
    // 先判断一下选择器上是否还有注册的通道
    if (this.channelArray == null) {
        throw new ClosedSelectorException();
    } else { // 如果有的话
        // 先获取一下阻塞等待的超时时长
        this.timeout = var1;
        // 然后将一些取消的事件从选择器上移除
        this.processDeregisterQueue();
        // 再判断一下是否存在线程中断唤醒
        // 这里主要是结合之前的wakeup()方法唤醒阻塞线程的
        if (this.interruptTriggered) {
            this.resetWakeupSocket();
            return 0;
        } else { // 如果没有唤醒阻塞线程的需求出现
            // 先判断一下辅助线程的数量(守护线程),多则减,少则增
            this.adjustThreadsCount();
            // 更新一下finishLock.threadsToFinish为辅助线程数
            this.finishLock.reset();
            // 唤醒所有的辅助线程
            this.startLock.startThreads();
            try {
                // 设置主线程中断的回调函数
                this.begin();

                try {
                    // 最终执行真正的poll逻辑,开始拉取事件
                    this.subSelector.poll();
                } catch (IOException var7) {
                    this.finishLock.setException(var7);
                }
                // 唤醒并等待所有未执行完的辅助线程完成
                if (this.threads.size() > 0) {
                    this.finishLock.waitForHelperThreads();
                }
            } finally {
                this.end();
            }
            // 检测状态
            this.finishLock.checkForException();
            this.processDeregisterQueue();
            // 获取当前选择器监听的事件的触发数量
            int var3 = this.updateSelectedKeys();
            // 本轮poll结束,重置WakeupSocket,为下次执行做准备
            this.resetWakeupSocket();
            // 最终返回获取到的事件数
            return var3;
        }
    }
}
复制代码

整个过程下来其实也并不短暂,但大体就分为三步:

poll

在这里面,有一个辅助线程的概念,这跟最大文件描述符有关,每当选择器上注册的通道数超过 1023 时,新增一条线程来管理这些新增的通道。其实是 1024 ,但其中有一个要用于唤醒,所以是 1023 (这里看可能有些懵,但待会分析过后就理解了)。

在这个过程中,最最最关键点在于其中的一行代码:

this.subSelector.poll();
复制代码

在这里调用了 poll 方法,执行具体的事件拉取逻辑,进一步往下走:

// WindowsSelectorImpl类 → poll()方法
private int poll() throws IOException {
    return this.poll0(WindowsSelectorImpl.this.pollWrapper.pollArrayAddress, 
    Math.min(WindowsSelectorImpl.this.totalChannels, 1024), 
    this.readFds, this.writeFds, this.exceptFds,
    WindowsSelectorImpl.this.timeout);
}

// WindowsSelectorImpl类 → poll0()方法
private native int poll0(long var1, int var3, int[] var4, 
                int[] var5, int[] var6, long var7);
复制代码

最后会调用 WindowsSelectorImpl.poll() 方法,而该方法最终会调用本地的 native 方法: poll0() 方法,而在 JVM 的源码实现中,该方法最终会调用内核所提供的函数。

OK~,由于 WindowsIDEA 工具辅助,所以方便调试源码,因此这里以 WindowsSelectorXXX 系的举例说明,但由于整个 Java-NIO 的核心组件,都是基于工厂模式编写的源码,所以其他操作系统下的源码位置也相同,仅最终调用的内核函数不同!!!

最终稍做总结, JDK 层面的源码入口,核心流程如下:

  • Selector 抽象类 → select() 抽象方法
  • SelectorImpl 类 → select() 方法
  • SelectorImpl 类 → lockAndDoSelect() 方法
  • SelectorImpl 类 → doSelect() 方法
  • XxxSelectorImpl 类 → doSelect() 方法
  • XxxSelectorImpl 类 → poll() 方法
  • XxxSelectorImpl 类 → JNI 本地的 poll0() 方法

如若在 Windows 系统下,上述的 XxxSelectorImpl 类则为 WindowsSelectorImpl ,同理,如若在 Linux 系统下, XxxSelectorImpl 类则为 EpollSelectorImpl

最后,如果大家对于JDK层面的 EPoll 感兴趣,可自行反编译 Linux 版的 JDK 源码, EpollSelectorXXX 的相关定义位于: jdk\src\solaris\classes\sun\nio\ch\ 目录下。

二、JDK源码级别的入口

经过第一阶段的分析后,会发现最终其实调用了 native 本地方法 poll0() ,在之前的 《JVM运行时数据区-本地方法栈》 的文章提到过,当程序执行时碰到 native 关键字修饰的方法时,会调用 C/C++ 所编写的本地方法库中的实现,那么又该如何查找 native 方法对应的源码呢?接着一起来聊一下。

①由于 Oracle-jdk 是收费的,所以咱们首先下载 open-jdk1.8 的源码,可以自行在Open-JDK官网下载,但官网下载时,常常会由于网络不稳定而中断,下载起来相当费劲,因此也为大家提供一下《open-jdk1.8》的源码链接。

②下载之后解压源码包,然后进入 jdk8-master\jdk\src\ 目录,在其中你会看到不同操作系统下的 Java 实现, JDK 源码会以操作系统的类型分包,不同系统的对应不同的实现,如下:

select、poll、epoll的底层原理剖析

但关于 Linux 系统下的 Java-NIO 实现,实际上并不在 linux 目录中,而是在 solaris 目录,进入 solaris 目录如下:

select、poll、epoll的底层原理剖析

solaris 目录中还包含了 LinuxOS、SunOS(SolarisOS/UnixOS) 以及 MacOS 等操作系统下的 Java-NIO 实现,但关于 MacOS 下的 Java-NIO 完整实现,则位于前面的 macosx 目录中,这里仅包含一部分,结构如下:

select、poll、epoll的底层原理剖析

观察上图会发现, solaris 目录中包含了 KQueue、EPoll、Poll、DevPollIO 多路复用模型的 Java 实现,但关于 Mac-KQueue 的完整实现则在 macosx 目录。

OK~,到目前为止大家对于 JDK 源码的目录结构应该有了基本认知。

稍微总结一下,重点就是搞清楚两个位置:

  • jdk8-master\jdk\src\xxxOS\classes\sun\nio\ch :对应 nio 包下的 Java 代码。
  • jdk8-master\jdk\src\xxxOS\native\sun\nio\ch :对应 nio 包中 native 方法的 JNI 代码。

③搞清楚 JDK 源码目录的结构后,那以之前分析的 Windows-NIO 为例:

private native int poll0(long var1, int var3, int[] var4, 
                int[] var5, int[] var6, long var7);
复制代码

对于 poll0() 这个本地方法,又该如何查找对应的源码呢?根据上述的源码结构,先去到 \windows\native\sun\nio\ch 目录中,然后找到与之对应的 WindowsSelectorImpl.c 文件,最终就能在该文件中定位到对应的 JNI 方法: Java_sun_nio_ch_WindowsSelectorImpl_00024SubSelector_poll0 (名字略微有些长)。

④找到对应的 JNI 方法源码后,其中存在这么一行:

select、poll、epoll的底层原理剖析

观察之后不难发现,其实最终还会调用到 OS 内核的提供的 select() 函数,所以 poll0() 实际上会依赖 OS 提供的多路复用函数实现相应的功能,对于其他操作系统而言,也是同理。

但是接下来只会重点叙述 Linux 下的三大 IO 多路复用函数: select、poll、epoll ,而对于 Windows-select、Mac-kqueue 不会进行深入讲解(不是不想分析,而是由于 Windows、Mac 系统都属于闭源的,想分析也无法获取其具体的源码实现过程)。

三、文件描述符与自实现网络服务器

到目前可得知:Java中的 NIO 最终会依赖于操作系统所提供的多路复用函数去实现,而 Linux 系统下对应的则是 epoll 模型,但 epoll 的前身则是 select、poll ,因此我们先分析 select、poll 多路复用函数,再分析其缺点,逐步引出 epoll 的由来,最终进一步对其进行全面剖析。

相信大家在学习 Linux 时,都听说过“ Linux 本质上就是一个文件系统 ”这句话,在 Linux-OS 中,万事万物皆为文件,连网络连接也不例外,因此在分析多路复用模型之前,咱们首先对这些基础概念做一定了解。

3.1、文件描述符(FD)

在上述中提到过: Linux 的理念就是“一切皆文件”,在 Linux 中几乎所有资源都是以文件的形式呈现的。如磁盘的数据是文件,网络套接字是文件,系统配置项也是文件等等,所有的数据内容在 Linux 都是通过文件系统来管理的。

既然所有的内容都是文件,那当我们要操作这些内容时,又该如何处理呢?为了方便系统执行, Linux 都是通过文件描述符 File Descriptor 对文件进行操作,对于文件描述符这个概念可以通过一个例子来理解:

Object obj = new Object();
复制代码

上述是Java创建对象的一行代码,类比 Linux 的文件系统,后面 new Object() 实例化出来的对象可以当成是具体的文件内容,而前面的引用 obj 则可理解为是文件描述符。 Linux 通过 FD 操作文件,其实本质上与 Java 中通过 reference 引用操作对象的过程无异。

而当出现网络套接字连接时,所有的网络连接都会以文件描述符的形式在内核中存在,也包括后面会提及的多路复用函数 select、poll、epoll 都会基于 FD 对网络连接进行操作,因此先阐明这点,作为后续分析的基础。

3.2、自己设计网络连接服务器

在分析之前,我们先自己设想一下,如果有个需求:请自己设计一套网络连接系统,那么此时你会怎么做呢?此刻例如来了 5 个网络连接,如下:

select、poll、epoll的底层原理剖析

那么又该如何处理这些请求呢?最简单的方式:

select、poll、epoll的底层原理剖析

对于每个到来的网络连接都为其创建一条线程,每个连接由单独的线程负责处理,所以最初的 BIO 也是这样来的,由于设计起来非常简单,所以它成为了最初的网络 IO 模型,但这种方式的缺陷非常明显,在之前的 BIO 章节也曾分析过,无法支撑高并发的流量访问,因此这种多线程的方式去实现自然行不通了,兜兜转转又得回到单线程的角度去思考,单线程如何处理多个网络请求呢?最简单的方式,伪代码如下:

// 不断轮询监听所有的网络连接
while(true){
    // 遍历所有的网络套接字连接
    for(SocketFD xFD : FDS){
        // 判断网络连接中是否有数据
        if (xFD.data != null){
            // 从套接字中读取网络数据
            readData();
            // 将网络数据交给应用程序处理(写入对应的程序缓冲区)
            processingData();
            // ......
        }
    }
}
复制代码

如上代码,当有网络连接到来时,将其加入 FDS 数组中,然后由单条线程不断的轮询监听所有网络套接字,如果套接字中有数据,则从中将网络数据读取出来,然后将读取到的网络数据交给应用程序处理。

这似乎是不是就通过单线程的方式解决了多个网络连接的问题?答案是 Yes ,但相较而言,性能自然不堪入目,如果内核是这样去处理网络连接,对于并发支持自然也上不去,那 Linux 内核具体是如何处理的呢?一起来看看。

四、多路复用函数 – select()

JDK1.8 的源码中,刚刚似乎并未发现 Selectxxx 这系列的定义,这是由于 Linux 内核 2.6 之后的版本中,已经使用 epoll 代替了 select ,所以对应的 JDK1.5 之后版本,也将 Linux-select 的实现给移除了,所以如若想观测到 Linux-select 相关的实现,那还需先安装一个 kernel-2.6 以下的 Linux 系统,以及还需要下载 JDK1.5 的源码,这样才能分析完整的 select 实现。

我大致过了一下内核中的源码,对于 select 函数的实现大致在 2000 行左右,大致看下来后,由于对 C 语言没有那么熟悉,并且源码实现较长,因此后续不再以全源码链路的方式剖析,而是适当结合部分核心源码进行阐述。当然,如若你的 C 语言功底还算扎实,那可以下载 《Linux2.6.28.6版本内核源码》 解压调试。

先讲清楚接下来的分析思路,在后续分析IO多路复用函数时,大体会以调用入口 → 函数定义 → 核心结构体 → 核心源码 → 函数缺陷这个思路进行展开。

4.1、Java-select函数的JNI入口

对于 Open-JDK1.4、1.5 的源码,由于年代较久远了,实在没有找到对应的 JDK 源码,所以在这里分析 Linux-select 函数时,就以前面分析的 Windows-select 思路举例说明,如下:

  • Java 中通过调用选择器的 select() 方法监听客户端连接。
  • ②线程执行时,会执行到当前平台对应的选择器实现类的 doSelect() 方法。
  • ③接着会调用实现类对应的 poll() 轮询方法,最终在该方法中会调用其 native 方法。
  • ④当线程需要执行本地方法时,触发 JNI 调用,会在本地方法库中查找对应的 C 实现。
  • ⑤定位到 native 本地方法对应的 C 语言函数,然后执行对应的 C 代码。
  • ⑥在 C 代码的函数中,最终会发起系统调用,那假设此时系统调用的函数为 select()

此时,对于 Java 是如何调用底层操作系统内核函数的过程就分析出来了,但是由于这里没有下载到对应版本的源码,因此无法通过源码进行演示,但就算没有对应的源码作为依据也无大碍,因为无论是什么类型的操作系统,也无论调用的是哪个多路复用函数,本质上入口都是相同的,只是 JNI 调用时会存在些许差异。

4.2、内核select函数的定义

OK~,得知了 Java-NIO 执行的前因后果后,现在来聊一聊最初 NIO 会调用的系统函数: select ,在 Linux 中的定义如下:

// 定义位于/sys/select.h文件中
int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);
复制代码

select 函数定义中,存在五个参数,如下:

  • nfds :表示 FDS 中有效的 FD 数量,全部文件描述符的最大值 +1
  • readfds :表示需要监控读事件发生的文件描述符集合。
  • writefds :表示需要监控写事件发生的文件描述符集合。
  • exceptfds :表示需要监控异常/错误发生的文件描述符集合。
  • timeout :表示 select 在没有事件触发的情况下,会阻塞的时间。

4.3、select结构体 – fd_set、timeval

在上述中简单了解 select 的定义与参数后,大家可能会有些晕乎乎的,这是由于这五个参数中涉及到两组类型的定义,分别为 fd_set、timeval ,先来看看它们是如何定义的:

// 相关定义位于linux/types.h、linux/posix_types.h文件中
// -------linux/types.h----------
// 这里定义了一个__kerenl_fd_set的类型,别名为fd_set。
typedef __kerenl_fd_set fd_set;
省略其他.....

// -------linux/posix_types.h----------
/* 
  unsigned long表示无符号长整型,占4bytes/32bits
  sizeof()函数是求字节的长度,sizeof(unsigned long)=4
  因此最终这里的__NFDBITS=(8 * 4)=32
*/
#undef __NFDBITS
#define __NFDBITS (8 * sizeof(unsigned long))

// 这里限制了最大长度为1024(可修改,不推荐)
#undef __FD_SETSIZE
#define __FD_SETSIZE 1024

// 根据前面的__NFDBITS求出long数组的最大容量为:1024/32=32个
#undef __FD_SET_LONGS
#define __FD_SET_LONGS (__FD_SETSIZE/__NFDBITS)

// 这两组定义则是用于置位、复位(清除置位)的
#undef __FDELT
#define __FDELT(d) ((d) / __NFDBITS)
#undef __FDMASK
#define __FDMASK(d) (1UL << (d) % __NFDBITS)

// 这里定义了__kerenl_fd_set类型,本质上是一个long数组
typedef struct {
    unsigned long fds_bits [__FDSET_LONGS];
} __kerenl_fd_set;
复制代码

观察上述源码,其实你会发现 fd_set 的定义是 __kerenl_fd_set 类型的,而 __kerenl_fd_set 的定义本质上就是一个 long 数组,同时在 __kerenl_fd_set 的定义中,也声明了最大长度为 1024 ,相信了解过多路复用函数的小伙伴都知道 select 模型的最大缺陷之一就在于: 最多只能监听 1024 个文件描述符 ,而对于具体是为什么,相信看到这个源码大家就彻底清楚了。

PS:首先基于上述的知识,已经得知最大长度为 1024 ,但这 1024 并非代表着:数组可以拥有 1024long 元素,而是限制了这个 long 数组最多只能有 1024 个比特位的长度,也就是数组中最多能拥有 1024/32=32 个元素。对于这点,在源码中也有定义,大家可参考源码中的注释。

OK~,那这个 long 类型的数组究竟有什么作用呢?简单来说明一下,在这个 fd_set 的数组中,其实每个位对应着一个 FD 文件描述符的状态, 0 代表没有事件发生, 1 则代表有事件触发,如下图:

select、poll、epoll的底层原理剖析

在这个数组中,所有的 long 元素,在计算机底层本质上都会被转换成 bit 存储,而每一个 bit 位都对应着一个 FD ,所以这个数组本质上就组成了一个位图结构,同时为了方便操作这个位图,在之前的 sys/select.h 文件中还提供了一组宏函数,如下:

// 位于/sys/select.h文件中
// 将一个fd_set数组所有位都置零
int FD_ZERO(int fd, fd_set *fdset);
// 将指定的某个位复位(赋零)
int FD_CLR(int fd, fd_set *fdset);
// 将指定的某个位置位(赋一)
int FD_SET(int fd, fd_set *fd_set);   
// 检测指定的某个位是否被置位
int FD_ISSET(int fd, fd_set *fdset);

// 这里则是上述宏函数的实现(位操作过程)
# define __FD_ZERO(set)  \
  do {									      \
    unsigned int __i;							      \
    fd_set *__arr = (set);						      \
    for (__i = 0; __i < sizeof (fd_set) / sizeof (__fd_mask); ++__i)	      \
      __FDS_BITS (__arr)[__i] = 0;					      \
  } while (0)
  
#define __FD_SET(d, set) \
  ((void) (__FDS_BITS (set)[__FD_ELT (d)] |= __FD_MASK (d)))
#define __FD_CLR(d, set) \
  ((void) (__FDS_BITS (set)[__FD_ELT (d)] &= ~__FD_MASK (d)))
#define __FD_ISSET(d, set) \
  ((__FDS_BITS (set)[__FD_ELT (d)] & __FD_MASK (d)) != 0)
复制代码

对于定义的几组宏函数,可以参考上述注释中的解释,而对于这些函数是如何实现的,大家可以自行阅读贴出的源码。接下来再看看 timeval 结构体是如何定义的:

struct timeval {
   long    tv_sec;         /* 秒 */
   long    tv_usec;        /* 毫秒 */
};
复制代码

其实这个结构体就是一个阻塞的时间,好比 select 传入的 timeout 参数为 3 ,则 timeval.tv_sec=3、timeval.tv_usec=3000 ,代表调用 select() 没有获取到有效事件的情况下,在 3s 内会不断循环检测。当然,这个 timeout 的值会分为三种情况:

  • 0 :表示调用 select() 函数后不等待,没有就绪事件时直接返回。
  • NULL :表示调用 select() 函数后无限等待,阻塞至出现中断信号或触发事件后返回。
  • 正数:表示调用 select() 函数后,在指定的时间内等待事件触发,超时则返回。

至此,对于 select() 函数所需参数中,涉及到的两个结构体已经弄明白了,那么再回来看看 select() 的五个参数。

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);
复制代码

调用 select() 时,中间的三个参数要求传入 fd_set 类型,它们分别对应着:那些文件描述符需要监听读事件发生、那些文件描述符需要监听写事件发生、那些文件描述符需要监听异常错误发生。当调用 select() 函数后会陷入阻塞,直到有描述符的事件就绪(有数据可读、可写或出现异常错误)或超时后才会返回。而 select() 函数返回也会存在三种状态:

  • 0 :当描述符集合中没有事件触发,并且超出设置的时间后,会返回 0
  • -1 :当 select 执行过程中,出现异常/错误时则会返回 -1
  • 正数:如果监视的文件描述符集合中有事件发生(有数据),则会对应的事件数量。

4.4、select()函数的使用案例

在上述中已经对于 select() 函数的一些基础知识建立了认知,接下来上个伪代码感受一下 select() 函数的使用过程:

/* ----------①---------- */
// 创建服务端socket套接字,并监听客户端连接
serverSockfd = socket(AF_INET,SOCK_STREAM,0);
// 省略.....
bind(serverSockfd,IP,Port);
listen(serverSockfd,numfds);
// 这里是已经接收的客户端连接集合
fds[numfds] = accept(serverSockfd,.....);

/* ----------②---------- */
// 将所有的客户端连接,分别加入对应的位图中
FD_SET readfds, writefds, exceptfds;
int read_count = 0, write_count = 0, except_count = 0;
for (i = 0; i < numfds; i++) {
    if (fds[i].events == 读取事件){
        // 加入readfds
    }
    if (fds[i].events == 写入事件){
        // 加入writefds
    }
    // 省略.....
}

/* ----------③---------- */
// 求出最大的fds值
maxfds = ....;
struct timeval timevalue, *tv;
// 省略.....

/* ----------④---------- */
while(1){
    // 初始化位图
    FD_ZERO(readfds);
    FD_ZERO(writefds);
    FD_ZERO(exceptfds);
    // 分别对每个位图中需要监听的FD进行置位
    for (i = 0; i < numfds; i++) {
        if (fds[i].events == 读取事件){
	    FD_SET(fds[i],&readfds);
	}
	// 省略其他置位处理.....
    }
    
    // 调用select函数
    int result = select(maxfds+1, &readfds, &writefds, &exceptfds, tv);
    
    /* ----------⑤---------- */
    if (result == 0){
	// 处理超时并返回....
    }
    if (result < 0){
	// 处理异常并返回....
    }
    
    /* ----------⑥、⑦---------- */
    // 能执行到这里,代表select()返回大于0
    for (i = 0; i < numfds; i++) {
	if(FD_ISSET(fds[i],&readfds)){
	    // 读取被置位的socket.....
	    read(fds[i], buffer,0,MAXBUF);
	}
	// 省略其他......
    }
}
复制代码

上述的伪代码虽然看着较多,但本质上并不难,大体分为如下几步:

Socket
select()
FD
FD

对于这个伪代码,其实也是调用 select() 函数的通用模型,以 JavaJNI 调用为例,其实大体的过程也是相同的,如下:

select、poll、epoll的底层原理剖析

没有下载到 JDK1.5 的源码,所以以 Windows-select 的调用为例。

4.5、内核select函数核心源码

在上述过程中,我们调用了 select() 函数实现了 IO 多路复用,但调用之后 select() 的执行过程,相对而言其实是未知,那么接着再来看看 select() 的核心源码,剖析一下调用 select 后,内核究竟会如何处理。

内核源码的执行流程: sys_select() → SYSCALL_DEFINE5() → core_sys_select() → do_select() → f_op->poll/tcp_poll()

所有的系统调用,都可以在它的名字前加上“ sys_ ”前缀,这就是它在内核中对应的函数。比如系统调用 open、read、write、select ,与之对应的内核函数为: sys_open、sys_read、sys_write、sys_select ,因此上述的 sys_select() 其实就是 select() 函数在内核中对应的函数。

接着来看看 SYSCALL_DEFINE5()、core_sys_select() 函数的内容:

// 位于fs/select.c文件中(sys_select函数)
SYSCALL_DEFINE5(select, int, n, fd_set __user *, inp, fd_set __user *, outp,
    fd_set __user *, exp, struct timeval __user *, tvp)
{
	struct timespec end_time, *to = NULL;
	struct timeval tv;
	int ret;
    // 判断是否传入了超时时间
	if (tvp) {
		if (copy_from_user(&tv, tvp, sizeof(tv))) 
			return -EFAULT;

		to = &end_time;
		// 如果已经到了超时时间,则中断执行并返回
		if (poll_select_set_timeout(to,
				tv.tv_sec + (tv.tv_usec / USEC_PER_SEC),
				(tv.tv_usec % USEC_PER_SEC) * NSEC_PER_USEC))
			return -EINVAL;
	}
    // 未超时或没有设置超时时间的情况下,调用core_sys_select
	ret = core_sys_select(n, inp, outp, exp, to);
	ret = poll_select_copy_remaining(&end_time, tvp, 1, ret);

	return ret;
}

// 位于fs/select.c文件中(core_sys_select函数)
int core_sys_select(int n, fd_set __user *inp, fd_set __user *outp,
			   fd_set __user *exp, struct timespec *end_time)
{
	fd_set_bits fds;
	void *bits;
	int ret, max_fds;
	unsigned int size;
	struct fdtable *fdt;
	/* 由于涉及到了用户态和内核态的切换,因此将位图存储在栈上,
	   (尽量提升状态切换时的效率,这里采用栈的方式存储) */
	long stack_fds[SELECT_STACK_ALLOC/sizeof(long)];

	ret = -EINVAL;
	if (n < 0)
		goto out_nofds;

	// 先计算出max_fds值
	rcu_read_lock();
	fdt = files_fdtable(current->files);
	max_fds = fdt->max_fds;
	rcu_read_unlock();
	if (n > max_fds)
		n = max_fds;

	// 根据前面计算的max_fds值,判断一下前面开栈空间是否足够
	// (在这里涉及到一个新的结构体:fd_set_bits,稍后详细分析)
	size = FDS_BYTES(n);
	bits = stack_fds;
	if (size > sizeof(stack_fds) / 6) {
		// 如果空间不够则调用内核的kmalloc为fd_set_bits分配更大的空间
		ret = -ENOMEM;
		bits = kmalloc(6 * size, GFP_KERNEL);
		if (!bits)
			goto out_nofds;
	}
	// 将fd_set_bits中六个位图指针指向分配好的内存位置
	fds.in      = bits;
	fds.out     = bits +   size;
	fds.ex      = bits + 2*size;
	fds.res_in  = bits + 3*size;
	fds.res_out = bits + 4*size;
	fds.res_ex  = bits + 5*size;
    
    // 将用户空间提交的三个fd_set拷贝到内核空间
	if ((ret = get_fd_set(n, inp, fds.in)) ||
	    (ret = get_fd_set(n, outp, fds.out)) ||
	    (ret = get_fd_set(n, exp, fds.ex)))
		goto out;
	zero_fd_set(n, fds.res_in);
	zero_fd_set(n, fds.res_out);
	zero_fd_set(n, fds.res_ex);
    
    // 调用select模型的核心函数do_select()
	ret = do_select(n, &fds, end_time);

	if (ret < 0)
		goto out;
	// 检测到有信号则系统调用退出,返回用户空间执行信号处理函数
	if (!ret) {
		ret = -ERESTARTNOHAND;
		if (signal_pending(current))
			goto out;
		ret = 0;
	}

	if (set_fd_set(n, inp, fds.res_in) ||
	    set_fd_set(n, outp, fds.res_out) ||
	    set_fd_set(n, exp, fds.res_ex))
		ret = -EFAULT;
// goto跳转的对应点
out:
	if (bits != stack_fds)
		kfree(bits);
out_nofds:
	return ret;
}
复制代码

源码看过去,看起来有些多,对于 C 语言不太熟悉的小伙伴可能看得会一脸懵,但没关系,我们不去讲细了,重点理解其主干内容,上述源码分为如下几步:

  • ①先判断调用 select() 时,是否设置了超时时间:core_sys_select()
  • ②计算出最大的文件描述符,然后采用开栈方式存储递交的参数值。
  • ③根据计算出的 max_fds 值,判断开栈空间能否可以存储递交的参数值:不能:调用内核的 kmalloc 分配器为 fd_set_bits 分配更大的空间(新分配的内存是在堆)。能:更改 fd_set_bits 中的指针指向,然后将递交的三个 fd_set 拷贝到内核空间。
  • ④上述工作全部已就绪后,调用 select() 函数中的核心函数: do_select() 处理。

在上述过程中,理解起来并不复杂,唯一的疑惑点就在于多出了一个新的结构体: fd_set_bits ,那它究竟是什么意思呢?先来看看它的定义:

typedef struct {
    unsigned long *in, *out, *ex;
    unsigned long *res_in, *res_out, *res_ex;
} fd_set_bits;
复制代码

很明显, fd_set_bits 是由六个元素组成的,这六个元素分别对应着六个位图,其中前三个则对应调用 select() 函数时递交的三个参数: readfds、writefds、exceptfds ,而后三个则对应着 select() 执行完成之后返回地位图,为什么还需要有后面三个呢?

因为 select() 在遍历需要监听的文件描述符列表时,也需要三个对应地位图来记录哪些 FD 中是有数据的,因此也需要有三个位图对应着传入的三个位图,在 select() 执行完成后,如若有 Socket 中存在数据需要处理,那则会将这三个位图中对应的 Socket 位置进行置位,然后从内核空间再将其拷贝回用户空间,以供程序处理。

OK~,了解 fd_set_bits 结构后,对于 core_sys_select 函数中做的工作就自然理解了,一句话总结一下这个函数做的工作:

core_sys_select 只不过是在为后面要调用的 do_select() 函数做准备工作而已。

当然,在上述的 core_sys_select 函数中还涉及到两个函数: get_fd_set()、set_fd_set() ,其实现如下:

// 调用了copy_from_user()函数,也就是从用户空间拷贝数据到内核空间
static inline
int get_fd_set(unsigned long nr, void __user *ufdset, unsigned long *fdset)
{
	nr = FDS_BYTES(nr);
	if (ufdset)
		return copy_from_user(fdset, ufdset, nr) ? -EFAULT : 0;

	memset(fdset, 0, nr);
	return 0;
}

// 调用了__copy_to_user()函数,也就是将数据从内核空间拷贝回用户空间
static inline unsigned long __must_check
set_fd_set(unsigned long nr, void __user *ufdset, unsigned long *fdset)
{
	if (ufdset)
		return __copy_to_user(ufdset, fdset, FDS_BYTES(nr));
	return 0;
}
复制代码

从最终调用的 copy_from_user()、copy_to_user() 两个函数中就能得知,这就是用于用户空间与内核空间之间数据拷贝的函数而已。

免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://itzsg.com/35805.html

(0)

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

联系我们YX

mu99908888

在线咨询: 微信交谈

邮件:itzsgw@126.com

工作时间:时刻准备着!

关注微信