目录

select、poll、epoll浅析


整篇博客的完整示例代码在:github

select

介绍与使用

一、介绍: select系统调用的目的是:在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常事件。poll和select应该被归类为这样的系统 调用,它们可以阻塞地同时探测一组支持非阻塞的IO设备,直至某一个设备触发了事件或者超过了指定的等待时间——也就是说它们的职责不是做IO,而是帮助 调用者寻找当前就绪的设备。

原理图:

https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5bfa533860f4465d816c85a6e77b8b88~tplv-k3u1fbpfcp-watermark.image?

二、使用

需要的系统调用API如下:

#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);

一般经过以下三个过程:

  1. 定义bitmap结构,就是定义fd_set类型变量,内部实现是一个int数组,整个数组的bit位长 FD_SETSIZE,这个宏默认为1024。
  2. 根据文件描述符大小设置bitmap,该步骤调用FD_SET宏进行设置。
  3. 调用select函数,并传入bitmap,select内部会根据bitmap上的标记进行轮询,一旦有文件描述符触发事件就将其重新标记到用户态的bitmap里,select除了第一个参数外(第一个参数传入fd的最大值+1),后面连续三个传入的指针参数分别代表:监听读取事件的bitmap、监听写入事件的bitmap、监听异常事件的bitmap,最后一个参数表示延迟时间。
  4. 根据bitmap得到已经准备好的文件描述符,并对其执行相应的操作。(Reactor模型

一个文件描述符是否准备就绪,有以下判断:

在网络编程中,

  1. 下列情况下socket可读: a) socket内核接收缓冲区的字节数大于或等于其低水位标记SO_RCVLOWAT; b) socket通信的对方关闭连接,此时该socket可读,但是一旦读该socket,会立即返回0(可以用这个方法判断client端是否断开连接); c) 监听socket上有新的连接请求; d) socket上有未处理的错误。
  2. 下列情况下socket可写: a) socket内核发送缓冲区的可用字节数大于或等于其低水位标记SO_SNDLOWAT; b) socket的读端关闭,此时该socket可写,一旦对该socket进行操作,该进程会收到SIGPIPE信号; c) socket使用connect连接成功之后; d) socket上有未处理的错误。

关键代码如下:

 //1.定义bitmap结构。  fd_set,是一个bitmap,大小为1024位,是一个长度为1024/32的int数组
    fd_set rset;
    char buffer[MAXBUF];
    while (1) {
        FD_ZERO(&rset); //重置为0
        for (int i = 0; i < 5; ++i) {
            FD_SET(fds[i],&rset);   //2.根据文件描述符标记bitmap
        }
        puts("round again\n");
        select(max+1,&rset,NULL,NULL,NULL); //3.调用select进行轮询

        for(auto &fd:fds){
            if(FD_ISSET(fd,&rset)){ //4.获取已经准备好的描述符进行相应操作
                memset(buffer, 0, MAXBUF);
                read(fd, buffer, MAXBUF);
                puts(buffer);
            }
        }
    }

优缺点

从以下三个角度来评析:

  1. 支持的最大连接数:与 FD_SETSIZE 宏的大小有关,一般为1024。
  2. 内核态到用户态的拷贝消耗:非常高,每次select调用都会重新copy一次。
  3. 内核态扫描的数据结构:线性扫描,FD剧增后会造成很大的效率问题

我们发现上面所说的都是它的各方面特性,同时也体现出了它的缺点。

缺点:支持的最大文件描述符不够大、每次select调用需要进行大量拷贝且bitmap每次都需要重新set值、内部逻辑是线性扫描不适合大量描述符的情况。

同样在某些条件下,这种简单模型反而会成为优点。

优点:如果是连接数特别少的情况下,线性扫描反而可能是最优的选择。

poll

介绍与使用

一、介绍

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。

二、使用

经过以下过程:

  1. 创建pollfd结构,并设置好fd和events。
  2. 调用poll函数进行轮询,第一个参数传入需要轮询的pollfd数组,第二个参数传入数组长度,第三个是超时时间。
  3. 遍历pollfd数组检测就绪fd并对其进行相应的处理。

代码如下:

    //1.创建pollfd结构,并设置好要管理的文件描述符以及对应的事件
    pollfd pollfds[5];
    for(auto& pollfd: pollfds){
        pollfd.fd = accept(server_fd,(sockaddr*)&client,&socklen);
        if(pollfd.fd<0)
            ERR_EXIT("fd accept error");
        pollfd.events = POLLIN; //检测读取事件
    }
    
    while (1){
        puts("round again");
        //2.开始进行poll轮询
        if(poll(pollfds,5,5000)<0)  //通过把需要监听的fd拷贝到内核态,如果有事件可读,则设置revents
            ERR_EXIT("poll error");
        //3.遍历pollfd数组检测已经就绪的事件,并执行对应的操作
        for(auto & pollfd:pollfds){
            if(pollfd.revents&POLLIN){
                pollfd.revents = 0; //重新设置
                char buffer[1024]{};
                int len;
                if((len= read(pollfd.fd,buffer,1024))<0)
                    ERR_EXIT("pollfd read error");
                if(write(pollfd.fd,buffer,len)<0){
                    ERR_EXIT("pollfd write error");
                }
            }
        }
    }

优缺点

三个角度剖析

  1. 支持的最大连接数:非常多(具体根据系统调度和创建的结构来。
  2. 内核态到用户态的拷贝消耗:非常高,每次poll调用都会将整个数组重新从用户态到内核态copy一次,且只支持水平触发。
  3. 内核态扫描的数据结构:线性扫描,FD剧增后会造成很大的效率问题。

优点:解决了select因为采取bitmap的连接数限制,且利用的是事件与fd绑定的结构体,用起来会更顺手。

缺点:仍然没有解决关键的效率问题,同时每次还是通过循环所有结构体来判断事件是否产生的方式来进行,所以本质上和select没有什么不同。

epoll

https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d415f2e59daf4a35b0d0ccd8f122c253~tplv-k3u1fbpfcp-watermark.image?

前面的讲解都只是为epoll铺路。

具体而言epoll解决了前面的IO复用模型的很多问题。

特点

  1. IO事件和监视的fd只需要添加一次。epoll_ctl
  2. 轮询操作是直接将已经就绪的fd赋值到用户态的数组,返回就绪的长度,也就是不需要再遍历所有的fd来判断是否就绪。epoll_wait
  3. 底层采用红黑树进行轮询,即使有大量fd需要检测,效率也不会太差。
  4. 支持水平触发和边缘触发。

技术点

  • 数据结构优化:底层采取红黑树+就绪队列。
  • 零拷贝优化:使用mmap进行内存映射实现内存共享。
  • 多种触发形式优化:支持EPOLLLT和EPOLLET两种触发模式,也就是支持水平触发和边缘触发。

关于水平触发与边缘触发: 水平触发是只要符合触发条件,就会一直触发可读信号,而边缘触发仅仅在状态转变的时候触发一次。

这个和数字电路里面的概念类似:你可以把当前是否符合触发条件想象成一个张图,这张图上有两个状态符合触发条件和不符合触发条件,水平触发是只要状态符合就触发,边缘触发是状态发生改变就触发,而在IO事件里这里的触发状态指的是前面select中讲的socket可读可写等状态。

https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0cdb8a54e88f48eda80900d2668fb361~tplv-k3u1fbpfcp-watermark.image?

使用

  1. 调用epoll_create创建结构并返回描述符。(需要传入一个参数,Linux 2.6.8后,传入的参数只要大于0即可,在此之前,表示最大的文件描述符
  2. 调用epoll_ctl加入需要监听的文件描述符以及对应事件(epoll_event结构体)。(需要传入四个参数:1.需要操作的epoll结构的描述符。2.需要进行的操作。3.需要操作的文件描述符。4.需要加入的结构体。
  3. 调用epoll_wait等待事件的到来,并返回就绪的文件描述符数量。(需要传入四个参数:1.需要操作的epoll结构的描述符。2.用于存放就绪的文件描述符的数组。3.数组的最大长度。4.超时时间。)返回值:就绪的文件描述符数量。

代码如下:

//1.在内核中创建结构并返回描述符                    epoll_create主要是将内核态的数据结构创建出来并初始化,然后再将它加入进程文件表,得到文件描述符
auto epfd = epoll_create(1); //Linux 2.6.8后,传入的参数只要大于0即可,在此之前,表示最大的文件描述符

//2.调用epoll_ctl加入需要监听的文件描述符以及对应事件
epoll_event evs[5];
for(int i=0;i<5;i++){
    sockaddr_in client{};
    socklen_t socklen;
    evs[i].data.fd = accept(server_fd,(sockaddr*)&client,&socklen);
    evs[i].events = EPOLLIN; //需要读取的事件
    if(epoll_ctl(epfd,EPOLL_CTL_ADD,evs[i].data.fd,&evs[i])<0)  //添加或删除内核数据结构中指定的文件描述符对应的结点
        ERR_EXIT("epoll_ctl");
}


while(1){
    puts("round again");
    //3.调用epoll_wait等待事件的到来,并返回就绪的文件描述符数量
    //查询就绪队列,并将已经ok的文件描述符,从左到右写入数组,返回写入的长度,返回-1表示轮询超时
    auto nfds = epoll_wait(epfd,evs,5,10000); //查询就绪队列,然后把就绪到位的文件描述符和对应的情况写入用户空间中的数组
    //遍历数组,执行最后的动作
    for(int i=0;i<nfds;i++){
        char buffer[1024]{};
        int len{};
        if((len = read(evs[i].data.fd,buffer,1024))<0){
            ERR_LOG("read error");
        }else{
            if(write(evs[i].data.fd,buffer,len)<0){
                ERR_LOG("write error");
            }
        }
    }
}

对epoll实现原理更为具体的描述有以下链接:

epoll 原理是如何实现的?

图解 | 深入揭秘 epoll 是如何实现 IO 多路复用的!