2023-09-13
原文作者:https://blog.csdn.net/wangwei19871103/category_9681495_2.html 原文地址: https://blog.csdn.net/wangwei19871103/article/details/104080859

Linux里的select函数

在讲NIO里的Selector之前,我觉得有必要先有预先的知识铺垫,否则很难理解NIOSelector在做什么。
所以打算先讲下Linuxselect,我们可以在Linux中用命令man select查看这个函数,我截取了一些:

    /* According to POSIX.1-2001 */
           #include <sys/select.h>
    
           /* According to earlier standards */
           #include <sys/time.h>
           #include <sys/types.h>
           #include <unistd.h>
    
           int select(int nfds, fd_set *readfds, fd_set *writefds,
                      fd_set *exceptfds, struct timeval *timeout);
    
           void FD_CLR(int fd, fd_set *set);
           int  FD_ISSET(int fd, fd_set *set);
           void FD_SET(int fd, fd_set *set);
           void FD_ZERO(fd_set *set);

我们先来分析这个函数,这个是C写的,需要一点点指针的概念,没学过也没关系,理解成JAVA中的对象引用地址就行了。我们先来看看函数的一些描述:

202309132206239971.png
说的就是允许一个程序去监视多个文件描述符,直到一个或者多个文件描述符为IO操作准备好了,也就是说有读写事件,准备好了也就意味着可以进行IO操作,不阻塞了。这个就说明select是阻塞的。他会去监听三个文件描述符集合里是否有改变的,如果有,就说明有事件,三个集合分别是读readfds,写writefds,异常exceptfds。而且还提供了4个宏定义来对集合操作。
我们先来看看他的几个参数吧:

202309132206252132.png
nfds就是这三个集合里面文件描述符最大的那个+1。文件描述符是什么,可以看这篇文章
比如说readfds里面的描述符是3,4,5writefds里是3,5,6exceptfds2,7,则最大的就是7nfds=8.

202309132206258143.png
timeout就是一个超时设置的结构体,这个就不多说了。下面说下4个宏定义。

fd就是文件描述符,是个int 整数。

fd_set是个位图类型,每个描述符都可以对应一位,设置为0或者1,类似哈希定址的方法,总能找到对应数组的索引,就能设置相应元素的值了。如果不理解,就当是个不重复的集合。位图是什么可以看这篇文章

void FD_CLR(int fd, fd_set *set)这个函数就是将fd从集合中清除,也就是位置值为0

int FD_ISSET(int fd, fd_set *set)这个函数就是判断fd是不是在集合里,也就是判断位置的值为0还是1

void FD_SET(int fd, fd_set *set)这个函数就是将fd设置到集合里,也就是位置值为1

void FD_ZERO( fd_set *set)这个函数就是将整个集合都清了,全是0
返回值是三个集合里面文件描述符事件的数量总和,这个后面我会举例子说明。
这里要注意,这三个集合是是传入传出集合,简单就是说,你设置了集合表示你要监听的文件描述符的值,等调用select传入集合后,集合里的值会随着监听事件而改变,有事件的文件描述符对应值为1,没有事件的为0

例子讲解

我们直接看linux给出的例子,我把一些无关的东西删除了,看主要这些参数怎么用的:

     #include <stdio.h>
           #include <stdlib.h>
           #include <sys/time.h>
           #include <sys/types.h>
           #include <unistd.h>
    
           int
           main(void)
           {
               fd_set rfds;//定义一个读集合
               struct timeval tv;//定义超时结构体
               int retval;//返回值
    
               /* 监听标准输入*/
               FD_ZERO(&rfds);//将集合清0
               FD_SET(0, &rfds);//将描述符0添加能进去
    
               /* 设置超时 */
               tv.tv_sec = 5;
               tv.tv_usec = 0;
    		   /* 开始监听,写事件和异常不监听 */
               retval = select(1, &rfds, NULL, NULL, &tv);
               /* Don't rely on the value of tv now! */
    
               if (retval == -1)//异常
                   perror("select()");
               else if (retval)//有事件
                   printf("Data is available now.\n");
                   /*FD_ISSET(0, &rfds)肯定是返回1*/ 
               else //0的话就没有事件发生
                   printf("No data within five seconds.\n");
    
               exit(EXIT_SUCCESS);
           }

上面的例子基本已经注释了,就是说我要监听0号文件描述符的读事件,如果有读事件返回那肯定是1,因为我只监听了读集合,而且里面就只有0号文件描述符。
其实更加通用的应该是三个集合,比如我举个例子,比如我现在有三个socket,其实socket就是个数据缓冲区,会生成三个文件描述符,fd1,fd2,fd3,添加之后对应的值是3,4,5因为0,1,2被系统的标准输入输出异常给用了,每个进程都有对应的文件描述符表,就当是一种个文件之间的联系,你可以通过这个关系来操作socket。默认集合里都是0的:

202309132206266294.png
如果我现在的想监听fd1,fd2的读,fd2的写,fd3的异常的话应该是这样子了:

202309132206277845.png
其实就是文件描述符的值对应监听集合的长度取模后的对应的位置上设置为1
然后我应该调用retval = select(6, &readfds, &writefds, &exceptfds, &tv);。第一个参数是三个集合里最大文件描述符的值+1,最大文件描述符所以是6这里要注意的是,如果有事件,那会把对应集合的对应文件描述符的位置值设置为1,没有事件的设置为0

如果retval 不为0-1。说明有事件,但是具体是什么不知道,所以只能一个个集合遍历。比如说返回为2,但是有三个集合,你不知道哪一个集合里面的哪些文件描述符的值是设置成1了,反正只有两个,所以你得遍历三个啊,这个比较麻烦了。

但是也可以用自定义的数组先把你要监听的文件描述符锁对应集合里的位置保存了,有了返回值之后直接就取那些位置判断是否是该文件描述符的事件。比如我保存了fd1,fd2的读集合的位置,fd2的写集合的位置,fd3的异常集合的位置。然后返回是2,那我直接就拿这5个位置,一一去三个集合里比对,看哪两个的值是1就可以了,比如最后发现是fd1的读事件和fd2的写事件,那我后面就可以直接用系统提供的read/write去操作了。这个确实不方便,但是后面的pollepoll就进步一的对这个做了升级。另外打开的文件描述符表在这里也是有限制的1024个,要改只能编译内核了。

Linux里的poll函数

pollselect基础上升级了一版,把前面我们所做的自定义监听事件数组的事给做了,同时他可以修改文件描述符的数量上限了,我们来看看他的函数:

    int poll(struct pollfd *fds, nfds_t nfds, int timeout);
    struct pollfd {
                   int   fd;         /* 文件描述符 */
                   short events;     /* 监听事件*/
                   short revents;    /* 监听返回事件 */
               }

看到他的参数少了不少,其实就是把前面三个集合换成一个数组,fds就是首地址,里面每个元素都可以自己添加,要监听什么就设置什么,这样就不像以前那样需要整一个集合传进去,现在只要把你要监听的元素放进去即可。监听事件events常用的是:POLLIN,POLLOUT,POLLERR。对应的监听返回事件revents也是这三个。
如果还是上面的例子,应该这样写:

    struct pollfd pfds[4];
    pfds[0].fd=fd1;
    pfds[0].events=POLLIN;//读事件
    
    pfds[1].fd=fd2;
    pfds[1].events=POLLIN;//读事件
    
    pfds[2].fd=fd2;
    pfds[2].events=POLLOUT;//写事件
    
    pfds[3].fd=fd3;
    pfds[3].events=POLLERR;//异常事件
    
    
    retval = poll(pfds, 6,-1);
    //遍历pfds,取出元素的revents,判断并处理。

相比较select,确实方便了不少,但是还是需要去遍历判断,能不能直接就告诉我哪些有事件,直接处理好了,那就要看epoll了。

Linux里的epoll函数

这个主要是分成三个函数了,epoll_create,epoll_ctl,epoll_wait这三个。下面就具体讲解一下。

epoll_create

    int epoll_create(int size);//建议监听多少个文件描述符,创建红黑树用

返回的是一个文件描述符,指向的是一颗红黑树的树根。红黑树是什么,可以百度下,搜索性能比较高,二分查找的思想。

epoll_ctl

    /** epfd就是上面的epoll_create创建的文件描述符
    	op是对这个epfd的操作,比如有EPOLL_CTL_ADD增加,EPOLL_CTL_MOD修改,EPOLL_CTL_DEL删除,其实就是对应	树结点的增加,修改,删除。
    	fd就是被操作的对象
    	event就是事件信息
    */
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    //返回0表示成功
    
    
    typedef union epoll_data {
        	void        *ptr;//任意类型的指针,可以是回调函数
            int          fd;//文件描述符
            uint32_t     u32;//32位无符号整数
            uint64_t     u64;//64位无符号整数
        } epoll_data_t;
    
        struct epoll_event {
            uint32_t     events;      //监听事件常用的是EPOLLIN,EPOLLOUT,EPOLLERR
            epoll_data_t data;        //监听到的数据
        };

其实这个函数就是对某个红黑树进行操作,增加,修改,删除某个结点的事件。

epoll_wait

    /**
    epfd就是上面的epoll_create创建的文件描述符
    events是事件数组的首地址,
    maxevents是事件数组的大小
    timeout超时
    */
    int epoll_wait(int epfd, struct epoll_event *events,
                          int maxevents, int timeout);
    //返回监听到事件的个数

要注意events是个传出参数,就是说运行完epoll_wait后,结果就会在events里,这就太方便了,不用像前面那样还去遍历,直接拿出来就好了。还是拿前面的例子,如果用这个来做就应该这样:

    int epfd=epoll_create(4)
    struct epoll_event event_fd1;
    //fd1读
    event_fd1.events=EPOLLIN;
    event_fd1.data.fd=fd1;
    epoll_ctl(epfd,EPOLL_CTL_ADD,fd1,&event_fd1)
    
    //fd2读
    struct epoll_event event_fd2_r;
    event_fd2_r.events=EPOLLIN;
    event_fd2_r.data.fd=fd2;
    epoll_ctl(epfd,EPOLL_CTL_ADD,fd2,&event_fd2_r)
    
    //fd2写
    struct epoll_event event_fd2_w;
    event_fd2_w.events=EPOLLOUT;
    event_fd2_w.data.fd=fd2;
    epoll_ctl(epfd,EPOLL_CTL_ADD,fd2,&event_fd2_w)
    
    //fd3异常
    struct epoll_event event_fd3;
    event_fd3.events=EPOLLERR;
    event_fd3.data.fd=fd3;
    epoll_ctl(epfd,EPOLL_CTL_ADD,fd3,&event_fd3)
    
    struct epoll_event events[4];
    int ret=epoll_wait(epfd,&events,4,-1)
    for(i=0;i<ret;i++){
    	//做处理
    }

我画了个简单的例子图,大致就这个思路,具体的有兴趣可以去研究下内部实现原理:

202309132206287056.png

总结

我们可以看到,从selectepoll,是一代代的进步,越来越方便了,而且性能相对也越来越高,不过如果说连接很多,监听也很多的情况下,其实性能都差不多,因为基本上都是要全部遍历,不过如果连接多,监听少的情况,epoll的性能就体现出来了。其实epoll还有边沿触发和水平出发的模式,边沿触发可以减少epoll_wait次数,再加上非阻塞IOfcntl性能可以不错的。这里我就不多展开了,还是把这些基础的先弄明白。

好了,今天就到这里了,希望对学习理解有帮助,大神看见勿喷,仅为自己的学习理解,能力有限,请多包涵。

阅读全文