I/O多路转接(多路复用)

一.什么是I/O多路复用

      I/O多路复用是一种在单个线程(进程)中管理多个输入/输出通道(文件描述符)的技术 ,本质是使用select,poll或者epoll函数,挂起进程,当一个或者多个I/O事件发生之后,将控制返回给用户进程。以服务器编程为例,传统的多进程(多线程)并发模型,在处理用户连接时都是开启一个新的线程或者进程去处理一个新的连接,而I/O多路复用则可以在一个进程(线程)当中同时监听多个网络I/O事件,也就是多个文件描述符。select、poll 和 epoll 都是 Linux API 提供的 IO 复用方式。多路是指网络连接,复用指的是同一个线程(进程)

二.I/O多路转接之select

        select系统调用是用来让我们的程序监视多个文件描述符的状态变化的; 程序会停在select这里等待,直到被监视的文件描述符有一个或多个发生了状态改变;

        函数原型:

  int select(int nfds, fd_set *readfds, fd_set *writefds,
              fd_set *exceptfds, struct timeval *timeout);

                首先我们在函数原型中看到一个fd_set,这个是啥,没见过? 

                       fd_set是内核提供的一种数据类型,其本质是long int类型的数组,又以位图的形式,每一位可以代表一个文件描述符。所以fd_set最多表示1024个文件描述符!。其作用就是:让用户和内核之间相互传递fd是否准备就绪

提供了一组操作fd_set的接口, 来比较方便的操作位图: 

void FD_CLR(int fd, fd_set *set); // 用来清除描述词组set中相关fd 的位

int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组set中相关fd 的位是否为真

void FD_SET(int fd, fd_set *set); // 用来设置描述词组set中相关fd的位 void FD_ZERO(fd_set *set); // 用来清除描述词组set的全部位

        函数参数解释:

1.参数nfds是需要监视的最大的文件描述符值+1; 

2.readfds,一个输入输出型参数。输入时:用户告诉内核,我给你几个fd,你帮我关心一下读事件,要是就绪了告诉我。比如:你帮我关心一下文件描述符6,7,那么readfds的比特位从右往左数,第6,和第7个由0变1;输出时:内核告诉用户,你叫我关心的fd有哪些准备就绪了,你快拿去处理。比如:文件描述符6就绪了,则readfds输出时为0100 0000.如果不关心 可以传NULL

3.writefds 同上.

4.exceptfds,用来监视文件错误或异常文件,将这类文件描述符放入这里面,等待检测错误接口。如果不关心 可以传NULL

5.*timeout。参数timeout为结构timeval,用来设置select()的等待时间。NULL:则表示select()没有timeout,select将一直被阻塞,直到某个文件描述符上发生了事件; 0:仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生。 特定的时间值:如果在指定的时间段里没有事件发生,select将超时返回。

关于timeval结构

timeval结构用于描述一段时间长度,如果在这个时间内,需要监视的描述符没有事件发生则函数返回,返回值为 0;

函数返回值:

1.执行成功则返回文件描述词状态已改变的个数

2.如果返回0代表在描述词状态改变前已超过timeout时间,没有返回

3.当有错误发生时则返回-1,错误原因存于errno,此时参数readfds,writefds, exceptfds和timeout的 值变成不可预测。 (错误值可能为: EBADF 文件描述词为无效的或该文件已关闭 EINTR 此调用被信号所中断 EINVAL 参数n 为负值。 ENOMEM 核心内存不足)

select 实现原理:

参考:I/O多路复用之select - zengzy - 博客园 (cnblogs.com)

        select的实现依赖于设备的驱动函数poll,poll的功能是检查设备的哪条条流可用(一个设备一般有三条流,读流,写流,设备发生异常的异常流),如果其中一条流可用,返回一个mask(表示可用的流),如果不可用,把当前进程加入设备的流等待队列中,例如读等待队列、写等待队列,并返回资源不可用。

  select正是利用了poll的这个功能,首先让程序员告知自己关心哪些io流(用文件描述符表示,也就是上文的readfds、writefds和exceptfds),并让程序员告知自己这些流的范围(也就是上文的nfds参数)以及程序的容忍度(timeout参数),然后select会把她们拷贝到内核,在内核中逐个调用流所对应的设备的驱动poll函数,当范围内的所有流也就是描述符都遍历完之后,他会检查是否至少有一个流发生了,如果有,就修改那三个流集合,把她们清空,然后把发生的流加入到相应的集合中,并且select返回。如果没有,就睡眠,让出cpu,直到某个设备的某条流可用,就去唤醒阻塞在流上的进程,这个时候,调用select的进程重新开始遍历范围内的所有描述符。

     select用于监控socket的状态变化,当socket从不就绪状态变为就绪状态时,select会通知程序进行相应的处理。那么socket怎样算就绪呢? 

  • 1、拷贝nfds、readfds、writefds和exceptfds到内核
  • 2、遍历[0,nfds)范围内的每个流,调用流所对应的设备的驱动poll函数
  • 3、检查是否有流发生,如果有发生,把流设置对应的类别,并执行4,如果没有流发生,执行5。或者timeout=0,执行4
  • 4、select返回
  • 5、select阻塞当前进程,等待被流对应的设备唤醒,当被唤醒时,执行2。或者timeout到期,执行4

 

socket 就绪条件:

读就绪:

1.socket内核中, 接收缓冲区中的字节数, 大于等于低水位标记SO_RCVLOWAT. 此时可以无阻塞的读该文件 描述符, 并且返回值大于0;

2.socket TCP通信中, 对端关闭连接, 此时对该socket读, 则返回0;

3.监听的socket上有新的连接请求; socket上有未处理的错误;

写就绪:

1.socket内核中, 发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小), 大于等于低水位标记 SO_SNDLOWAT, 此时可以无阻塞的写, 并且返回值大于0;

2.socket的写操作被关闭(close或者shutdown). 对一个写操作被关闭的socket进行写操作, 会触发SIGPIPE 信号;

3.socket使用非阻塞connect连接成功或失败之后; socket上有未读取的错误;

异常就绪:

socket上收到带外数据. 关于带外数据, 和TCP紧急模式相关(回忆TCP协议头中, 有一个紧急指针的字段)

select使用示例: 检测标准输入输出 

 #include <stdio.h>
 #include <unistd.h>
 #include <sys/select.h>
 
int main() {
  fd_set read_fds;
  FD_ZERO(&read_fds);
  FD_SET(0, &read_fds);
  for (;;) {
    printf("> ");
    fflush(stdout);
    int ret = select(1, &read_fds, NULL, NULL, NULL);
    if (ret < 0) {
      perror("select");
      continue;
    }
    if (FD_ISSET(0, &read_fds)) {
      char buf[1024] = {0};
      read(0, buf, sizeof(buf) - 1);
      printf("input: %s", buf);
    } else {
      printf("error! invaild fd\n");
      continue;
    }
    FD_ZERO(&read_fds);
    FD_SET(0, &read_fds);
  }
  return 0;
 }
//当只检测文件描述符0(标准输入)时,因为输入条件只有在你有输入信息的时候,才成立,所以如果
//一直不输入,就会产生超时信息。

        select特点: 
        可监控的文件描述符个数取决与sizeof(fd_set)的值.

        将fd加入select监控集的同时,还要再使用一个数据结构array保存放到select监控集中的fd:

一.是用于再select 返回后,array作为源数据和fd_set进行FD_ISSET判断。

二.是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得 fd逐一加入(FD_ZERO最先),扫描array的同时取得fd最大值maxfd,用于select的第一个参数。

 select机制的缺点    

1.每次调用select, 都需要手动设置fd集合, 从接口使用角度来说也非常不便.

2.每次调用select,因为输入输出型参数比较多,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大

3.同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大

4.select支持的文件描述符数量太小

三.I/O多路转接之poll

        函数原型:

#include <poll.h>
 int poll(struct pollfd *fds, nfds_t nfds, int timeout);
 // pollfd结构
struct pollfd {
   int   fd;         /* file descriptor */
   short events;     /* requested events */
   short revents;    /* returned events */
 };

        参数说明: 

1.fds是一个poll函数监听的结构列表. 每一个元素中, 包含了三部分内容: 文件描述符, 监听的事件集合, 返回的事件集合.

  • fd属性表示一个打开的文件描述符
  • events属性是一个输入参数,通过bit mask的方式描述程序感兴趣的事件(读、写)
  • revents属性是一个传出参数,同样式通过bit mask的方式描述发生的事件,这个属性的值是由内核设置的。revents的值可能是events属性的值,也可能是POLLERR,POLLHUP,POLLNVAL的一个或多个,POLLERR,POLLHUP,POLLNVAL在events属性中是没有意义的。

2.nfds表示fds数组的长度.

3.timeout表示poll函数的超时时间, 单位是毫秒(ms). 

  • 0,poll函数不阻塞
  • 整数,阻塞timeout时间
  • 负数,无限阻塞

        从pollfd结构体成员我们可以看到,poll将输入/输出事件经行了分离。 而在select中,输入输出都是同一参数(如:*writerfds),每次调用select时,都要重新传参。

        events 和 revents能够设置的值都定义在<poll.h>头中,有以下几种可能:

函数返回结果 :

返回值小于0, 表示出错;

返回值等于0, 表示poll函数等待超时;

返回值大于0, 表示poll由于监听的文件描述符就绪而返回.

socket的就绪条件和select的一样。

poll的优/缺点:

优点:

        1.pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式. 接口使用比 select更方便

        2.poll并没有最大数量限制 (但是数量过大后性能也是会下降),原因是它是基于链表来存储的。

缺点:

        1.和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符.

        2. 每次调用poll都需要把大量的pollfd结构从用户态拷贝到内核中.

        3. 同时连接的大量客户端在一时刻可能只有很少的处于就绪状态, 因此随着监视的描述符数量的增长, 其效率也会线性下降.

poll的简单使用: 

        

 #include <poll.h>
 #include <unistd.h>
 #include <stdio.h>
 
int main() {
  struct pollfd poll_fd;
  poll_fd.fd = 0;
  poll_fd.events = POLLIN;
  
  for (;;) {
    int ret = poll(&poll_fd, 1, 1000);
    if (ret < 0) {
      perror("poll");
      continue;
    }
    if (ret == 0) {
      printf("poll timeout\n");
      continue;
    }
    if (poll_fd.revents == POLLIN) {
      char buf[1024] = {0};
      read(0, buf, sizeof(buf) - 1);
      printf("stdin:%s", buf);
    }
  }
 }

四.I/O多路转接之epoll 

         epoll的相关系统调用

         epoll_create:

int epoll_create(int size); 

        用于创建一个新的 epoll 实例的系统调用。这个实例是一个特殊的文件描述符,用于高效地管理多个文件描述符(如套接字)上的 I/O 事件。参数 size 在较老的 Linux 内核版本中,曾经用来告诉内核这个 epoll 实例所管理的文件描述符的数量上限。然而,从 Linux 2.6.8 版本开始,这个参数被忽略,因为内核动态地管理这个大小。因此,在实际使用中,可以将 size 设置为 0。

        epoll_ctl

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

它不同于select()是在监听事件时告诉内核要监听什么类型的事件, 而是在这里先注册要监听的事件类型.

第一个参数是epoll_create()的返回值(epoll的句柄).

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

        第二个参数的取值:  EPOLL_CTL_ADD :注册新的fd到epfd中;

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

                                        EPOLL_CTL_DEL :从epfd中删除一个fd;

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

第四个参数是告诉内核需要监听什么事。

        struct epoll_event结构如下:

events可以是以下几个宏的集合:

EPOLLIN : 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭);

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

EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来); EPOLLERR : 表示对应的文件描述符发生错误;

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

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

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

        epoll_wait:

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

收集在epoll监控的事件中已经发送的事件.

1.参数events是分配好的epoll_event结构体数组.

2.epoll将会把发生的事件赋值到events数组中 (events不可以是空指针,内核只负责把数据复制到这个 events数组中,不会去帮助我们在用户态中分配内存).

3.maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size.

4.参数timeout是超时时间 (毫秒,0会立即返回,-1是永久阻塞). 如果函数调用成功,返回对应I/O上已准备好的文件描述符数目,如返回0表示已超时, 返回小于0表示函 数失败


epllo工作原理


         step1.当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体eventpoll对象也是文件系统中的一员,和socket一样,它也会有等待队列。每一个epoll对象都有一个独立的eventpoll结构体。

其中:/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/ struct rb_root rbr; /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/ struct list_head rdlist;

 step2.通过epoll_ctl方法向epoll对象中添加进来的事件.这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插 入时间效率是lgn,其中n为树的高度).所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当响应的事件发生时 会调用这个回调方法. 这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中. 在epoll中,对于每一个事件,都会建立一个struct epitem结构体(也就是说红黑树和双链表中存的就是struct epitem结构体)。

step3.当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem 元素即可. 如果rdlist不为空,则把发生的事件复制到用户态,用户态就可以拿到epitem 元素,然后epitem 元素里面有发生事件的fd,就可以对事件处理了。同时将事件数量返回给用户. 这个操作的时间复杂度 是O(1).

epoll需要在用户态和内核态拷贝数据吗?

        epoll是需要在用户态和内核态拷贝数据的,只不过相比select和poll而言,拷贝次数较少。

  • select/poll两次拷贝:在select和poll的实现中,文件描述符集合(fds)通常需要从用户态拷贝到内核态,以便内核检查哪些文件描述符有事件发生。当内核检查完毕后,如果有事件发生,它还需要将结果(哪些文件描述符有事件发生)从内核态拷贝回用户态。
  • epoll一次拷贝:epoll在注册事件时(通过epoll_ctl),会将需要关心的文件描述符和事件类型从用户态拷贝到内核态,并存储在内核的数据结构中(如红黑树)。而用户态这是通过就绪队列拿到将发生的事件。

epllo优点

1.接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文件描述符, 也做到了输入输出参数分离开

2.数据拷贝轻量: 只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中, 这个操作并不频 繁(而select/poll都是每次循环都要进行拷贝)

3.事件回调机制: 避免使用遍历, 而是使用回调函数的方式, 将就绪的文件描述符结构加入到就绪队列中, epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度O(1). 即使文件描述 符数目很多, 效率也不会受到影响.

4.没有数量限制: 文件描述符数目无上限

epoll工作方式 

        水平触发Level Triggered 工作模式 。epoll默认状态下就是LT工作模式.比如:当epoll检测到socket上事件就绪的时候, 可以不立刻进行处理. 或者只处理一部分.

        边缘触发Edge Triggered工作模式。将socket添加到epoll描述符的时候使用了EPOLLET标志, epoll进入ET工作模式.比如:当epoll检测到socket上事件就绪时, 必须立刻处理.

select和poll其实也是工作在LT模式下. epoll既可以支持LT, 也可以支持ET.

        ET和LT对比:

LT是 epoll 的默认行为. 使用 ET 能够减少 epoll 触发的次数. 但是代价就是强逼着程序猿一次响应就绪过程中就把 所有的数据都处理完. 相当于一个文件描述符就绪之后, 不会反复被提示就绪, 看起来就比 LT 更高效一些. 但是在 LT 情况下如果也能做到 每次就绪的文件描述符都立刻处理, 不让这个就绪被重复提示的话, 其实性能也是一样的. 另一方面, ET 的代码复杂程度更高了。ET只支持非阻塞的读写,LT是支持阻塞读写和非阻塞读写;

        ET和LT如何实现? 

et(边沿触发)模式下:
触发完一次后,设置一个flag,即使recvbuf不为空,也不会被触发;
 
lt(水平触发)模式下:
触发完一次后,如果recvbuf不为空,就会一直触发;
 
et和lt是通过回调函数次数实现的:
if(触发过一次)
{
  if(buf不为空 && lt模式 )
  {
      继续触发;
  }
}

epoll的使用场景 

        epoll的高性能, 是有一定的特定场景的. 如果场景选择的不适宜, epoll的性能可能适得其反。例如, 典型的一个需要处理上万个客户端的服务器, 例如各种互联网APP的入口服务器, 这样的服务器就很适合epoll. 如果只是系统内部, 服务器和服务器之间进行通信, 只有少数的几个连接, 这种情况下用epoll就并不合适. 具体要根 据需求和场景特点来决定使用哪种IO模型。

epoll惊群问题

        在多线程或者多进程环境下,有些人为了提高程序的稳定性,往往会让多个线程或者多个进程同时在epoll_wait监听的socket描述符。当一个新的链接请求进来时,操作系统不知道选派那个线程或者进程处理此事件,则干脆将其中几个线程或者进程给唤醒,而实际上只有其中一个进程或者线程能够成功处理accept事件,其他线程都将失败,且errno错误码为EAGAIN。这种现象称为惊群效应,结果是肯定的,惊群效应肯定会带来资源的消耗和性能的影响。

解决办法:

        不建议让多个线程同时在epoll_wait监听的socket,而是让其中一个线程epoll_wait监听的socket,当有新的链接请求进来之后,由epoll_wait的线程调用accept,建立新的连接,然后交给其他工作线程处理后续的数据读写请求,这样就可以避免了由于多线程环境下的epoll_wait惊群效应问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值