IO多路复用

对于IO复用,我们可以通过一个例子来很好的理解它。

例子来自于《TCP/IP网络编程》)

某教室有10名学生和1名老师,这些学生上课会不停的提问,所以一个老师处理不了这么多的问题。那么学校为每个学生都配一名老师,也就是这个教室目前有10名老师。此后,只要有新的转校生,那么就会为这个学生专门分配一个老师,因为转校生也喜欢提问题。如果把以上例子中的学生比作客户端,那么老师就是负责进行数据交换的服务端。则该例子可以比作是多进程的方式。

后来有一天,来了一位具有超能力的老师,这位老师回答问题非常迅速,并且可以应对所有的问题。而这位老师采用的方式是学生提问前必须先举手,确认举手学生后在回答问题。则现在的情况就是IO复用。

目前的常用的IO复用模型有三种:select,poll,epoll

1、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);


select()函数允许程序监视多个文件描述符,等待所监视的一个或者多个文件描述符变为“准备好”的状态。所谓的”准备好“状态是指:文件描述符不再是阻塞状态,可以用于某类IO操作了,包括可读,可写,发生异常三种。

  • int nfds是一个整数值,是指集合中所有文件描述符的范围,即所有文件描述符的最大值加1,不能错!在Windows中这个参数的值无所谓,可以不设置。

    • 表示的是文件描述符的数量,从0开始所以比最大的描述符多1
  • fd_set可以理解为一个集合,这个集合中存放的是文件描述符(file descriptor),即文件句柄。fd_set集合可以通过一些宏由人为来操作。

    void FD_ZERO(fd_set *fdset)			清空fdset与所有文件句柄的联系。 
    void FD_SET(int fd, fd_set *fdset)	建立文件句柄fd与fdset的联系。 
    void FD_CLR(int fd, fd_set *fdset)	清除文件句柄fd与fdset的联系。 
    int  FD_ISSET(int fd, fdset *fdset)	检查fdset联系的文件句柄fd是否可读写,>0表示可读写。 
    
    • readfds:
      监视文件描述符的一个集合,我们监视其中的文件描述符是不是可读,或者更准确的说,读取是不是不阻塞了。
    • writefds:
      监视文件描述符的一个集合,我们监视其中的文件描述符是不是可写,或者更准确的说,写入是不是不阻塞了。
    • exceptfds:
      用来监视发生错误异常文件
  • struct timeval用来代表时间值,有两个成员,一个是秒数,另一个是毫秒数。

    • 若将NULL以形参传入,就是将select置于阻塞状态,一定等到监视文件描述符集合中某个文件描述符发生变化为止;
    • 若将时间值设为0秒0毫秒,就变成一个纯粹的非阻塞函数,不管文件描述符是否有变化,都立刻返回继续执行,文件无变化返回0,有变化返回一个正值;
    • timeout的值大于0,这就是等待的超时时间,即select在timeout时间内阻塞,超时时间之内有事件到来就返回了,否则在超时后不管怎样一定返回。
    struct timeval{      
        long tv_sec;   /*秒 */
        long tv_usec;  /*微秒 */   
    }
    
  • 三个fd_set分别监视文件描述符的读写异常变化,如果有select会返回一个大于0的值。如果没有则在timeout的时间后select返回0,若发生错误返回负值。可以传入NULL值,表示不关心任何文件的读/写/异常变化。

  • 返回值

    • 成功时:返回三中描述符集合中”准备好了“的文件描述符数量。
    • 超时:返回0
    • 错误:返回-1,并设置 errno
      • EBADF:集合中包含无效的文件描述符。(文件描述符已经关闭了,或者文件描述符上已经有错误了)。
      • EINTR:捕获到一个信号。
      • EINVAL:nfds是负的或者timeout中包含的值无效。
      • ENOMEM:无法为内部表分配内存。

select是三者当中最底层的,它的事件的轮训机制是基于比特位的。每次查询都要遍历整个事件列表。
理解select,首先要理解select要处理的fd_set数据结构,fd_set简单地理解为一个长度是1024的比特位,每个比特位表示一个需要处理的FD,如果是1,那么表示这个FD有需要处理的I/O事件,否则没有。

一般的通用模型

int main() {


  fd_set read_fs, write_fs;
  struct timeval timeout;
  int max_sd = 0;  // 用于记录最大的fd,在轮询中时刻更新即可
  
  /*
   * 这里进行一些初始化的设置,
   * 包括socket建立,地址的设置等,
   * 同时记得初始化max_sd
   */

  // 初始化比特位
  FD_ZERO(&read_fs);
  FD_ZERO(&write_fs);

  int rc = 0;
  int desc_ready = 0; // 记录就绪的事件,可以减少遍历的次数
  while (1) {
    // 这里进行阻塞
    rc = select(max_sd + 1, &read_fd, &write_fd, NULL, &timeout);
    if (rc < 0) {
      // 这里进行错误处理机制
    }
    if (rc == 0) {
      // 这里进行超时处理机制
    }

    desc_ready = rc;
    // 遍历所有的比特位,轮询事件
    for (int i = 0; i <= max_sd && desc_ready; ++i) {
      if (FD_ISSET(i, &read_fd)) {
        --desc_ready;
        // 这里处理read事件,别忘了更新max_sd
      }
      if (FD_ISSET(i, &write_fd)) {
        // 这里处理write事件,别忘了更新max_sd
      }
    }
  }
}

2、poll

 struct pollfd{
    int fd; //file descriptor
    short event;//event of interest on fd
    short revent;//event that occurred on fd
  }

int poll(struct pollfd fdarray[], nfds_t nfds, int timeout);
  • 第一个参数

每一个pollfd结构体指定了一个被监视的文件描述符,可以传递多个结构体,指示poll()监视多个文件描述符。

events域是监视该文件描述符的事件掩码,由用户来设置这个域。

revents域是文件描述符的操作结果事件掩码。内核在调用返回时设置这个域。events域中请求的任何事件都可能在revents域中返回。

poll函数可用的测试值

常量说明
POLLIN普通或优先级带数据可读
POLLRDNORM普通数据可读
POLLRDBAND优先级带数据可读
POLLPRI高优先级数据可读
POLLOUT普通数据可写
POLLWRNORM普通数据可写
POLLWRBAND优先级带数据可写
POLLERR发生错误
POLLHUP发生挂起
POLLNVAL描述字不是一个打开的文件

注意:后三个只能作为描述字的返回结果存储在revents中,而不能作为测试条件用于events中。

  • 第二个参数 :nfds 指定第一个参数中数组中元素个数。
  • 第三个参数:指定等待的毫秒数,无论 I/O 是否准备好,poll() 都会返回.
    • -1: 永远等待
    • 0: 立即返回,不阻塞
    • >0: 等待指定数目的毫秒数
  • 返回值:
    • 成功时,poll() 返回结构体中 revents 域不为 0 的文件描述符个数;如果在超时前没有任何事件发生,poll()返回 0;
    • 失败时,poll() 返回 -1,并设置 errno 为下列值之一:
      • EBADF:一个或多个结构体中指定的文件描述符无效。
      • EFAULT:fds 指针指向的地址超出进程的地址空间。
      • EINTR:请求的事件之前产生一个信号,调用可以重新发起。
      • EINVAL:nfds 参数超出 PLIMIT_NOFILE 值。
      • ENOMEM:可用内存不足,无法完成请求。
int main(void)
{
	int fd,key_value,ret;
	struct pollfd event;                      //创建一个struct pollfd结构体变量,存放文件描述符、要等待发生的事件
	fd=open("/dev/key",O_RDWR); 

	if(fd<0){
    	perror("open /dev/key error!/n");
    	exit(1);
    }
    printf("open /dev/key sucessfully!/n");

    while(1){                                                     //poll结束后struct pollfd结构体变量的内容被全部清零,需要再次设置
    	memset(&event,0,sizeof(event));                      //memst函数对对象的内容设置为同一值
    	event.fd=fd;                                                       //存放打开的文件描述符
    	event.events=POLLIN;                                                             //存放要等待发生的事件
   	 	ret=poll((struct pollfd *)&event,1,5000);                       //监测event,一个对象,等待5000毫秒后超时,-1为无限等待

        //判断poll的返回值,负数是出错,0是设定的时间超时,整数表示等待的时间发生
        if(ret<0){
            printf("poll error!/n");
            exit(1);
   		}
        else if(ret==0){
            printf("Time out!/n");
            continue;
        }
        
        if(event.revents&POLLERR){                               //revents是由内核记录的实际发生的事件,events是进程等待的事件
            printf("Device error!/n");
            exit(1);
        }

        if(event.revents&POLLIN){
            read(fd,&key_value,sizeof(key_value));
            printf("Key value is '%d'/n",key_value);
        }
    }
    close(fd);
    return 0;
}
#include <sys/socket.h>
#include <netinet/in.h>
#include <stdio.h>
#include <error.h>
#include <errno.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <limits.h>
#include <poll.h>
#include <sys/stropts.h>
#include <signal.h>
#define MAXLINE 5
#define OPEN_MAX 1024
#define SA struct sockaddr
 
int main()
{
    int listenfd, connfd, sockfd, i, maxi;
    int nready;
    socklen_t clilen;
    ssize_t n;
    char buf[MAXLINE];
    struct pollfd client[OPEN_MAX];
    struct sockaddr_in servaddr, cliaddr;
    //创建监听套接字
    if((listenfd = socket(AF_INET, SOCK_STREAM, 0)) < 0){
       printf("socket() error!");
       exit(0);
    }
    
    
    bzero(&servaddr,sizeof(servaddr));//先要对协议地址进行清零
    
    servaddr.sin_family = AF_INET;//设置为 IPv4 or IPv6
    servaddr.sin_port    = htons(9805); //绑定本地端口号  
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);//任何一个 IP 地址,让内核自行选择
   
    if(bind(listenfd, (SA *) &servaddr,sizeof(servaddr)) < 0){ //绑定套接口到本地协议地址
        printf("bind() error!");
        exit(0);
    }
    
    //服务器开始监听
    if(listen(listenfd,5) < 0){
        printf("listen() error!");
        exit(0);
    }
    
    client[0].fd = listenfd;
    client[0].events = POLLRDNORM;//关心监听套机字的读事件
    for(i = 1; i < OPEN_MAX; ++i){
        client[i].fd = -1;
    }
    
    maxi = 0;
    for(;;){
        nready = poll(client, maxi + 1, -1);
        
        if(client[0].revents & POLLRDNORM){
            clilen = sizeof(cliaddr);
            //accept 的后面两个参数都是值-结果参数,他们的保留的远程连接电脑的信息,如果不管新远程连接电脑的信息,可以将这两个参数设置为 NULL
            connfd = accept(listenfd, (SA *) &cliaddr, &clilen);
            if(connfd < 0){
               continue;
            }
            
            for(i = 1; i < OPEN_MAX; ++i){
               if(client[i].fd < 0)
                 client[i].fd = connfd;
                 break;
            }
            if(i == OPEN_MAX){
                printf("too many clients");
                exit(0);
            }
            client[i].events = POLLRDNORM;
            if(i > maxi){
                maxi = i;
            }
            if(--nready <=0 )
                continue;
          }
 
          for(i = 1; i < OPEN_MAX; ++i){
               if((sockfd = client[i].fd) < 0){
                  continue;
               }
             
               if(client[i].revents & POLLRDNORM | POLLERR){
                   if((n = read(sockfd, buf, MAXLINE)) < 0){
                      if(errno == ECONNRESET){
                         close(sockfd);
                         client[i].fd = -1;
                      }
                      else{
                         printf("read error!\n");
                      }
               		}
                   else if(n == 0){
                       close(sockfd);
                       client[i].fd = -1;
                   }
                   else{
                       write(sockfd, buf,  n);
                   }
                   if(--nready <= 0)
                       break;
               }
         }
    }
}

3、epoll 背景

select的缺点:

  1. 单个进程能够监视的文件描述符的数量存在最大限制,通常是1024,当然可以更改数量,但由于select采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差;(在linux内核头文件中,有这样的定义:#define __FD_SETSIZE 1024)
  2. 内核 / 用户空间内存拷贝问题,select需要复制大量的句柄数据结构,产生巨大的开销;
  3. select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件;
  4. select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次select调用还是会将这些文件描述符通知进程。

相比select模型,poll使用链表保存文件描述符,因此没有了监视文件数量的限制,但其他三个缺点依然存在。

拿select模型为例,假设我们的服务器需要支持100万的并发连接,则在__FD_SETSIZE 为1024的情况下,则我们至少需要开辟1k个进程才能实现100万的并发连接。除了进程间上下文切换的时间消耗外,从内核/用户空间大量的无脑内存拷贝、数组轮询等,是系统难以承受的。因此,基于select模型的服务器程序,要达到10万级别的并发访问,是一个很难完成的任务。

因此,该epoll上场了。

3.1、 IO多路复用模型实现机制

由于epoll的实现机制与select/poll机制完全不同,上面所说的 select的缺点在epoll上不复存在。

设想一下如下场景:有100万个客户端同时与一个服务器进程保持着TCP连接。而每一时刻,通常只有几百上千个TCP连接是活跃的(事实上大部分场景都是这种情况)。如何实现这样的高并发?

在select/poll时代,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄数据结构到内核态),让操作系统内核去查询这些套接字上是否有事件发生,轮询完后,再将句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大,因此,select/poll一般只能处理几千的并发连接。

个人总结select/poll的缺点:

  • 所监控的文件描述符, 每次都需要在用户空间/内核空间之间拷贝
  • 返回的文件描述符信息, 需要遍历才能知道哪些可以进行操作

epoll主要解决上面的两个缺点问题。

epoll引入了epoll_ctl系统调用,将高频调用的epoll_wait和低频的epoll_ctl隔离开。

1、epoll_ctl通过(EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL)三个操作来对需要监控的fds集合进行修改,做到了有变化才变更,将select/poll高频、大块内存拷贝变成epoll_ctl的低频、小块内存的拷贝,避免了大量的在用户空间/内核空间之间拷贝。

2、对于高频epoll_wait的fd集合返回的拷贝问题,epoll通过内核与用户空间mmap(内存映射)同一块内存来解决。mmap将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址(不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理地址),使得这块物理内存对内核和对用户均可见,减少用户态和内核态之间的数据交换。

另外epoll通过epoll_ctl对fds集合进行增、删、改,那么必须涉及到fd的快速查找问题。

在linux 2.6.8之前的内核,epoll使用hash来组织fds集合,于是在创建epoll fd的时候,epoll需要初始化hash的大小。epoll_create(int size)有一个参数size,以便内核根据size的大小来分配hash的大小。

在linux 2.6.8之后的内核中,epoll使用红黑树来组织监控的fds集合,于是epoll_create(int size)的参数size实际上已经没有意义了,同时,size不要传0,会报invalid argument错误。

3.2、图解

网卡把数据写入到内存后,网卡向 CPU 发出一个中断信号,操作系统便能得知有新数据到来,再通过网卡中断程序去处理数据。

进程阻塞为什么不占用 CPU 资源?

阻塞是进程调度的关键一环,指的是进程在等待某事件(如接收到网络数据)发生之前的等待状态,Recv、Select 和 Epoll 都是阻塞方法。

下边分析一下进程阻塞为什么不占用 CPU 资源?为简单起见,我们从普通的 Recv 接收开始分析,先看看下面代码:

//创建socket 
int s = socket(AF_INET, SOCK_STREAM, 0);    
//绑定 
bind(s, ...) 
//监听 
listen(s, ...) 
//接受客户端连接 
int c = accept(s, ...) 
//接收客户端数据 
recv(c, ...); 
//将数据打印出来 
printf(...) 

先新建 Socket 对象,依次调用 Bind、Listen 与 Accept,最后调用 Recv 接收数据。

Recv 是个阻塞方法,当程序运行到 Recv 时,它会一直等待,直到接收到数据才往下执行。那么阻塞的原理是什么?

工作队列

操作系统为了支持多任务,实现了进程调度的功能,会把进程分为“运行”和“等待”等几种状态。

运行状态是进程获得 CPU 使用权,正在执行代码的状态;等待状态是阻塞状态,比如上述程序运行到 Recv 时,程序会从运行状态变为等待状态,接收到数据后又变回运行状态。

操作系统会分时执行各个运行状态的进程,由于速度很快,看上去就像是同时执行多个任务。

下图的计算机中运行着 A、B 与 C 三个进程,其中进程 A 执行着上述基础网络程序,一开始,这 3 个进程都被操作系统的工作队列所引用,处于运行状态,会分时执行。

在这里插入图片描述

工作队列中有 A、B 和 C 三个进程

等待队列

当进程 A 执行到创建 Socket 的语句时,操作系统会创建一个由文件系统管理的 Socket 对象(如下图)。

在这里插入图片描述

创建 Socket, 这个 Socket 对象包含了发送缓冲区、接收缓冲区与等待队列等成员。等待队列是个非常重要的结构,它指向所有需要等待该 Socket 事件的进程。

当程序执行到 Recv 时,操作系统会将进程 A 从工作队列移动到该 Socket 的等待队列中(如下图)。

在这里插入图片描述

Socket 的等待队列

由于工作队列只剩下了进程 B 和 C,依据进程调度,CPU 会轮流执行这两个进程的程序,不会执行进程 A 的程序。所以进程 A 被阻塞,不会往下执行代码,也不会占用 CPU 资源。

:操作系统添加等待队列只是添加了对这个“等待中”进程的引用,以便在接收到数据时获取进程对象、将其唤醒,而非直接将进程管理纳入自己之下。上图为了方便说明,直接将进程挂到等待队列之下。

唤醒进程

当 Socket 接收到数据后,操作系统将该 Socket 等待队列上的进程重新放回到工作队列,该进程变成运行状态,继续执行代码。

同时由于 Socket 的接收缓冲区已经有了数据,Recv 可以返回接收到的数据。

内核接收网络数据全过程

这一步,贯穿网卡、中断与进程调度的知识,叙述阻塞 Recv 下,内核接收数据的全过程。

在这里插入图片描述

内核接收数据全过程

如上图所示,进程在 Recv 阻塞期间:

  • 计算机收到了对端传送的数据(步骤 ①)
  • 数据经由网卡传送到内存(步骤 ②)
  • 然后网卡通过中断信号通知 CPU 有数据到达,CPU 执行中断程序(步骤 ③)

此处的中断程序主要有两项功能,先将网络数据写入到对应 Socket 的接收缓冲区里面(步骤 ④),再唤醒进程 A(步骤 ⑤),重新将进程 A 放入工作队列中。

唤醒进程的过程如下图所示:

在这里插入图片描述

唤醒进程

以上是内核接收数据全过程,这里我们可能会思考两个问题:

  • 操作系统如何知道网络数据对应于哪个 Socket?
  • 如何同时监视多个 Socket 的数据?

第一个问题:因为一个 Socket 对应着一个端口号,而网络数据包中包含了 IP 和端口的信息,内核可以通过端口号找到对应的 Socket。

当然,为了提高处理速度,操作系统会维护端口号到 Socket 的索引结构,以快速读取。

第二个问题是多路复用的重中之重,也正是本文后半部分的重点。

同时监视多个 Socket 的简单方法

Select 的流程

Select 的实现思路很直接,假如程序同时监视如下图的 Sock1、Sock2 和 Sock3 三个 Socket,那么在调用 Select 之后,操作系统把进程 A 分别加入这三个 Socket 的等待队列中。

操作系统把进程 A 分别加入这三个 Socket 的等待队列中

当任何一个 Socket 收到数据后,中断程序将唤起进程。下图展示了 Sock2 接收到了数据的处理流程:

在这里插入图片描述

Sock2 接收到了数据,中断程序唤起进程 A

所谓唤起进程,就是将进程从所有的等待队列中移除,加入到工作队列里面,如下图所示:

在这里插入图片描述

将进程 A 从所有等待队列中移除,再加入到工作队列里面

经由这些步骤,当进程 A 被唤醒后,它知道至少有一个 Socket 接收了数据。程序只需遍历一遍 Socket 列表,就可以得到就绪的 Socket。

缺点是:

  • 每次调用 Select 都需要将进程加入到所有监视 Socket 的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历,而且每次都要将整个 FDS 列表传递给内核,有一定的开销。正是因为遍历操作开销大,出于效率的考量,才会规定 Select 的最大监视数量,默认只能监视 1024 个 Socket。

  • 进程被唤醒后,程序并不知道哪些 Socket 收到数据,还需要遍历一次。

Epoll 的设计思路

Epoll 是在 Select 出现 N 多年后才被发明的,是 Select 和 Poll(Poll 和 Select 基本一样,有少量改进)的增强版本。Epoll 通过以下一些措施来改进效率:

措施一:功能分离

Select 低效的原因之一是将“维护等待队列”和“阻塞进程”两个步骤合二为一。

在这里插入图片描述

相比 Select,Epoll 拆分了功能

如上图所示,每次调用 Select 都需要这两步操作,然而大多数应用场景中,需要监视的 Socket 相对固定,并不需要每次都修改。

Epoll 将这两个操作分开,先用 epoll_ctl 维护等待队列,再调用 epoll_wait 阻塞进程。显而易见地,效率就能得到提升。

为方便理解后续的内容,我们先了解一下 Epoll 的用法。如下的代码中,先用 epoll_create 创建一个 Epoll 对象 Epfd,再通过 epoll_ctl 将需要监视的 Socket 添加到 Epfd 中,最后调用 epoll_wait 等待数据:

int s = socket(AF_INET, SOCK_STREAM, 0);    
bind(s, ...) 
listen(s, ...) 
 
int epfd = epoll_create(...); 
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中 
 
while(1){ 
    int n = epoll_wait(...) 
    for(接收到数据的socket){ 
        //处理 
    } 
} 

措施二:就绪列表

Select 低效的另一个原因在于程序不知道哪些 Socket 收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的 Socket,就能避免遍历。

在这里插入图片描述

如上图所示,计算机共有三个 Socket,收到数据的 Sock2 和 Sock3 被就绪列表 Rdlist 所引用。

当进程被唤醒后,只要获取 Rdlist 的内容,就能够知道哪些 Socket 收到数据。

Epoll 的原理与工作流程

本节会以示例和图表来讲解 Epoll 的原理和工作流程。

创建 Epoll 对象

如下图所示,当某个进程调用 epoll_create 方法时,内核会创建一个 eventpoll 对象(也就是程序中 Epfd 所代表的对象)。

在这里插入图片描述

内核创建 eventpoll 对象, eventpoll 对象也是文件系统中的一员,和 Socket 一样,它也会有等待队列。创建一个代表该 Epoll 的 eventpoll 对象是必须的,因为内核要维护“就绪列表”等数据,“就绪列表”可以作为 eventpoll 的成员。

维护监视列表

创建 Epoll 对象后,可以用 epoll_ctl 添加或删除所要监听的 Socket。以添加 Socket 为例。

在这里插入图片描述

如上图,如果通过 epoll_ctl 添加 Sock1、Sock2 和 Sock3 的监视,

当 Socket 收到数据后,中断程序会操作 eventpoll 对象(个人理解: socket中应该有eventpoll的指针/引用,保障收到数据时,操作eventpoll),而不是直接操作进程。

接收数据

当 Socket 收到数据后,中断程序会给 eventpoll 的“就绪列表”添加 Socket 引用。

在这里插入图片描述

如上图展示的是 Sock2 和 Sock3 收到数据后,中断程序让 Rdlist 引用这两个 Socket(个人理解: 上图中的红色线箭头画反了)。

eventpoll 对象相当于 Socket 和进程之间的中介,Socket 的数据接收并不直接影响进程,而是通过改变 eventpoll 的就绪列表来改变进程状态。

当程序执行到 epoll_wait 时,如果 Rdlist 已经引用了 Socket,那么 epoll_wait 直接返回,如果 Rdlist 为空,阻塞进程。

阻塞和唤醒进程

假设计算机中正在运行进程 A 和进程 B,在某时刻进程 A 运行到了 epoll_wait 语句。

在这里插入图片描述

epoll_wait 阻塞进程, 如上图所示,内核会将进程 A 放入 eventpoll 的等待队列中,阻塞进程。

当 Socket 接收到数据,中断程序一方面修改 Rdlist,另一方面唤醒 eventpoll 等待队列中的进程,进程 A 再次进入运行状态(如下图)

在这里插入图片描述

也因为 Rdlist 的存在,进程 A 可以知道哪些 Socket 发生了变化。

总结:

epoll巧妙的引入一个中间层解决了大量监控socket的无效遍历问题。细心的同学会发现,epoll在中间层上为每个监控的socket准备了一个单独的回调函数epoll_callback_sk,正是这个单独的回调epoll_callback_sk使得每个socket都能单独处理自身,当自己就绪的时候将自身socket挂入epoll的ready_list。

同时,epoll引入了一个睡眠队列single_epoll_wait_list,分割了两类睡眠等待。process不再睡眠在所有的socket的睡眠队列上,而是睡眠在epoll的睡眠队列上,在等待”任意一个socket可读就绪”事件。

epoll数据结构

就绪列表的数据结构

就绪列表引用着就绪的 Socket,所以它应能够快速的插入数据。程序可能随时调用 epoll_ctl 添加监视 Socket,也可能随时删除。

当删除时,若该 Socket 已经存放在就绪列表中,它也应该被移除。所以就绪列表应是一种能够快速插入和删除的数据结构。

双向链表就是这样一种数据结构,Epoll 使用双向链表来实现就绪队列(对应上图的 Rdlist)。

索引结构

既然 Epoll 将“维护监视队列”和“进程阻塞”分离,也意味着需要有个数据结构来保存监视的 Socket,至少要方便地添加和移除,还要便于搜索,以避免重复添加。

红黑树是一种自平衡二叉查找树,搜索、插入和删除时间复杂度都是 O(log(N)),效率较好,Epoll 使用了红黑树作为索引结构。

注:因为操作系统要兼顾多种功能,以及有更多需要保存的数据,Rdlist 并非直接引用 Socket,而是通过 Epitem 间接引用,红黑树的节点也是 Epitem 对象。

同样,文件系统也并非直接引用着 Socket。为方便理解,本文中省略了一些间接结构。

3.3、ET(Edge Triggered 边沿触发) vs LT(Level Triggered 水平触发)

  • Edge Triggered (ET) 边沿触发

    • .socket的接收缓冲区状态变化时触发读事件,即空的接收缓冲区刚接收到数据时触发读事件
    • .socket的发送缓冲区状态变化时触发写事件,即满的缓冲区刚空出空间时触发写事件
  • Level Triggered (LT) 水平触发

    • .socket接收缓冲区不为空,有数据可读,则读事件一直触发
    • .socket发送缓冲区不满可以继续写入数据,则写事件一直触发

大家都认为ET模式更为高效,实际上是不是呢?下面我们来说说两种模式的本质:

遍历epollready_list,挨个调用每个skpoll逻辑收集发生的事件,挂在ready_list上的sk什么时候会被移除掉呢?其实,sk从ready_list移除的时机正是区分两种事件模式的本质。因为,通过上面的介绍,我们知道ready_list是否为空是epoll_wait是否返回的条件。

对于Edge Triggered (ET) 边沿触发:

遍历epoll的ready_list,将sk从ready_list中移除,然后调用该sk的poll逻辑收集发生的事件

对于Level Triggered (LT) 水平触发:

遍历epoll的ready_list,将sk从ready_list中移除,然后调用该sk的poll逻辑收集发生的事件
如果该sk的poll函数返回了关心的事件(对于可读事件来说,就是POLL_IN事件),那么该sk被重新加入到epoll的ready_list中。

ET模式下,如果某个socket有新的数据到达,那么该sk就会被排入epoll的ready_list,从而epoll_wait就一定能收到可读事件的通知,于是,我们通常理解的缓冲区状态变化(从无到有)的理解是不准确的,准确的理解应该是是否有新的数据达到缓冲区

在LT模式下,某个sk被探测到有数据可读,那么该sk会被重新加入到read_list,那么在该sk的数据被全部取走前,下次调用epoll_wait就一定能够收到该sk的可读事件,从而epoll_wait就能返回。

3.4、使用

epoll操作过程需要三个接口,分别如下:

#include <sys/epoll.h>
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
 int epoll_create(int size);

创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

epoll的事件注册函数,先注册要监听的事件类型。

  • 第一个参数是epoll_create()的返回值,

  • 第二个参数表示动作,用三个宏来表示:

    • EPOLL_CTL_ADD:注册新的fd到epfd中;

    • EPOLL_CTL_MOD:修改已经注册的fd的监听事件;

    • EPOLL_CTL_DEL:从epfd中删除一个fd;

  • 第三个参数是需要监听的fd

  • 第四个参数是告诉内核需要监听什么事,struct epoll_event结构如下:

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队列里

int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

等待事件的产生,类似于select()调用。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。

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

epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:

LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。

ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。

ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。

epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作,把处理多个文件描述符的任务饿死。

参考上位:

https://blog.csdn.net/armlinuxww/article/details/92803381

https://blog.csdn.net/u012662731/article/details/77334251

https://blog.csdn.net/freedom8531/article/details/39202673

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值