美文网首页IT领域
(八)Java网络编程之IO模型篇-内核Select、Poll、

(八)Java网络编程之IO模型篇-内核Select、Poll、

作者: 竹子爱熊猫 | 来源:发表于2022-08-26 17:00 被阅读0次

    引言

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

    Java-NIO体系
    观察上述结构,其实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();
    }
    

    从源码中可明显得知,选择器最终是由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()方法去监听事件是否被触发,如下:

    // 轮询监听选择器上注册的通道是否有事件被触发
    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()触发真正的逻辑操作,接下来再看看这个方法:

    // 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源码会以操作系统的类型分包,不同系统的对应不同的实现,如下:

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

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

    目录结构

    观察上图会发现,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方法源码后,其中存在这么一行:

    win-select

    观察之后不难发现,其实最终还会调用到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个网络连接,如下:

    socket
    那么又该如何处理这些请求呢?最简单的方式:
    网络服务器
    对于每个到来的网络连接都为其创建一条线程,每个连接由单独的线程负责处理,所以最初的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则代表有事件触发,如下图:

    fd_set

    在这个数组中,所有的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调用

    没有下载到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()两个函数中就能得知,这就是用于用户空间与内核空间之间数据拷贝的函数而已。

    那么再来看看select()的核心函数do_select()吧,先上源码:

    int do_select(int n, fd_set_bits *fds, struct timespec *end_time)
    {
        ktime_t expire, *to = NULL;
        // -------- 核心结构:poll_wqueues -------------
        struct poll_wqueues table;
        poll_table *wait;
        int retval, i, timed_out = 0;
        unsigned long slack = 0;
        
        // 先获取一下最大的文件描述符
        rcu_read_lock();
        retval = max_select_fd(n, fds);
        rcu_read_unlock();
        // 如果获取到的值为负数,则返回select()执行过程中错误
        if (retval < 0)
            return retval;
        n = retval;
        
        // 初始化poll_wqueues结构体中的poll_table,并更改__pollwait的指针指向
        poll_initwait(&table);
        wait = &table.pt;
        
        // 如果系统调用select()函数时,设置的超时时间为0,
        // 那么赋值timed_out = 1,表示未获取到事件的情况下不阻塞,直接返回。
        if (end_time && !end_time->tv_sec && !end_time->tv_nsec) {
            wait = NULL;
            timed_out = 1;
        }
        
        // 如果设置了超时时间,则预估一下还剩下多少时间
        if (end_time && !timed_out)
            slack = estimate_accuracy(end_time);
    
        retval = 0; // 这个是最终返回的值
        // 开启轮询,这里是核心!!!
        for (;;) {
            unsigned long *rinp, *routp, *rexp, *inp, *outp, *exp;
            
            // 对于每个需要监听的fd,向其等待队列中注册后一个entry
            set_current_state(TASK_INTERRUPTIBLE);
            
            // 准备工作
            inp = fds->in; outp = fds->out; exp = fds->ex;
            rinp = fds->res_in; routp = fds->res_out; rexp = fds->res_ex;
            for (i = 0; i < n; ++rinp, ++routp, ++rexp) {
                unsigned long in, out, ex, all_bits, bit = 1, mask, j;
                unsigned long res_in = 0, res_out = 0, res_ex = 0;
                const struct file_operations *f_op = NULL;
                struct file *file = NULL;
                
                // 做一次位或操作,对于并集为0的FD直接忽略
                // (在前面分析过,只有置位=1的,才代表这个FD需要被监听事件)
                in = *inp++; out = *outp++; ex = *exp++;
                all_bits = in | out | ex;
                if (all_bits == 0) {
                    i += __NFDBITS;
                    continue;
                }
                
                // 内层循环:开始对需要监听的FD进行扫描(核心中的核心!!)
                for (j = 0; j < __NFDBITS; ++j, ++i, bit <<= 1) {
                    int fput_needed;
                    if (i >= n)
                        break;
                    if (!(bit & all_bits))
                        continue;
                    file = fget_light(i, &fput_needed);
                    // 这里是重点:主要做了f_op->poll这个操作(具体含义后面细聊)
                    if (file) {
                        f_op = file->f_op;
                        mask = DEFAULT_POLLMASK;
                        // 检测对应的FD是否能够进行IO操作
                        if (f_op && f_op->poll)
                            // 会调用具体设备的poll()方法
                            mask = (*f_op->poll)(file, retval ? NULL : wait);
                        fput_light(file, fput_needed);
                        
                        // 判断对应的文件描述符目前的状态
                        // 如果是可读状态,则将其res_in集合对应的坑位置1
                        if ((mask & POLLIN_SET) && (in & bit)) {
                            res_in |= bit;
                            retval++;
                        }
                        // 如果是可写状态,则将其res_out集合.......
                        if ((mask & POLLOUT_SET) && (out & bit)) {
                            res_out |= bit;
                            retval++;
                        }
                        if ((mask & POLLEX_SET) && (ex & bit)) {
                            res_ex |= bit;
                            retval++;
                        }
                    }
                }
                // 对于监听到有数据的FD,赋值给之前要返回的位图中
                if (res_in)
                    *rinp = res_in;
                if (res_out)
                    *routp = res_out;
                if (res_ex)
                    *rexp = res_ex;
                cond_resched();
            }
            // 如果扫描到了活跃FD、或出现超时、出现唤醒信号以及指向碰到错误
            // 中断循环扫描,返回到之前的core_sys_select()函数中
            // 如若是被唤醒或超时了,则会重新扫描一次所有FD
            wait = NULL;
            if (retval || timed_out || signal_pending(current))
                break;
            if (table.error) {
                retval = table.error;
                break;
            }
    
            // 第一次循环时,如果设置了超时时间,那么则将时间赋值给to指针
            if (end_time && !to) {
                expire = timespec_to_ktime(*end_time);
                to = &expire;
            }
            /* 未扫描到活跃的FD,则调用schedule_hrtimeout_range函数,
            函数作用:让当前程序进入睡眠,让出CPU资源,避免无效扫描浪费CPU,
            调用时传入了to,这是调用时指定的阻塞时间,超时则返回0,
            如果在睡眠过程中,被socket唤醒则返回-EINTR  */
            if (!schedule_hrtimeout_range(to, slack, HRTIMER_MODE_ABS))
                timed_out = 1; // 睡眠超时后置1,方便后面退出循环返回到上层
        }
        __set_current_state(TASK_RUNNING);
        
        // 清理各个驱动程序的等待队列头,
        // 同时释放所有空出来的poll_table_page页(包含的poll_table_entry)
        poll_freewait(&table);
        
        // 返回扫描到的活跃FD数量
        return retval;
    }
    

    对于源码的执行过程,在上面都已给出了相关注释,但看起来有些费力,我们稍后再去总结一遍,但在此之前我们需要先理解两个内容:活跃FD数、poll_wqueues结构体。

    活跃FD数:表示有事件发生的文件描述符,比如一个网络套接字中有数据可读,那么这个Socket对应的FD则可记为一次活跃数。如果一个FD同时触发了两个事件,那么则会计算两次活跃数。

    poll_wqueues结构体则属于do_select()函数中的一个核心结构,定义如下:

    // 位于include/linux/poll.h文件中
    struct poll_wqueues {
        // 驱动注册,回调函数__pollwait的指针
        poll_table pt;
        // 如果下面的inline_entries不够 就会需要
        struct poll_table_page * table;
        int error;
         // 记录下面的table使用过的下标
        int inline_index;
        // 对应下述的poll_table_entry结构
        struct poll_table_entry inline_entries[N_INLINE_POLL_ENTRIES];
    };
    
    // 加入等待队列的节点
    struct poll_table_entry {
        struct file * filp;
        wait_queue_t wait;
        wait_queue_head_t * wait_address;
    };
    
    // 回调函数的指针
    typedef struct poll_table_struct {
        poll_queue_proc qproc;
    } poll_table;
    

    对于这个结构体而言,核心就在于其中的pt成员,它是poll_table类型的,不过想要理解它,那首先必须明白一个知识点:

    当某个进程需要对一个IO设备(例如socket)进行读写时,如果发现此设备的数据暂且还未就绪,所以不能进行读写操作,当前进程就需要阻塞等待。为了实现阻塞进程,那每个socket/IO设备都有个等待队列,当进程需要阻塞等待数据时,就可以将该进程添加到对应的等待队列中进行休眠,当socket数据就绪后,再唤醒队列中的进程。

    poll_table结构就是为了将进程添加到等待队列中而创造的,在上述源码中调用poll_initwait()函数后,就会将poll_wqueues中的poll_table成员的poll_queue.proc设置为__pollwait()回调函数,当后续执行到f_op->poll()时会调用poll_wait()函数,最终就会执行到这里设置的__pollwait()回调,这两个函数实现如下:

    // 将当前进程添加到wait参数指定的等待列表(poll_table)中
    poll_wait(struct file *filp, wait_queue_head_t *queue, poll_table *wait)
    {
        if (p && wait_address)
            p->qproc(filp, wait_address, p);
    }
    
    // 设置唤醒回调函数为pollwake函数,并将poll_table_entry.wait加入等待队列
    static void __pollwait(struct file *filp, wait_queue_head_t *wait_address,
                    poll_table *p)
    {
        struct poll_wqueues *pwq = container_of(p, struct poll_wqueues, pt);
        struct poll_table_entry *entry = poll_get_entry(p);
        if (!entry)
            return;
        get_file(filp);
        entry->filp = filp;
        // 设置等待队列头
        entry->wait_address = wait_address;
        // 设置关注的事件
        entry->key = p->key;
        // 设置等待队列节点的回调函数为pollwake()
        init_waitqueue_func_entry(&entry->wait, pollwake);
        // 私有数据 poll_wqueues
        entry->wait.private = pwq;
        // 将 poll_table_entry 添加到对应的等待队列上
        add_wait_queue(wait_address, &entry->wait);
    }
    

    OK~,到这里看的可能会有些懵,因为这是跟后续的唤醒动作有关的,待会儿结合具体的设备驱动一起来理解,现在咱们重点先分析一下do_select()函数的核心过程:

    • ①准备阶段:获取最大文件描述符值、设置阻塞回调、处理超时时间等。
    • ②开启轮询,将不需要监听的FD忽略,需要监听的FD都向其等待队列注册一个entry
    • ③开启循环将所有需要监听的FD全部扫描一遍,判断FD对应的设备是否有数据可读写:
      • 有:直接跳到步骤⑤。
      • 没有:内核调用schedule让当前进程睡眠xx秒,让出cpu进入阻塞。
    • ④如果有FD主动唤醒了当前进程,或xx秒后自己醒了,再次跳回步骤③。
    • ⑤如果从文件描述符集合中扫描到了有数据可读写的FD,记录相应的活跃个数。
    • ⑥将就绪事件结果保存在fdsres_in、res_out、res_ex集合中,然后调用poll_freewait()函数移除各个驱动程序的等待队列头,最后返回对应的活跃FD数。

    do_select()函数的核心流程总结给出来了,其实粗略理解起来也不难,唯一有些绕的估计就是进程阻塞/唤醒这块的内容,下面重点来说一下这块。

    do_select()中,扫描FD时有一个核心操作:
    mask = (*f_op->poll)(file, retval ? NULL : wait);
    在这步操作中,会调用文件描述符对应设备的poll检测当前是否能够进行IO操作,那么对于网络Socket套接字而言,调用poll之后,对应的接口就是sock_poll(),其定义位于net/ipv4/,如下:

    static unsigned int sock_poll(struct file *file, poll_table * wait)
    {
        struct socket *sock;
     
        sock = socki_lookup(file->f_dentry->d_inode);
        return sock->ops->poll(file, sock, wait);
    }
    

    实现很简单,首先会通过socki_lookup()函数将文件描述符转换为具体的Socket套接字,然后会调用该socket.poll()函数,例如这里的套接字是TCP类型的,那么对应的实现就是tcp_poll()函数:

    // 位于net/ipv4/tcp/目录下
    unsigned int tcp_poll(struct file *file, struct socket *sock, poll_table *wait)
    {
        unsigned int mask;
        struct sock *sk = sock->sk;
        struct tcp_sock *tp = tcp_sk(sk);
    
        poll_wait(file, sk->sk_sleep, wait);
        if (sk->sk_state == TCP_LISTEN)
            return inet_csk_listen_poll(sk);
        // 用mask来记录socket数据是否可被读写
        mask = 0;
        // 开始进行判断
        if (sk->sk_err)
            mask = POLLERR;
        if (sk->sk_shutdown == SHUTDOWN_MASK || sk->sk_state == TCP_CLOSE)
            mask |= POLLHUP;
        if (sk->sk_shutdown & RCV_SHUTDOWN)
            mask |= POLLIN | POLLRDNORM | POLLRDHUP;
        if ((1 << sk->sk_state) & ~(TCPF_SYN_SENT | TCPF_SYN_RECV)) {
            int target = sock_rcvlowat(sk, 0, INT_MAX);
    
            if (tp->urg_seq == tp->copied_seq &&
                !sock_flag(sk, SOCK_URGINLINE) &&
                tp->urg_data)
                target--;
            if (tp->rcv_nxt - tp->copied_seq >= target)
                mask |= POLLIN | POLLRDNORM;
    
            if (!(sk->sk_shutdown & SEND_SHUTDOWN)) {
                if (sk_stream_wspace(sk) >= sk_stream_min_wspace(sk)) {
                    mask |= POLLOUT | POLLWRNORM;
                } else {  /* send SIGIO later */
                    set_bit(SOCK_ASYNC_NOSPACE,
                        &sk->sk_socket->flags);
                    set_bit(SOCK_NOSPACE, &sk->sk_socket->flags);
    
                    /* Race breaker. If space is freed after
                     * wspace test but before the flags are set,
                     * IO signal will be lost.
                     */
                    if (sk_stream_wspace(sk) >= sk_stream_min_wspace(sk))
                        mask |= POLLOUT | POLLWRNORM;
                }
            }
            if (tp->urg_data & TCP_URG_VALID)
                mask |= POLLPRI;
        }
        // 最终返回当前socket是否可被读写
        return mask;
    }
    

    在这个函数中,首先会调用poll_wait()函数将当前进程添加到wait等待列表中,然后检测socket目前数据是否可以被读写,最终通过mask变量来记录当前套接字的数据是否可被读写,如果可读写会将对应的FD记录为活跃状态。如若不可读写则会先返回,然后等当前进程遍历完所有FD后,所有的FD都不能进行I/O操作的情况下,当前进程则会进入休眠阻塞状态。

    如果进程陷入休眠阻塞状态后,它被再次唤醒只有两种情况:
    ①为进程设置的休眠时间到了自己醒来。
    ②由对应的驱动设备主动唤醒。

    第一种情况都懂就不聊了,重点来说说第二种,这种唤醒则是由I/O设备决定的,之前分析__pollwait函数时,在最后调用了add_wait_queue(wait_address, &entry->wait)函数,在对应的等待队列上插入了一个entry,那当I/O设备的数据就绪后,就会去遍历等待队列找到这个entry,然后会调用设置好的pollwake()回调函数唤醒对应的进程。此时由于数据已经准备好了,所以当select被唤醒后,自然就能扫描到对应的FD变为了可读写状态,然后返回给用户态的程序。

    当然,对于唤醒这块的具体实现位于/sys/wait.h、wait.c文件中,感兴趣的可自行研究。

    至此,select()函数被调用后,在内核具体是如何工作的,整个源码流程也就大致分析清楚了,现在咱们会简单总结一下,梳理清楚完整流程。

    4.6、select底层原理小结

       在经过上述一系列分析后,我们大致摸透了select()运行的底层原理,但估摸着大家看下来都有一点云里雾里的感觉,因此再简单的写一个完整流程的总结:

    • ①外部调用select()函数,传入最大文件描述符值、三个FD集合以及超时时间。
    • ②用六个位图组成的fd_set_bits结构存储传入的FD集合,用kmalloc为其分配栈空间。
    • ③将用户态传递的fd_set拷贝到内核空间,紧接着调用do_select()函数。
    • ④获取传入的最大文件描述符值、设置阻塞回调函数、处理超时时间等。
    • ⑤开启轮询,将不需要监听的FD忽略,需要监听的FD都向其等待队列注册一个entry
    • ⑥开启循环将所有需要监听的FD全部扫描一遍,判断FD对应的设备是否有数据可读写:
      • 有:直接跳到步骤⑧。
      • 没有:内核调用schedule让当前进程睡眠xx秒,让出cpu进入阻塞。
    • ⑦如果有FD主动唤醒了当前进程,或xx秒后自己醒了,再次跳回步骤⑥。
    • ⑧如果从文件描述符集合中扫描到了有数据可读写的FD,记录相应的活跃个数。
    • ⑨将就绪事件结果保存在fdsres_in、res_out、res_ex集合中,然后调用poll_freewait()函数移除各个驱动程序的等待队列头,最后返回对应的活跃FD数。
    • ⑩将扫描到的FD从内核拷贝会用户态空间,同时向程序返回已触发的事件数。

    其实整个流程下来,select分析的内容颇多,这是因为它也是后续两个函数的基础,把它的过程弄明白了,在分析后面的函数时,过程也是换汤不换药的,步骤都大致相同。

    4.7、select的缺点分析与思考

       详细了解了select()函数后,再来想想它有哪些不足的地方呢?

    ①由32long元素组成的fd_set,最大只能表示1024位,因此最多只能监听1024socket,所以对于高并发的I/O场景很难提供支持。

    ②因为监听FD的工作是内核完成的,所以每次调用select()时,都需将FD集合从用户态拷贝到内核态空间,这个过程开销会较大。

    ③当监听的FD集合中,某个Socket上有数据可读写后,会唤醒陷入睡眠的select,但select醒来后也不知道那个FD有数据,因此会重新将整个集合遍历一次,造成了很大程度上的浪费。

    ④每次调用select函数时,由于需要监听的文件描述符不同,所以需要构建新的fd_set集合,也就是上一次使用过的fd_set不可被重用,造成较大的资源开销。

    上述四点,则是select多路复用模型的四个致命缺陷,由于这些原因导致它并不适合于一些高性能的场景,因此才有后续的poll、epoll等模型出现。

    但在分析其他两个函数之前,再思考一个问题,假设此时CPU正在处理一个IO数据,但此刻另外一个Socket上也来了数据,那么这个数据会被丢弃吗?

    答案是不会的,因为有专门用于处理I/O数据的硬件:DMA控制器以及网卡,在网络连接到来时,如果CPU正在处理另外一条网络连接的数据,新连接的网络数据并不会被丢弃,而是会由网卡将数据接收并放入内核缓冲区。同理,如果是本地IO,则会由DMA控制器处理。

    相关文章

      网友评论

        本文标题:(八)Java网络编程之IO模型篇-内核Select、Poll、

        本文链接:https://www.haomeiwen.com/subject/yxtxnrtx.html