十三、select、poll和epoll机制的介绍

 相关链接:

一、概述

1、IO操作类型介绍

(1)同步IO

  在操作系统中,程序运行的空间分为内核空间和用户空间,用户空间所有对io操作的代码(如文件的读写、socket的收发等)都会通过系统调用进入内核空间完成实际的操作。

  CPU的速度远快于硬盘、网络等IO。在一个线程中,CPU执行代码的速度极快,然而,一旦遇到IO操作,如读写文件、发送网络数据时,就需要等待IO操作完成,才能继续进行下一步操作,这种情况称为同步IO.

  在某个应用程序运行时,假设需要读写某个文件,此时就发生了 I/O 操作,在 I/O 操作的过程中,系统会将当前线程挂起,而其他需要 CPU 执行的代码就无法被当前线程执行了,这就是同步 I/O操作,因为一个 IO 操作就阻塞了当前线程,导致其他代码无法执行,所以我们可以使用 多线程或者多进程来并发执行代码,当某个线程/进程被挂起后,不会影响其他线程或进程。多线程和多进程虽然解决了这种并发的问题,但是系统不能无上限地增加线程/进程。由于系统切换线程/进程的开销也很大,所以,一旦线程/进程数量过多,CPU 的时间就花在线程/进程切换上了,真正运行代码的时间就少了,这样子的结果也导致系统性能严重下降。多线程和多进程只是解决这一问题的一种方法,另一种解决 I/O 问题的方法是异步 I/O,当然还有其他解决的方法

(2)异步IO

  当程序需要对 I/O 进行操作时,它只发出 I/O 操作的指令,并不等待 I/O 操作的结果,然后就去执行其他代码了。一段时间后,当 I/O 返回结果时,再通知 CPU 进行处理。这样子用户空间中的程序不需要等待内核空间中的 I/O 完成实际操作,就可执行其他任务,提高 CPU 的利用率。
  简单来说就是,用户不需要等待内核完成实际对 IO的读写操作就直接返回了

(3)阻塞IO  

在 linux 中,默认情况下所有的 socket 都是阻塞的,一个典型的读操作流程大概是这样:

 

 

  当用户进程调用了 read()/recvfrom() 等系统调用函数,它会进入内核空间中,当这个网络I/O 没有数据的时候,内核就要等待数据的到来,而在用户进程这边,整个进程会被阻塞,直到内核空间返回数据。当内核空间的数据准备好了,它就会将数据从内核空间中拷贝到用户空间,此时用户进程才解除阻塞的的状态,重新运行起来。
  所以,阻塞 I/O 的特点就是在 IO 执行的两个阶段(用户空间与内核空间)都被阻塞了

(4)非阻塞IO

  linux 下,可以通过设置 socket 使其变为非阻塞模式,这种情况下,当内核空间并无数据的时候,它会马上返回结果而不会阻塞,此时用户进程可以根据这个结果自由配置,比如继续请求数据,或者不再继续请求。当对一个非阻塞 socket 执行读操作时,流程是这个样子:
  • 当用户进程调用 read()/recvfrom() 等系统调用函数时,如果内核空间中的数据还没有准备好,那么它并不会阻塞用户进程,而是立刻返回一个 error。
  • 对于应用进程来说,它发起一个 read() 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error 时,它就知道内核中的数据还没有准备好,那么它可以再次调用 read()/recvfrom() 等函数。
  • 当内核空间的数据准备好了,它就会将数据从内核空间中拷贝到用户空间,此时用户进程也就得到了数据。
  所以,非阻塞 I/O 的特点用户进程需要不断的 主动询问内核空间的数据准备好了没有

(5)多路复用IO

  多路复用 I/O 就是我们说的 select,poll,epoll 等操作,复用的好处就在于 单个进程就可以同时处理多个网络连接的 I/O,能实现这种功能的原理就是 select、poll、epoll 等函数会不断的 轮询它们所负责的所有 socket ,当某个 socket 有数据到达了,就通知用户进程
一般来说 I/O 复用多用于以下情况:
  • 当客户处理多个描述符时。
  • 服务器在高并发处理网络连接的时候。
  • 服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到 I/O 复用。 
  •  如果一个服务器即要处理 TCP,又要处理 UDP,一般要使用 I/O 复用。
  • 如果一个服务器要处理多个服务或多个协议,一般要使用 I/O 复用。
  与多进程和多线程技术相比,I/O 多路复用技术的最大优势系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。但 select,poll,epoll 本质上都是同步 I/O,因为他们都需要 在读写事件就绪后自己负责进行读写,也就是说这个读写过程是 阻塞的,而 异步 I/O 则无需自己负责进行读写,异步 I/O 的实现会负责把数据从内核拷贝到用户空间.

二、多路复用IO操作介绍

1、select

  select 机制会监听它所负责的所有 socket,当其中一个 socket 或者多个socket 可读或者可写的时候,它就会返回,而如果所有的 socket 都是不可读或者不可写的时候,这个进程就会被阻塞,直到超时或者 socket 可读写,当 select 函数返回后,可以通过遍历 fdset,来找到就绪的描述符。

(1)select整个处理过程

  • 用户进程调用 select() 函数,如果当前没有可读写的 socket,则用户进程进入阻塞状态。
  •  对于内核空间来说,它会从用户空间拷贝 fd_set 到内核空间,然后在内核中遍历一遍所有的 socket 描述符,如果没有满足条件的 socket 描述符,内核将进行休眠,当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的内核进程,即在 socket 可读写时唤醒,或者在超时后唤醒。
  • 返回 select() 函数的调用结果给用户进程,返回就绪 socket 描述符的数目,超时返回0,出错返回-1。
  • 注意,在 select() 函数返回后还是需要轮询去找到就绪的 socket 描述符的,此时用户进程才可以去操作 socket 。

(2)优缺点

 优点:
  • select 目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。
缺点:
  • 单个进程能够监视的文件描述符的数量存在最大限制,在 Linux 上一般为 1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低。

  • 需要维护一个用来存放大量 fd 的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大

  • 每次在有 socket 描述符活跃的时候,都需要遍历一遍所有的 fd 找到该描述符,这会带来大量的时间消耗(时间复杂度是 O(n),并且伴随着描述符越多,这开销呈线性增长)

(3)函数原型

#include <sys/select.h>

/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)
参数说明:
  • maxfdp1 指定感兴趣的文件 描述符个数,它的值是套接字最大文件描述符加 1,socket描述符 0、1、2 …maxfdp1-1 均将被设置为感兴趣(即会查看他们是否可读、可写)。(加1的原因是文件描述符的值是从0开始的,所以文件描述符的个数是文件描述符的个数+1)
  • readset:指定这个 文件描述符是可读的时候才返回。
  •  writeset:指定这个 文件描述符是可写的时候才返回。
  • exceptset:指定这个 文件描述符是异常条件时候才返回。
  • timeout:指定了超时的时间,当超时了也会返回。

 如果对某一个的条件不感兴趣,就可以把它设为空指针。

返回值:
  就绪文件描述符的数目,超时返回 0,出错返回-1

(4)基本用法

  • 创建
fd_set rset , allset;

//用来清空fd_set集合,即让fd_set集合不再包含任何文件句柄。
FD_ZERO(&allset);

//用来将一个给定的文件描述符加入集合之中
FD_SET(listenfd, &allset);
  • 监听
/*只select出用于读的描述字,阻塞无timeout*/
nready = select(maxfd+1 , &rset , NULL , NULL , NULL);
  • 获取
if(FD_ISSET(listenfd,&rset))  //检测fd在fdset集合中的状态是否变化,当检测到fd状态发生变化时返回真,否则,返回假(也可以认为集合中指定的文件描述符是否可以读写)。
  • 删除文件描述符
FD_CLR(int fd ,fd_set* set);

用来将一个给定的文件描述符从集合中删除

(5)实例1

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

    /* Watch stdin (fd 0) to see when it has input. */
    FD_ZERO(&rfds);
    FD_SET(0, &rfds);

    /* Wait up to five seconds. */
    tv.tv_sec = 5;
    tv.tv_usec = 0;

    retval = select(1, &rfds, NULL, NULL, &tv);   //监听标准输出是否有数据,最多阻塞5s ,第一个参数值为1,代表标准输出的文件描述符的值
//在Linux进程中,默认情况下会有3个缺省打开的文件描述符,分别是标准输入(stdin)0,标准输出(stdout)1,标准错误(stderr)2。

    // 文件描述符0,1,2对应的物理设备一般是:键盘,显示器,显示器。


    /* 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) will be true. */
    else
        printf("No data within five seconds.\n");

    exit(EXIT_SUCCESS);
} 

执行结果:

终端无任何输入时,等待5s后select返回

 终端有输入时,select会监控读文件描述符返回非-1

 

(6)实例2

TCP的服务器端,可以监听多个客户端连接,只要有客户端发数据过来,select就返回。然后进行数据的接收

#include <stdio.h>
#include <string.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/select.h>



const static int MAXLINE = 1024;
const static int SERV_PORT = 10001;

int main()
{
    int i , maxi , maxfd, listenfd , connfd , sockfd ;
    /*nready 描述字的数量*/
    int nready ,client[FD_SETSIZE];
    int n ;
    /*创建描述字集合,由于select函数会把未有事件发生的描述字清零,所以我们设置两个集合*/
    fd_set rset , allset;
    char buf[MAXLINE];
    socklen_t clilen;
    struct sockaddr_in cliaddr , servaddr;
    /*创建socket*/
    listenfd = socket(AF_INET , SOCK_STREAM , 0);
    /*定义sockaddr_in*/
    memset(&servaddr , 0 ,sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(SERV_PORT);
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    printf("s_addr=%s\n",servaddr.sin_addr.s_addr);
    bind(listenfd, (struct sockaddr *) & servaddr , sizeof(servaddr));
    listen(listenfd , 100);  //监听socket连接,100是连接请求队列的最大个数,表示最大可以连接100个客户端
    /*listenfd 是第一个描述字*/
    /*最大的描述字,用于select函数的第一个参数*/
    maxfd = listenfd;
    /*client的数量,用于轮询*/
    maxi = -1;
    /*init*/
    for(i=0 ;i<FD_SETSIZE ; i++)
        client[i] = -1;
    FD_ZERO(&allset);    //清空文件描述符集合
    FD_SET(listenfd, &allset);  //将listenfd文件描述符加入集合

    for (;;)
    {
        rset = allset;
        
        nready = select(maxfd+1 , &rset , NULL , NULL , NULL); //监听集合中的读socket,如果读文件描述符处于就绪状态就返回,否则一直阻塞
        if(FD_ISSET(listenfd,&rset))  //判断集合中是否存在listenfd文件描述符
        {
            clilen = sizeof(cliaddr);
            connfd = accept(listenfd , (struct sockaddr *) & cliaddr , &clilen);
            /*寻找第一个能放置新的描述字的位置*/
            for (i=0;i<FD_SETSIZE;i++)
            {
                if(client[i]<0)
                {
                    client[i] = connfd;
                    break;
                }
            }
            /*找不到,说明client已经满了*/
            if(i==FD_SETSIZE)
            {
                printf("Too many clients , over stack .");
                return -1;
            }
            FD_SET(connfd,&allset);//设置fd
            /*更新相关参数*/
            if(connfd > maxfd) maxfd = connfd;
            if(i>maxi) maxi = i;
            if(nready<=1) continue;
            else nready --;
        }

        for(i=0 ; i<=maxi ; i++)
        {
            if (client[i]<0) continue;
            sockfd = client[i];
            if(FD_ISSET(sockfd,&rset))
            {
                n = read(sockfd , buf , MAXLINE);
                if (n==0)
                {
                    /*当对方关闭的时候,server关闭描述字,并将set的sockfd清空*/
                    close(sockfd);
                    FD_CLR(sockfd,&allset);
                    client[i] = -1;
                }
                else
                {
                    buf[n]=' ';
                    printf("Socket %d said : %s",sockfd,buf);
                    write(sockfd,buf,n); //Write back to client
                }
                nready--;
                if(nready<=0) break;
            }
        }

    }
    return 0;
}

  

2、poll  

(1)概述  

  poll的实现和select非常相似,只是描述fd集合的方式不同,poll使用pollfd结构而不是select的fd_set结构,poll不限制socket描述符的个数,因为它是使用链表维护这些socket描述符的,其他的都差不多和select()函数一样,poll()函数返回后,需要轮询pollfd来获取就绪的描述符,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。
 
  poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。

(2)函数原型

监视多个文件描述符的指定事件,事件发生时(驱动唤醒),把发生的具体事件通知给用户程序  
int  poll(struct pollfd *fds, unsigned int nfds,int timeout);
参数:
  • fds :一个struct pollfd类型的数组.
  struct pollfd {
  int fd; /* 文件描述符 */
  short events; /* 请求的事件类型 */
  short revents; /* 返回的事件类型 */
  };  
    • fd:要监视的文件描述符

    • events:是要监视的事件revents是返回事件,内核设置具体的返回事件
      • POLLIN:系统内核通知应用层指定数据已经备好,读数据不会被阻塞

      • POLLPRI :有紧急的数据需要读取

      • POLLOUT :系统内核通知应用层IO缓冲区已准备好,写数据不会被阻塞

      • POLLERR :指定的文件描述符发生错误

      • POLLNVAL:无效的请求

  • nfds:pollfd数组的元素个数,要监控的文件描述符数量

  • timeout:超时时间(ms)

返回值:
  • 成功:发生事件的文件数量,超时返回0
  • 失败:-1

(3)实例

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#include <poll.h>
int main(int argc, char *argv[])
{
   struct pollfd fds ={0};
   fds.fd =0;
   fds.events=POLLIN;     //系统内核通知应用层指定数据已经备好,读数据不会被阻塞

   int ret = poll(&fds,1,5000);  //监视读事件,超时事件为5s

   if(ret == -1)
        printf("poll error!\r\n");
    else if(ret)
        printf("data is ready!\r\n");
    else if(ret ==0)
        printf("time out!/r/n");
}
执行结果:

终端无输入时:

终端有输入时:

3、epoll

(1)概述

  其实相对于select和poll来说,epoll更加灵活,但是核心的原理都是当socket描述符就绪(可读、可写、出现异常),就会通知应用进程,告诉他哪个socket描述符就绪,只是通知处理的方式不同而已。

  epoll使用一个epfd(epoll文件描述符)管理多个socket描述符,epoll不限制socket描述符的个数,将用户空间的socket描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。当epoll记录的socket产生就绪的时候,epoll会通过callback的方式来激活这个fd,这样子在epoll_wait便可以收到通知,告知应用层哪个socket就绪了,这种通知的方式是可以直接得到那个socket就绪的,因此相比于select和poll,它不需要遍历socket列表,时间复杂度是O(1),不会因为记录的socket增多而导致开销变大。

  • LT 模式:即水平触发模式,当 epoll_wait 检测到 socket 描述符处于就绪时就通知应用程序,应用程序可以不立即处理它。下次调用 epoll_wait 时,还会再次产生通知。

 

  • ET 模式:即边缘触发模式,当 epoll_wait 检测到 socket 描述符处于就绪时就通知应用程序,应用程序 必须立即处理它。如果不处理,下次调用 epoll_wait 时,不会再次产生通知。ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。epoll 工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

(2)创建一个 epoll 的文件描述符

   当创建好 epoll 句柄后,它就是会占用一个 fd 值,必须调用 close() 关闭,否则可能导致 fd 被耗尽,这也是为什么我们前面所讲的是:epoll 使用一个 epfd 管理多个 socket 描述符

#include <sys/epoll.h>

int epoll_create(int size);   
参数:
  • size :用来告诉内核这个监听的数目一共有多大,它其实是在内核申请一空间,用来存放用户想监听的 socket fd 上是否可读可行或者其他异常,只要有足够的内存空间,size 可以随意设置大小,1G 的内存上能监听约 10 万个端口。 
返回值:
  • 成功:返回一个非负的文件描述符。

  • 失败:返回-1 

(3)epoll事件控制函数
该函数用于控制某个 epoll 文件描述符上的事件,可以注册事件,修改事件,以及删除事件。 
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数:
  • epdf:由 epoll_create() 函数返回的 epoll 文件描述符(句柄)。

  • op:op 是操作的选项,目前有以下三个选项:

    • EPOLL_CTL_ADD:注册要监听的目标 socket 描述符 fd 到 epoll 句柄中。

    •  EPOLL_CTL_MOD:修改 epoll 句柄已经注册的 fd 的监听事件。

    • EPOLL_CTL_DEL:从 epoll 句柄删除已经注册的 socket 描述符。

  • fd:指定监听的 socket 描述符。
  • event:event 结构如下
typedef union epoll_data {
  void *ptr;
  int fd;
  uint32_t u32;
  uint64_t u64;
} epoll_data_t;
struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ };
 
events 可以是以下几个宏的集合:
  • EPOLLIN:表示对应的文件描述符可以读(包括对端 SOCKET 正常关闭)。

  • EPOLLOUT:表示对应的文件描述符可以写。

  •  EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)。

  • EPOLLERR:表示对应的文件描述符发生错误。

  • EPOLLHUP:表示对应的文件描述符被挂断。

  • EPOLLET:将 EPOLL 设为边缘触发 (Edge Triggered) 模式,这是相对于水平触发(Level Triggered) 来说的。

  • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个 socket 的话,需要再次把这个 socket 加入到 EPOLL 队列里

(4)epoll等待事件函数

等待监听的事件的发生,类似于调用 select() 函数

int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
参数:
  • events:用来从内核得到事件的集合。

  •  maxevents:告知内核这个 events 有多大,这个 maxevents 的值不能大于创建 epoll_create() 时的指定的 size。

  • timeout:超时时间。

  • 函数的返回值表示需要处理的事件数目,如返回 0 表示已超时。 

(5)epoll 为什么更高效 

1. 当我们调用 epoll_wait() 函数返回的不是实际的描述符,而是一个代表就绪描述符数量的值,这个时候需要去 epoll 指定的一个数组中依次取得相应数量的 socket 描述符即可,而不需要遍历扫描所有的 socket 描述符,因此这里的时间复杂度是 O(1)。

2. 此外还使用了内存映射(mmap )技术,这样便彻底省掉了这些 socket 描述符在系统调用时拷贝的开销(因为从用户空间到内核空间需要拷贝操作)。mmap 将用户空间的一块地址和 内核空间的一块地址同时映射到相同的一块物理内存地址(不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理地址),使得这块物理内存对内核和对用户均可见,减少用户态和内核态之间的数据交换,不需要依赖拷贝,这样子内核可以直接看到 epoll 监听的 socket 描述符,效率极高。

3. 另一个本质的改进在于 epoll 采用基于事件的就绪通知方式。在 select/poll 中,进程只有在调用一定的方法后,内核才对所有监视的 socket 描述符进行扫描,而 epoll 事先通过 epoll_ctl() 来注册一个 socket 描述符,一旦检测到 epoll 管理的 socket 描述符就绪时,内核会采用类似 callback 的回调机制,迅速激活这个 socket 描述符,当进程调用 epoll_wait() 时便可以得到通知,也就是说 epoll 最大的优点就在于它 只管就绪的socket 描述符,而跟 socket 描述符的总数无关
(6)实例

 

  #define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int listen_sock, conn_sock, nfds, epollfd;

/* Set up listening socket, 'listen_sock' (socket(),
    bind(), listen()) */

epollfd = epoll_create(10);   /
if (epollfd == -1) {
    perror("epoll_create");
    exit(EXIT_FAILURE);
}

ev.events = EPOLLIN;
ev.data.fd = listen_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
    perror("epoll_ctl: listen_sock");
    exit(EXIT_FAILURE);
}

for (;;) {
    nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
    if (nfds == -1) {
        perror("epoll_pwait");
        exit(EXIT_FAILURE);
    }

    for (n = 0; n < nfds; ++n) {
        if (events[n].data.fd == listen_sock) {
            conn_sock = accept(listen_sock,
                            (struct sockaddr *) &local, &addrlen);
            if (conn_sock == -1) {
                perror("accept");
                exit(EXIT_FAILURE);
            }
            setnonblocking(conn_sock);
            ev.events = EPOLLIN | EPOLLET;
            ev.data.fd = conn_sock;
            if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock,
                        &ev) == -1) {
                perror("epoll_ctl: conn_sock");
                exit(EXIT_FAILURE);
            }
        } else {
            do_use_fd(events[n].data.fd);
        }
    }
}

 

  

   

 

  

  

 

posted @ 2022-05-05 11:32  轻轻的吻  阅读(323)  评论(0编辑  收藏  举报