I/O多路复用之Select、Poll和Epoll

近期写一个在线聊天室的时候接触到epoll,加上之前腾讯面试的时候面试官有问到这一题。这里就对select、poll和epoll做一个总结,目的是让自己更加深入地理解,大部分内容来自网上,可能存在错误,欢迎大家指正。

I/O多路复用

之前在讲阻塞/非阻塞和同步/异步的理解的时候讲到,在Unix网络I/O中,有三种同步I/O方式:阻塞式I/O、非阻塞式I/O和I/O复用。

I/O多路复用简单来说就是对多个文件进行操作,通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回

select,poll,epoll都是I/O多路复用的机制。I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

select

使用

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

select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。nfds表示所有监视的文件描述符中最高的数+1,同时可以定义时间timeout,超过时间将返回0。

调用后select函数会阻塞,直到有描述副就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。

实现

在 Linux内核层面, select使用了内核poll例程集合。每个例程都返回有关单个文件描述符就绪的信息。这个就绪信息以位掩码的形式返回。select主要是轮询这些例程集合,当有例程返回的文件描述符就绪信息表示已经就绪以后,阻塞的select就会返回。

特点

select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。

但是通过上面实现的分析,select还存在的问题是:

  • 被监控的fds需要从用户空间拷贝到内核空间。为了减少数据拷贝带来的性能损坏,内核对被监控的fds集合大小做了限制,并且这个是通过宏控制的,大小不可改变(限制为1024)。
  • 每次调用 select,内核都必须轮询检查所有被指定的文件描述符,看它们是否处于就绪态。当检查大量处于密集范围内的文件描述符时,该操作将会耗费大量的时间
  • select调用完成后,程序必须检査返回的数据结构中的每个元素,以此查明哪个文件描述符处于就绪态了

poll

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

不同与select使用三个集合来表示三个fdset的方式,poll使用一个 pollfd的数组实现

1
2
3
4
5
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};

fd表示检测的文件描述符,要测试的条件由 events成员指定,函数在相应的 revents成员中返回该描述符的状态。和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。

在API调用上其与select的区别是:

  • 由于 select的参数fd_set同时也是保存调用结果的地方,如果要在循环中重复调用select的话,我们必须每次都要重新初始化fd_set。而poll通过独立的两个字段 events(针对输入)和 revents(针对输出)来处理,从而避免每次都要重新初始化参数
  • select提供的超时精度(微秒)比poll提供的超时精度(毫秒)高。(这两个系统调用的超时精度都受软件时钟粒度的限制。)

实现

poll在Linux内核中的实现原理和select是一样的,同样是通过轮询poll例程集合来判断文件描述符的状态的。

特点

poll虽然解决了fds集合大小1024的限制问题,但是,它并没改变大量描述符数组被整体复制于用户态和内核态的地址空间之间,以及个别描述符就绪触发整体描述符集合的遍历的低效问题。poll随着监控的socket集合的增加性能线性下降,poll不适合用于大并发场景。

select中存在的性能问题poll中也一样存在

epoll

相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一组函数来完成任务,而不是单个函数。epoll把用户关心的文件描述符上的事件放在内核里的一个事件表中,从而无须像 select和poll那样每次调用都要重复传人文件描述符集或事件集。但epoll要使用一个额外的文件描述符,来唯一标识内核中的这个事件表。

使用

epoll含有的接口有三个:

1
2
3
4
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);

epoll_create

创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。

当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽

epoll_ctl

对由epfd所引用的epoll实例进行操作,其中op表示操作类型(包含增删改),fd表示操作的目标文件描述符,event和文件描述符相关联表示具体监听什么事件,以下是event的具体数据结构

1
2
3
4
5
6
7
8
9
10
11
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; /* 事件信息 */
};

epoll_wait

等待epfd所引用的epoll实例上的I/O就绪,同时用events来获得从内核得到的事件集合、maxevents表示传入的events有多少

函数返回以后可以遍历event数组,对已经就绪的I/O进行处理。

工作模式

在每个事件的属性中可以设置对文件描述符的工作模式,有两种模式:水平触发LT(level trigger)和边沿出发ET(edge trigger)。LT模式是默认模式。两种模式的区别如下:

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

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

EPOLLONESHOT事件

即使我们使用ET模式,一个socket上的某个事件还是可能被触发多次。这在并发程序中就会引起一个问题。比如一个线程(或进程,下同)在读取完某个 socket上的数据后开始处理这些数据,而在数据的处理过程中该 socket上又有新数据可读( EPOLLIN再次被触发),此时另外一个线程被唤醒来读取这些新的数据。于是就出现了两个线程同时操作一个socket的局面。这当然不是我们期望的。我们期望的是一个socket连接在任一时刻都只被一个线程处理。这一点可以使用 epoll的 EPOLLONESHOT事件实现。

对于注册了EPOLLONESHOT事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次,除非我们使用 epoll_ctl函数重置该文件描述符上注册的 EPOLLONESHOT事件。这样,当一个线程在处理某个 socket时,其他线程是不可能有机会操作该 socket的。但反过来思考,注册了 EPOLLONESHOT事件的 socket一且被某个线程处理完毕,该线程就应该立即重置这个 socket上的 EPOLLONESHOT事件,以确保这个socket下一次可读时,其 EPOLLIN事件能被触发,进而让其他工作线程有机会继续处理这个socket

实现

当通过epoll_ctl指定了需要监视的文件描述符时,内核会在与打开的文件描述上下文相关联的列表中记录该描述符。之后每当执行I/O操作使得文件描述符成为就绪态时,就会启用回调函数使得内核就在epoll描述符的就绪列表中添加一个元素。(单个打开的文件描述上下文中的一次I/O事件可能导致与之相关的多个文件描述符成为就绪态)之后的epoll_wait()调用从就绪列表中简单地取出这些元素。这样采用回调的方式获取就绪的文件描述符相比select和poll轮询的方式效率就快很多

在 epoll中我们使用 epoll_ctl在内核空间中建立一个事件表,事件表会将待监视的文件描述符都记录下来。一旦这个数据结构建立完成,稍后每次调用epoll_wait时就不需要再传递任何与文件描述符有关的信息给内核了,而调用返回的信息中只包含那些已经处于就绪态的描述符。这样就减少了将文件描述符信息从用户态转移到内核态所需要的消耗。

特点

在 select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait() 时便得到通知。(此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。)

其优点主要有如下几点:

  • 监视的描述符数量不受限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于1024
  • 在执行调用的时候不需要将监听的文件描述符集合从用户态复制到内核态,减少开销
  • IO的效率不会随着监视fd的数量的增长而下降。epoll不同于select和poll轮询的方式,而是通过每个fd定义的回调函数来实现的。只有就绪的fd才会执行回调函数。

总结

监视事件数量

select受限于1024(依据系统而定),poll虽然数量上没有收到限制,但是因为需要轮询数量非常大的时候性能会下降,epoll在数量和性能上面都没有限制

实现方式

select,poll实现需要自己不断轮询所有fd集合,直到设备就绪。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程
虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的 时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间,这就是回调机制带来的性能提升。

开销

select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内 部定义的等待队列),这也能节省不少的开销

参考