IO系列3-详解IO多路复用(select、poll、epoll)

6,695 阅读22分钟

欢迎大家关注 github.com/hsfxuebao ,希望对大家有所帮助,要是觉得可以的话麻烦给点一下Star哈

1.重要概念

1.1 IO多路复用

I/O多路复用在英文中其实叫 I/O multiplexing。就是我们说的select,poll,epoll,有些地方也称这种IO方式为event driven IO事件驱动IO。就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。可以基于一个阻塞对象,同时在多个描述符上等待就绪,而不是使用多个线程(每个文件描述符一个线程,每次new一个线程),这样可以大大节省系统资源。所以,I/O 多路复用的特点是 通过一种机制一个进程能同时等待多个文件描述符而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。详见IO系列2-深入理解五种IO模型
I/O multiplexing 这里面的 multiplexing 指的其实是在单个线程通过记录跟踪每一个Sock(I/O流)的状态(对应空管塔里面的Fight progress strip槽)来同时管理多个I/O流. 发明它的原因,是尽量多的提高服务器的吞吐能力。

是不是听起来好拗口,看个图就懂了 image.png
大家都用过nginx, nginx使用epoll接收请求,ngnix会有很多链接进来, epoll会把他们都监视起来,然后像拨开关一样,谁有数据就拨向谁,然后调用相应的代码处理。redis类似同理。

1.2 文件描述符

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。如下:

/*
 * @author  Pavani Diwanji
 * @see     java.io.FileInputStream
 * @see     java.io.FileOutputStream
 * @since   JDK1.0
 */
public final class FileDescriptor {

    private int fd;

    private Closeable parent;
    private List<Closeable> otherParents;
    private boolean closed;
}

2. Linux实现IO多路复用函数

所谓 I/O 多路复用机制指内核一旦发现进程指定的一个或者多个IO条件准备读取,它就通知该进程,就是说通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作。这种机制的使用需要 select 、 poll 、 epoll 来配合。 

  • 多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。 
  • 当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。

2.1 select函数

select是第一个实现IO多路复用 (1983 左右在BSD里面实现)。select是三者当中最底层的,它的事件的轮训机制是基于比特位的。每次查询都要遍历整个事件列表。

2.1.1 数据结构和参数定义

理解select,首先要理解select要处理的fd_set数据结构,每个select都要处理一个fd_set结构。fd_set数据结构如下:

typedef long int __fd_mask;

/* fd_set for select and pselect.  */
typedef struct
  {
#ifdef __USE_XOPEN
    __fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->fds_bits)
#else
    __fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->__fds_bits)
#endif
  } fd_set;

fd_set简单地理解为一个长度是1024的比特位,每个比特位表示一个需要处理的FD(File descriptor),如果是1,那么表示这个FD有需要处理的I/O事件,否则没有。Linux为了简化位操作,定义了一组宏函数来处理这个比特位数组。

void FD_CLR(int fd, fd_set *set);     // 清空fd在fd_set上的映射,说明select不在处理该fd
int  FD_ISSET(int fd, fd_set *set);   // 查询fd指示的fd_set是否是有事件请求
void FD_SET(int fd, fd_set *set);     // 把fd指示的fd_set置1
void FD_ZERO(fd_set *set);            // 清空整个fd_set,一般用于初始化

从上述可以看出,select能处理fd最大的数量是1024,这是由fd_set的容量决定的。

再看select的调用方式:select函数详细参数文档

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);
  • nfds:表示表示文件描述符最大的数目+1,这个数目是指读事件和写事件中数目最大的,+1是为了全面检查
  • readfds:表示需要监视的会发生读事件的fd,没有设置为NULL
  • writefds:表示需要监视的会发生写事件的fd,没有设置为NULL
  • exceptfds:表示异常处理的,暂时没用到。。。
  • timeout:表示阻塞的时间,如果是0表示非阻塞模型,NULL表示永远阻塞,直到有数据来
    • timeval定义如下:
      struct timeval {
         long    tv_sec;         /* seconds */
         long    tv_usec;        /* microseconds */
      };
      
    • timeval *timeout有三种情况:
      1. NUll,永远等下去
      2. 设置timeval,等待固定时间
      3. 设置timeval都为0,检查描述字后立即返回,轮询

select 函数有三个类型的返回值:

  • 正数: 表示readfdswritefds就绪事件的总数
  • 0:超时返回0
  • -1:出现错误 select 函数调用过程如下:
  • select 函数监视的文件描述符分3类,分别是readfds、writefds和exceptfds,将用户传入的数组拷贝到内核空间
  • 调用后select函数会阻塞,直到有描述符就绪 (有数据 可读、可写、或者有except)或超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。 
  • 当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。

2.1.2 执行过程

image.png
流程:

  1. 用户线程调用select,将fd_set从用户空间拷贝到内核空间
  2. 内核在内核空间对fd_set遍历一遍,检查是否有就绪的socket描述符,如果没有的话,就会进入休眠,直到有就绪的socket描述符
  3. 内核返回select的结果给用户线程,即就绪的文件描述符数量
  4. 用户拿到就绪文件描述符数量后,再次对fd_set进行遍历,找出就绪的文件描述符
  5. 用户线程对就绪的文件描述符进行读写操作

2.1.3 c语言代码案例

image.png image.png

2.1.4 优缺点

优点

  1. select 其实就是把NIO中用户态要遍历的fd数组(我们的每一个socket链接,安装进ArrayList里面的那个)拷贝到了内核态,让内核态来遍历,因为用户态判断socket是否有数据还是要调用内核态的,所有拷贝到内核态后,这样遍历判断的时候就不用一直用户态和内核态频繁切换了
  2. 从代码中可以看出,select系统调用后,返回了一个置位后的&rset,这样用户态只需进行很简单的二进制比较,就能很快知道哪些socket需要read数据,有效提高了效率
  3. 所有平台都支持,良好的跨平台性 缺点:
  4. bitmap最大1024位,一个进程最多只能处理1024个客户端
  5. &rset不可重用,每次socket有数据就相应的位会被置位 
  6. 文件描述符数组拷贝到了内核态(只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)),仍然有开销。select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制) 
  7. select并没有通知用户态哪一个socket有数据,仍然需要O(n)的遍历。select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历) 小结:
    select方式,既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次 select 的系统调用 + N次就绪状态的文件描述符的 read 系统调用

2.2 poll函数

1993年实现了poll函数。可以认为poll是一个增强版本的select,因为select的比特位操作决定了一次性最多处理的读或者写事件只有1024个,而poll使用一个新的方式优化了这个模型。

2.2.1 数据结构和参数定义

poll底层操作的数据结构pollfd,使用链表存储

struct pollfd {
	int fd;          // 需要监视的文件描述符
	short events;    // 需要内核监视的事件,比如读事件、写事件
	short revents;   // 实际发生的事件,如果该文件描述符有事件发生置为1
};

在使用该结构的时候,不用进行比特位的操作,而是对事件本身进行操作就行。同时还可以自定义事件的类型。具体可以参考手册

同样的,事件默认初始化全部都是0,通过bzero或者memset统一初始化即可,之后在events上注册感兴趣的事件,监听的时候在revents上监听即可。注册事件使用|操作,查询事件使用&操作。比如想要注册POLLIN数据到来的事件,需要pfd.events |= POLLIN,注册多个事件进行多次|操作即可。取消事件进行~操作,比如pfd.events ~= POLLIN。查询事件:pfd.revents & POLLIN

使用poll函数进行操作:

int poll(struct pollfd* fds, nfds_t nfds, int timeout);

参数说明:

  • fds:一个pollfd队列的队头指针,我们先把需要监视的文件描述符和他们上面的事件放到这个队列中
  • nfds:队列的长度
  • timeout:事件操作,设置指定正数的阻塞事件,0表示非阻塞模式,-1表示永久阻塞。
    时间的数据结构:
    struct timespec {
    long    tv_sec;         /* seconds */
    long    tv_nsec;        /* nanoseconds */
    };
    

2.2.2 执行过程

基本与select函数执行过程类似:

  1. 用户线程调用poll系统调用,并将文件描述符链表拷贝到内核空间
  2. 内核对文件描述符遍历一遍,如果没有就绪的描述符,则内核开始休眠,直到有就绪的文件描述符
  3. 返回给用户线程就绪的文件描述符数量
  4. 用户线程再遍历一次文件描述符链表,找出就绪的文件描述符,并将events重置为0,便于复用
  5. 用户线程对就绪的文件描述符进行读写操作

2.2.3 c语言代码案例

image.png

2.2.4 优缺点

优点:

  1. poll使用pollfd数组来代替select中的bitmap,数组没有1024的限制,可以一次管理更多的client。它和 select 的主要区别就是,去掉了 select 只能监听 1024 个文件描述符的限制。 
  2. 当pollfds数组中有事件发生,相应的revents置位为1,遍历的时候又置位回零,实现了pollfd数组的重用 缺点: poll 解决了select缺点中的前两条,其本质原理还是select的方法,还存在select中原来的问题 
  3. pollfds数组拷贝到了内核态,仍然有开销 
  4. poll并没有通知用户态哪一个socket有数据,仍然需要O(n)的遍历

2.3 epoll函数

2002年被大神 Davide Libenzi (戴维德·利本兹)发明出epoll函数。epoll是一个更加高级的操作,上述的select或者poll操作都需要轮询所有的候选队列逐一判断是否有事件,而且事件队列是直接暴露给调用者的,比如上面selectwrite_fdpollfds,这样复杂度高,而且容易误操作。epoll给出了一个新的模式,直接申请一个epollfd的文件,对这些进行统一的管理,初步具有了面向对象的思维模式。

2.3.1 数据结构和参数定义

我们先了解底层的数据结构:

#include <sys/epoll.h>
// 数据结构
// 每一个epoll对象都有一个独立的eventpoll结构体
// 红黑树用于存放通过epoll_ctl方法向epoll对象中添加进来的事件
// epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可
struct eventpoll {
    ...
    /*红黑树的根节点,这颗树存储着所有添加到epoll中的需要监控的事件*/
    struct rb_root  rbr;
    /*双链表存储所有就绪的文件描述符*/
    struct list_head rdlist;
    ...
};
typedef union epoll_data {
	void *ptr;
	int fd;
	uint32_t u32;
	uint64_t u64;
} epoll_data_t;

struct epoll_event {
	uint32_t events;
	epoll_data_t data;
};
// API 
int epoll_create(int size); // 内核中间加一个 eventpoll 对象,把所有需要监听的 socket 都放到 eventpoll 对象中 
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // epoll_ctl 负责把 socket 增加、删除到内核红黑树 
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);// epoll_wait 检测双链表中是否有就绪的文件描述符,如果有,则返回

注意到,epoll_data是一个union类型。fd很容易理解,是文件描述符;而文件描述符本质上是一个索引内核中资源地址的一个下标描述,因此也可以用ptr指针代替;同样的这些数据可以用整数代替。参数定义 再来看epoll_event,有一个data用于表示fd,之后又有一个events表示注册的事件。

epoll通过下面三个函数进行。

  1. 创建epollfd:创建一个epoll句柄

    int epoll_create(int size);
    

    size用于指定内核维护的队列大小,不过在2.6.8之后这个参数就没有实际价值了,因为内核维护一个动态的队列了。 函数返回的是一个epoll的fd,之后的事件操作通过这个epollfd进行。

    还有另一个创建的函数:

    int epoll_create1(int flag);
    

    flag==0时,功能同上,另一个选项是EPOLL_CLOEXEC。这个选项的作用是当父进程fork出一个子进程的时候,子进程不会包含epoll的fd,这在多进程编程时十分有用。

  2. 处理事件:向内核添加、修改或删除要监控的文件描述符

    int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
    
    • epfd是创建的epoll的fd
    • op表示操作的类型
      • EPOLL_CTL_ADD :注册事件
      • EPOLL_CTL_MOD:更改事件
      • EPOLL_CTL_DEL:删除事件
    • fd是相应的文件描述符
    • event是事件队列
  3. 等待事件:类似发起select()调用

    int epoll_wait(int epfd, struct epoll_event* evlist, int maxevents, int timeout);
    
    • epfdepoll的文件描述符
    • evlist是发生的事件队列
    • maxevents是队列最长的长度
    • timeout是时间限制,正整数时间,0是非阻塞,-1永久阻塞直到事件发生。返回就绪的个数,0表示没有,-1表示出错。 从下图可以得知epoll相关接口作用: image.png 事件回调通知机制:
  • 当有网卡上有数据到达了,首先会放到DMA(内存中的一个buffer,网卡可以直接访问这个数据区域)中 
  • 网卡向cpu发起中断,让cpu先处理网卡的事 
  • 中断号在内存中会绑定一个回调,哪个socket中有数据,回调函数就把哪个socket放入就绪链表中

2.3.2 执行过程

  1. epoll_create创建eventpoll对象(红黑树,双链表)
  2. 一棵红黑树,存储监听的所有文件描述符,并且通过epoll_ctl将文件描述符添加、删除到红黑树
  3. 一个双链表,存储就绪的文件描述符列表,epoll_wait调用时,检测此链表中是否有数据,有的话直接返回;当有数据的时候,会把相应的文件描述符'置位',但是epoll没有revent标志位,所以并不是真正的置位。这时候会把有数据的文件描述符放到队首。
  4. 所有添加到eventpoll中的事件都与设备驱动程序建立回调关系;epoll会返回有数据的文件描述符的个数,根据返回的个数,读取前N个文件描述符即可

2.3.3 c语言代码案例

image.png

2.3.4 epoll的工作模式(LT和ET触发)

2.3.4.1 水平触发模式(LT模式)

LT模式也就是水平触发模式,是epoll的默认触发模式(select和poll只有这种模式) 触发条件:

  • 可读事件:接受缓冲区中的数据大小高于低水位标记,则会触发事件
  • 可写事件:发送缓冲区中的剩余空间大小大于低水位标记,则会触发事件
  • 低水位标记:一个基准值,默认是1 所以简单点说,水平触发模式就是只要缓冲区中还有数据,就会一直触发事件。当epoll检测到socket上事件就绪的时候, 可以不立刻进行处理. 或者只处理一部分。 例如:由于只读了1K数据, 缓冲区中还剩1K数据, 在第二次调用 epoll_wait 时, epoll_wait 仍然会立刻返回并通知socket读事件就绪.直到缓冲区上所有的数据都被处理完, epoll_wait 才不会立刻返回。
    支持阻塞读写和非阻塞读写

2.3.4.2 边缘触发模式(ET模式)

ET模式也就是边缘触发模式,如果我们将socket添加到epoll_event描述符的时候使用了EPOLLET标志, epoll就会进入ET工作模式。 触发条件

  • 可读事件:(不关心接受缓冲区是否有数据)每当有新数据到来时,才会触发事件。
  • 可写事件:剩余空间从无到有的时候才会触发事件

简单点说,ET模式下只有在新数据到来的情况下才会触发事件。这也就要求我们在新数据到来的时候最好能够一次性将所有数据取出,否则不会触发第二次事件,只有等到下次再有新数据到来才会触发。而我们也不知道具体有多少数据,所以就需要循环处理,直到缓冲区为空,但是recv是一个阻塞读取,如果没有数据时就会阻塞等待,这时候就需要将描述符的属性设置为非阻塞,才能解决这个问题

void SetNoBlock(int fd) 
{
    int flag = fcntl(fd, F_GETFL);

    flag |= O_NONBLOCK;
    fcntl(fd, F_SETFL, flag);
}

当epoll检测到socket上事件就绪时, 必须立刻处理。 如上面的例子, 虽然只读了1K的数据, 缓冲区还剩1K的数据, 在第二次调用 epoll_wait 的时候, epoll_wait 不会再返回了。也就是说, ET模式下, 文件描述符上的事件就绪后, 只有一次处理机会。

ET的性能比LT性能更高( epoll_wait 返回的次数少了很多)。
只支持非阻塞的读写

2.3.4.3 区别

  • LT模式的优点主要在于其简单且稳定,不容易出现问题,传统的select和poll都是使用这个模式。但是也有缺点,就是因为事件触发过多导致效率降低

  • ET最大的优点就是减少了epoll的触发次数,但是这也带来了巨大的代价,就是要求必须一次性将所有的数据处理完,虽然效率得到了提高,但是代码的复杂程度大大的增加了。Nginx就是默认采用ET模式

  • 还有一种场景适合ET模式使用,如果我们需要接受一条数据,但是这条数据因为某种问题导致其发送不完整,需要分批发送。所以此时的缓冲区中数据只有部分,如果此时将其取出,则会增加维护数据的开销,正确的做法应该是等待后续数据到达后将其补全,再一次性取出。但是如果此时使用的是LT模式,就会因为缓冲区不为空而一直触发事件,所以这种情况下使用ET会比较好。(拆包粘包问题)

2.3.5 优缺点

优点:

  1. 时间复杂度为O(1),当有事件就绪时,epoll_wait只需要检测就绪链表中有没有数据,如果有的话就直接返回
  2. 不需要从用户空间到内核空间频繁拷贝文件描述符集合,使用了内存映射(mmap)技术
  3. 当有就绪事件发生时采用回调的形式通知用户线程 缺点:
  4. 只能工作在linux下

2.4 总结

  • 多路复用快的原因在于,操作系统提供了这样的系统调用,使得原来的 while 循环里多次系统调用, 变成了一次系统调用 + 内核层遍历这些文件描述符。 
  • epoll是现在最先进的IO多路复用器,Redis、Nginx,linux中的Java NIO都使用的是epoll。 
  • 这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。 
    • 一个socket的生命周期中只有一次从用户态拷贝到内核态的过程,开销小 
    • 使用event事件通知机制,每次socket中有数据会主动通知内核,并加入到就绪链表中,不需要遍历所有的socket
  • 在多路复用IO模型中,会有一个内核线程不断地去轮询多个 socket 的状态,只有当真正读写事件发送时,才真正调用实际的IO读写操作。因为在多路复用IO模型中,只需要使用一个线程就可以管理多个socket,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有真正有读写事件进行时,才会使用IO资源,所以它大大减少来资源占用
  • 多路I/O复用模型是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流( epoll 是只轮询那些真正发出了事件的流 ),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。 采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗)

2.5 select、poll、epoll对比

selectpollepoll
如何从fd数据中获取就绪的fd遍历遍历回调
底层数据结构bitmap存储文件描述符链表存储文件描述符红黑树存储监控的文件描述符,双链表存储就绪的文件描述符
时间复杂度获得就绪的文件描述符需要遍历fd数组,On)获得就绪的文件描述符需要遍历fd链表,O(n)当有就绪事件时,系统注册的回调函数就会被调用,将就绪的fd放入到就绪链表中。O(1)
最大支持文件描述符一般有最大值限制6553565535
最大连接数1024(x86)或2048(x64)无限制无限制
FD数据拷贝每次调用select,需要将fd数据从用户空间拷贝到内核空间每次调用poll,需要将fd数据从用户空间拷贝到内核空间使用内存映射(mmap),不需要从用户空间频繁拷贝fd数据到内核空间

3. IO多路复用应用场景

3.1 Ngnix支持IO多路复用模型

  • select
  • poll
  • epoll: IO多路复用、高效并发模型,可在 Linux 2.6+ 及以上内核可以使用
  • kqueue: IO多路复用、高效并发模型,可在 FreeBSD 4.1+, OpenBSD 2.9+, NetBSD 2.0, and Mac OS X 平台中使用
  • /dev/poll: 高效并发模型,可在 Solaris 7 11/99+, HP/UX 11.22+ (eventport), IRIX 6.5.15+, and Tru64 UNIX 5.1A+ 平台使用
  • eventport: 高效并发模型,可用于 Solaris 10 平台,PS:由于一些已知的问题,建议 使用/dev/poll替代。

3.2 Redis支持IO多路复用模型

Redis 是跑在单线程中的,所有的操作都是按照顺序线性执行的,但是 由于读写操作等待用户输入或输出都是阻塞的,所以 I/O 操作在一般情况下往往不能直接返回,这会导致某一文件的 I/O 阻塞导致整个进程无法对其它客户提供服务,而 I/O 多路复用就是为了解决这个问题而出现

所谓 I/O 多路复用机制,就是说通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作。这种机制的使用需要 select 、 poll 、 epoll 来配合。多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。 

Redis 服务采用 Reactor 的方式来实现文件事件处理器(每一个网络连接其实都对应一个文件描述符)。Redis基于Reactor模式开发了网络事件处理器,这个处理器被称为文件事件处理器。它的组成结构为4部分: 

  • 多个套接字
  • IO多路复用程序 
  • 文件事件分派器 因为文件事件分派器队列的消费是单线程的,所以Redis才叫单线程模型
  • 事件处理器。  image.png

Redis对IO多路复用函数的选择如下图: image.png

参考文章:
IO多路复用,select、poll、epoll区别
select、poll和epoll的总结对比
知乎大佬图文并茂的epoll讲解,看不懂的去砍他
epoll的工作模式:LT与ET触发模式