IO多路复用是一种网络通信的手段,通过这种方式可以同时监测多个文件描述符并且这个过程默认是阻塞的,一旦检测到有文件描述符就绪(可以读数据或者可以写数据或者出现异常)程序的阻塞就会被解除,之后就可以基于就绪的文件描述符进行通信了。通过这种方式在单线程/进程的场景下也可以在服务器端实现并发。在Linux上常见的IO多路复用方式有:

  1. select
  2. poll
  3. epoll模型

IO多路复用之select

  1. select:这个函数是跨平台的。小小的区别就是第一个参数,Windows上这个函数的第一参数只是为了与 Berkeley套接字兼容。不过为了统一,使用select函数的时候,第一参数也会和Linux一样,传递select待检测的文件描述符个数
    1. 函数原型:
    #include <sys/select.h>
    struct timeval {
        time_t      tv_sec;         /* seconds */
        suseconds_t tv_usec;        /* microseconds */
    };
    
    int select(int nfds, fd_set *readfds, fd_set *writefds,
               fd_set *exceptfds, struct timeval * timeout);
    
    函数参数:
        nfds:委托内核检测的这三个集合中最大的文件描述符 + 1,即被select待检测的描述符个数
            内核需要线性遍历这些集合中的文件描述符,这个值是循环结束的条件
            在 Window 中这个参数是无效的,指定为 - 1 即可
        readfds:文件描述符的集合,内核只检测这个集合中文件描述符对应的读缓冲区
            传入传出参数,读集合一般情况下都是需要检测的,这样才知道通过哪个文件描述符接收数据
        writefds:文件描述符的集合,内核只检测这个集合中文件描述符对应的写缓冲区
            传入传出参数,如果不需要使用这个参数可以指定为 NULL
        exceptfds:文件描述符的集合,内核检测集合中文件描述符是否有异常状态
            传入传出参数,如果不需要使用这个参数可以指定为 NULL
        timeout:超时时长,用来强制解除 select () 函数的阻塞的
            NULL:函数检测不到就绪的文件描述符会一直阻塞。
            等待固定时长(秒):函数检测不到就绪的文件描述符,在指定时长之后强制解除阻塞,函数返回 0
            不等待:函数不会阻塞,直接将该参数对应的结构体初始化为 0 即可
    函数返回值:
        大于 0:成功,返回集合中已就绪的文件描述符的总个数
        等于 - 1:函数调用失败
        等于 0:超时,没有检测到就绪的文件描述符
    
    1. 注意,如果select在待检测的文件描述符集合中没有检测到对应的就绪事件,则会将文件描述符对应的标志位从1又设置为0。如果需要进行下一轮检测,则需要使用初始化fd_set类型参数的相关宏重新设置。初始化fd_set类型参数的相关宏如下所示:
    // 将文件描述符fd从set集合中删除 == 将fd对应的标志位设置为0        
    void FD_CLR(int fd, fd_set *set);
    // 判断文件描述符fd是否在set集合中即 读一下fd对应的标志位到底是0还是1
    int  FD_ISSET(int fd, fd_set *set);
    // 将文件描述符fd添加到set集合中 == 将fd对应的标志位设置为1
    void FD_SET(int fd, fd_set *set);
    // 将set集合中, 所有文件文件描述符对应的标志位设置为0
    void FD_ZERO(fd_set *set);
    
  2. 事件就绪的含义:
    1. 读事件就绪:
      1. 侦听 socket 上有新的连接请求
      2. socket 上有未处理的错误。比如说关闭了套接字
      3. TCP连接的对端关闭连接,此时调用recv或read函数对该socket读返回0
      4. socket内核中,接收缓冲区中的字节数大于等于低水位标记SO_RCVLOWAT,此时调用recv或read 函数可以无阻塞的读该文件描述符, 并且返回值大于 0
    2. 写事件就绪:
      1. socket内核中,发送缓冲区中的可用字节数大于等于低水位标记SO_SNDLOWAT,此时可以无阻塞的写, 并且返回值大于 0;
      2. socket使⽤非阻塞connect连接成功或失败时
    3. 异常事件就绪:比如套接字收到带外数据
  3. 使用select这种IO多路复用技术的优缺点:
    1. 优点:
      1. 与多进程、多线程技术相比,select系统调用不必创建进程或者说线程,也不必维护这些进程、线程,从而减少了系统的开销。
    2. 缺点:
      1. 待检测集合(第234个参数)需要频繁的在用户区和内核区之间进行数据的拷贝,效率低
      2. 内核对于select传递进来的待检测集合的检测方式是线性的。如果集合内待检测的文件描述符很多,检测效率会比较低;如果集合内待检测的文件描述符相对较少,检测效率会比较高。
      3. 使用select能够检测的最大文件描述符个数有上限,默认是1024,这是在内核中被写死了的。

IO多路复用之poll

  1. poll和select的对比:
    1. 两者在函数返回后,都需要遍历fd集合来获取就绪的fd。如果fd集合元素多则性能下降。
    2. poll和select检测的文件描述符集合会在检测过程中频繁的进行用户区和内核区的拷贝,它的开销随着文件描述符数量的增加而线性增大,从而效率也会越来越低。
    3. select检测的文件描述符个数上限是1024,poll没有最大文件描述符数量的限制,因为底层使用的链表保存文件描述符
    4. select可以跨平台使用,poll只能在Linux平台使用
  2. poll函数原型如下:
#include <poll.h>
// 每个委托poll检测的一个fd都对应这样一个结构体
struct pollfd {
    int   fd;         /* 委托内核检测的文件描述符 */
    short events;     /* 委托内核检测文件描述符的什么事件 */
    short revents;    /* 文件描述符实际发生的事件 -> 传出参数 */
};

struct pollfd myfd[100];
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

函数参数:
    fds:这是一个struct pollfd类型的数组,里边存储了待检测的文件描述符的信息,这个数组中有三个成员:
        fd:委托内核检测的文件描述符
        events:委托内核检测的事件(输入、输出、错误),每一个事件有多个取值
        revents:这是一个传出参数,数据由内核写入,存储内核检测之后的结果
    nfds:这是第一个参数数组中最后一个有效元素的下标 + 1
    timeout: 指定 poll 函数的阻塞时长
        -1:一直阻塞,直到检测的集合中有就绪的文件描述符(有事件产生)解除阻塞
        0:不阻塞,不管检测集合中有没有已就绪的文件描述符,函数马上返回
        大于 0:阻塞指定的毫秒(ms)数之后,解除阻塞
函数返回值:
    失败: 返回 - 1
    成功:返回一个大于 0 的整数,表示检测的集合中已就绪的文件描述符的总个数
  1. pollfd结构体的events参数可以传递的事件如下:
事件宏 事件描述 是否可以作为输入(events) 是否可以作为输出(revents)
POLLIN 数据可读(包括普通数据&优先数据)
POLLOUT 数据可写(普通数据&优先数据)
POLLRDNORM 普通数据可读
POLLRDBAND 优先级带数据可读(一般用于 Linux 系统)
POLLPRI 高优先级数据可读,例如 TCP 带外数据
POLLWRNORM 普通数据可写
POLLWRBAND 优先级带数据可写
POPPHUP 挂起
POLLERR 错误
POLLNVAL 文件描述符没有打开

IO多路复用之epoll模型

1.epoll模型相关函数
  1. epoll全称eventpoll,是Linux内核实现IO多路复用的实现之一。epoll是select和poll的升级版本,他改进了工作方式。
    1. 对于待检测集合select和poll是基于线性方式处理的,epoll是基于红黑树来管理待检测集合的。
    2. select和poll每次都会线性扫描整个待检测集合,集合越大速度越慢,epoll使用的是回调机制,效率高,处理效率也不会随着检测集合的变大而下降
    3. 程序猿需要对select和poll返回的集合进行判断才能知道哪些文件描述符是就绪的,通过epoll可以直接得到已就绪的文件描述符集合,无需再次检测
    4. 使用 epoll 没有最大文件描述符的限制,仅受系统中进程能打开的最大文件描述符数目限制
  2. epoll相关的函数
    1. 创建epoll实例,通过一棵红黑树管理待检测集合
    #include <sys/epoll.h>
    // 创建epoll实例,通过一棵红黑树管理待检测集合
    int epoll_create(int size);
    函数参数:
        size:在 Linux内核2.6.8版本以后,这个参数是被忽略的,只需要指定一个大于 0 的数值就可以了。
    函数返回值:
        失败:-1
        成功:返回一个有效的文件描述符,通过这个文件描述符就可以访问创建的 epoll 实例
    
    1. 管理红黑树上的文件描述符,包括添加、删除、修改
    typedef union epoll_data
    {
      void *ptr;
      int fd; // 通常使用这个成员,用于存储待检测的文件描述符的值
      uint32_t u32;
      uint64_t u64;
    } epoll_data_t;
    
    结构体变量
        events:委托 epoll 检测的事件
            EPOLLIN:读事件,接收数据,检测读缓冲区,如果有数据该文件描述符就绪
            EPOLLOUT:写事件,发送数据,检测写缓冲区,如果可写该文件描述符就绪
            EPOLLERR:异常事件
    struct epoll_event
    {
      uint32_t events;	/* Epoll events */
      epoll_data_t data;	/* User data variable */
    }
    
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    函数参数:
        epfd:epoll_create () 函数的返回值,通过这个参数找到 epoll 实例
        op:这是一个枚举值,控制通过该函数执行什么操作
            EPOLL_CTL_ADD:往 epoll 模型中添加新的节点
            EPOLL_CTL_MOD:修改 epoll 模型中已经存在的节点
            EPOLL_CTL_DEL:删除 epoll 模型中的指定的节点
        fd:文件描述符,即要添加 / 修改 / 删除的文件描述符
        event:epoll事件,用来修饰第三个参数对应的文件描述符的,指定检测这个文件描述符的什么事件
    函数返回值:
        失败:返回-1
        成功:返回0
    
    1. 检测epoll树中是否有就绪的文件描述符
    int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
    函数参数:
        epfd:epoll_create () 函数的返回值,通过这个参数找到 epoll 实例
        events:传出参数,这是一个结构体数组的地址,里边存储了已就绪的文件描述符的信息
        maxevents:修饰第二个参数,结构体数组的容量(元素个数)
        timeout:如果检测的epoll实例中没有已就绪的文件描述符,该函数阻塞的时长,单位毫秒
            0:函数不阻塞,不管epoll实例中有没有就绪的文件描述符,函数被调用后都直接返回
            大于0:如果epoll实例中没有已就绪的文件描述符,函数阻塞对应的毫秒数再返回
            -1:函数一直阻塞,直到epoll实例中有已就绪的文件描述符之后才解除阻塞
    函数返回值:
        成功:
            等于 0:函数阻塞被强制解除了,没有检测到满足条件的文件描述符
            大于 0:检测到的已就绪的文件描述符的总个数
        失败:返回-1。此时需要判断错误码,错误码为EINTR表示被信号中断
    
2.epoll的两种工作模式

epoll包含两种工作模式:水平模式和边缘模式。

  1. epoll的工作模式之水平模式:水平模式可以简称为LT模式,LT(level triggered)是默认的工作方式,并且同时支持block和no-block socket。在这种做法中,内核通知使用者哪些文件描述符已经就绪,之后就可以对这些已就绪的文件描述符进行IO操作了。如果我们不作任何操作,内核还是会继续通知使用者。水平模式的特点如下:
    1. 读事件:如果文件描述符对应的读缓冲区还有数据,读事件就会一直被触发,epoll_wait () 解除阻塞
      1. 当读事件被触发,epoll_wait () 解除阻塞,之后就可以调用recv或者read函数接收数据了
      2. 如果接收数据的buf很小,不能全部将缓冲区数据读到用户缓冲区,那么读事件会继续被触发,直到数据被全部读出,如果接收数据的内存相对较大,读数据的效率也会相对较高(减少了读数据的次数)
      3. 因为读数据是被动的,必须要通过读事件才能知道有数据到达了,因此对于读事件的检测是必须的
    2. 写事件:如果文件描述符对应的写缓冲区可写,写事件就会一直被触发,epoll_wait () 解除阻塞
      1. 当写事件被触发,epoll_wait()解除阻塞,之后就可以将数据写入到写缓冲区了
      2. 写事件的触发发生在写数据之前而不是之后,被写入到写缓冲区中的数据是由内核自动发送出去的
      3. 如果写缓冲区没有被写满,写事件会一直被触发
      4. 因为写数据是主动的,并且写缓冲区一般情况下都是可写的(缓冲区不满),因此对于写事件的检测不是必须的
  2. epoll的工作模式之边缘模式:边缘模式(edge-triggered)可以简称为ET模式,只支持no-blocksocket。在这种模式下,当文件描述符从未就绪变为就绪时,内核会通过epoll通知使用者。然后它会假设使用者知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知(only once)。如果我们对这个文件描述符做IO操作,从而导致它再次变成未就绪,当这个未就绪的文件描述符再次变成就绪状态,内核会再次进行通知,并且还是只通知一次。ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率通常来说要比LT模式高。
    1. 边沿模式的特点如下:
      1. 读事件:当读缓冲区有新的数据进入,读事件被触发一次,没有新数据不会触发该事件
        1. 如果有新数据进入到读缓冲区,读事件被触发,epoll_wait()解除阻塞
        2. 读事件被触发,可以通过调用 read()/recv() 函数将缓冲区数据读出
          1. 如果数据没有被全部读走,并且没有新数据进入,读事件不会再次触发,只通知一次。因此需要一次性将触发可读事件的套接字的内核接收缓冲区的数据全部接收完毕
          2. 如果数据被全部读走或者只读走一部分,此时有新数据进入,读事件被触发,并且只通知一次
      2. 写事件:当写缓冲区状态可写,写事件只会触发一次
        1. 如果写缓冲区被检测到可写,写事件被触发,epoll_wait()解除阻塞
        2. 写事件被触发,就可以通过调用write()/send()函数,将数据写入到写缓冲区中
          1. 写缓冲区从不满到被写满,期间写事件只会被触发一次
          2. 写缓冲区从满到不满,状态变为可写,写事件只会被触发一次
    2. ET模式的设置:将EPOLLET添加到epoll_event结构体的events成员即可。
    3. 边缘触发模式下,需要将套接字设置为非阻塞模式:由于边缘模式进行读事件的检测,有新数据到达只会通知一次,那么必须要保证得到通知后将数据全部从读缓冲区中读出。有如下方式将这些数据全部读出:
      1. 准备一块特别大的内存,用于存储从读缓冲区中读出的数据。缺点是:
        1. 内存的大小没有办法界定,太大浪费内存,太小又不够用
        2. 系统能够分配的最大堆内存以及栈内存都是有上限的
      2. 在非阻塞模式下,循环地将读缓冲区数据读到本地内存中,当缓冲区数据被读完了,调用的read()/recv()函数还会继续从缓冲区中读数据,此时函数调用就会返回 - 1,对应的全局变量 errno 值为 EAGAIN 或者 EWOULDBLOCK,表示数据读取完毕,示例代码如下:
      bool recv(int clientfd) {
      	char buf[256];
      	while (true) {
      		memset(buf, 0, 256);
      		int nRecv = recv(clientfd, buf, 256, 0);
      		if (nRecv == -1) {
      			if (errno == EWOULDBLOCK) {
      				return true;
      			} else if (errno == EINTR) {
      				continue;
      			}
      			return false;
      		} else if (nRecv == 0) {	// 对端关闭了连接
      			return false;
      		}
      
      		// 处理buf
      
      	}
      	return true;
      
      }
      
3.两种工作模式的区别
  1. 水平工作模式:
    1. 可读事件触发条件
    1. socket上无数据 => socket上有数据
    2. socket处于有数据状态
    
    1. 可写事件触发条件
    1. socket可写   => socket可写
    2. socket不可写 => socket可写
    
  2. 边缘工作模式:
    1. 可读事件触发条件
    1. socket上无数据 => socket上有数据
    2. socket又新来一次数据
    
    1. 可写事件触发条件
    1. socket不可写 => socket可写
    
4.epoll模型收发数据的正确姿势

服务端开发中,使用epoll模型通常会将侦听套接字以及普通套接字都设置为非阻塞的。

  1. 收数据:对于侦听套接字listenfd,只处理它的读事件。当可读事件触发后,调用accept函数接收连接,产生一个clientfd,并将clientfd设置为非阻塞的。然后将clientfd挂载到epollfd上,挂载时处理clientfd的读事件。普通socket clientfd可读事件触发后,表明该clientfd上有读事件。此时需要调用recv函数进行收据收取工作,如果recv函数返回0,说明对端关闭了连接,我们需要调用epoll_ctl函数将该clientfd从epollfd上卸载,并关闭该clientfd。如果recv函数返回值大于0,说明正常收到了数据,此时将数据存储起来,然后根据协议格式进行解包,并进一步做业务处理。对于这种情况,epoll的两种工作模式会有不同的表现。在水平模式下,可以按需收取想要的字节数目,因为即使本次不能够将内核缓冲区数据全部读到用户缓冲区,该socket的读事件会继续触发;在边缘模式下,必须在循环中调用recv或者read函数将当前socket上的数据收完,直到函数的返回值为-1,错误码为EWOULDBLOCK。
  2. 发数据:通常socket都是可写的,无须第一次就注册可写事件。因此对于发送数据,直接调用send或者write函数发送数据,如果send函数返回值为-1,错误码是EWOULDBLOCK表明当前缓冲区已满,数据无法发出。此时需要注册一次检测可写事件,调用epoll_ctl给挂载到epollfd上的clientfd追加写事件检测;如果send函数返回值为-1,错误码为EPIPE,表示写端关闭。LT模式下,不需要写数据时写事件一定要及时移除,避免不必要的触发,浪费CPU资源;ET模式下,写事件触发后,如果还需要下一次的写事件触发来发上次剩余的数据,需要继续注册一次检测可写事件。
5.epoll模型一定比select、poll高效吗
  1. 在socket连接数比较多且活跃连接数比较少时,使用epoll模型会比较高效。
  2. 在socket连接数很多,且活跃连接数多的情况下使用select。