select,poll和epoll使用场景和区别
阻塞I/O至I/O多路复用
阻塞I/O指进程发起调用后会被挂起(阻塞),直到收到数据再返回。如果调用一直不返回,进程就一直被挂起。因此,使用阻塞I/O需要利用多线程来处理多个文件描述符。
引入非阻塞I/O的原因为:多线程切换有一定的开销。非阻塞I/O不会被挂起,调用时立即返回成功或错误,因此,可以在单线程里轮询多个文件描述符是否就绪。
引入I/O多路复用的原因是:非阻塞I/O每次发起系统调用,只能检查一个文件描述符是否就绪;当文件描述符较多时,系统调用成本高。I/O多路复用,可以通过一次系统调用,检查多个文件描述符状态。这也是I/O多路复用的主要优点,相比于非阻塞I/O,在文件描述符较多场景下,减少系统调用的开销。
- I/O多路复用相当于把遍历所有文件描述符,并通过非阻塞I/O查看其是否就绪的过程从用户线程移到了内核中,由内核负责轮询。
进程通过select/poll/epoll系统调用发起I/O多路复用,这些系统调用默认是同步阻塞的:如果传入的多个文件描述符中,有描述符就绪,则返回就绪的文件描述符;否则如果所有文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪,或阻塞时长超过设置的timeout后,再返回。(I/O多路复用内部检查每个文件描述符的就绪状态时采用非阻塞I/O)
timeout
参数为NULL时,无限阻塞直到某个描述符就绪;timeout
参数为0时,立即返回,不阻塞。
为什么I/O多路复用内部使用非阻塞I/O
I/O多路复用内部会遍历集合中每个文件描述符,判断其是否就绪;
for(auto& fd : read_set){
if(readable(fd)){ //判断fd是否就绪
count++;
FDSET(fd, &res_rset); //将fd添加到就绪集合
}
}
return count; //返回就绪描述符个数(select)
这里readable()
就是一个非阻塞I/O调用。若在这使用阻塞I/O,那么当fd未就绪时,select会阻塞在这个文件描述符上,无法检查下一个描述符。
select
主旨思想:
- 首先构造一个关于文件描述符的列表,将要监听的文件描述符添加到该列表中。
- 调用一个系统函数,监听该列表中的文件描述符,直到这些描述符中的一个或多个进行I/O操作时,该函数才返回。
- 此函数是阻塞的
- 函数对文件描述符检测的操作由内核完成
- 在返回时,它会告诉进程有多少描述符要进行I/O操作。
int select(int nfds, fd_set *restrict readfds,
fd_set *restrict writefds,
fd_set *restrict errorfds,
struct timeval *restrict timeout);
readfds
,writefds
,errorfds
是三个文件描述符集合。select会遍历每个集合的前nfds
个描述符,分别找到可读取,可写入,发生错误的描述符,统称“就绪”描述符。用找到的子集替换参数中的对应集合,返回所有就绪描述符的总数。
timeout
参数为调用select时的阻塞时长。若所有文件描述符均未就绪,则阻塞调用进程,直到某个描述符就绪或阻塞时长超过设置的timeout后返回。timeout设为NULL,无限阻塞直到某个描述符就绪;timeout设为0,立即返回,不阻塞。
fd_set文件描述符集合
由于文件描述符fd是一个从0开始的无符号整数,所以可用fd_set
的二进程每一位来表述一个文件描述符。某一位为1,则已经就绪。当设fd_set
长度为1字节时,则一个fd_set变量最大可以表示8个文件描述符。返回fd_set = 00100100时,表示第3,6文件描述符已就绪。
fd_set的API:
#include <sys/select.h>
int FD_ZERO(int fd, fd_set *fdset); //令fd_set全部bit置0
int FD_CLR(int fd, fd_set *fdset); //令fd_set某一bit置0
int FD_SET(int fd, fd_set *fdset); //令fd_set某一bit置1
int FD_ISSET(int fd, fd_set *fdset); //检测fd_set某一bit是否为1
select使用示例
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int main(void){
while(1){
//声明一个fd_set类型的变量readFDs
fd_set readFDs;
//调用FD_ZERO,将readFDs所有bit置0
FD_ZERO(&readFDs);
int fd;
//调用FD_SET,将readFDs感兴趣的bit置1,表示要监听这几个文件描述符
for(fd = minFD; fd < maxFD; fd++)
FD_SET(fd, &readFDs);
//将readFDs传给select,调用select
int rc = select(maxFD + 1, &readFDs, NULL, NULL, NULL);
//select会将readFDs中就绪的bit置1,未就绪的置0,并返回就绪的描述符数量
//当select返回后,调用FD_ISSET检测给定位是否为1,表示对应描述符是否就绪
int fd;
for(fd = minFD; fd < maxFD; fd++)
if(FD_ISSET(fd, &readFDs))
processFD(fd);
}
}
select的缺点
- 性能开销大:
- 调用select时陷入内核,这时需将参数中的fd_set从用户空间拷贝到内核空间
- 内核需遍历传递进来的所有fd_set每一bit,不管它们是否就绪;不断轮询所有fd集合,直到存在就绪,期间可能存在睡眠和唤醒多次交替。
- 同时监听的文件描述符数量太少。受限于sizeof(fd_set),在编译内核时就已经确定并无法更改,一般为1024,不同操作系统不一样。
- fds集合不能重用,每次都需要重置。
poll
poll的主旨思想大致与select相同。poll在用户态通过数组方式传递文件描述符,在内核态会转为链表方式存储,没有最大数量限制。
int poll(struct pollfd *fds,
nfds_t nfds, int timeout
);
poll通过pollfd数组向内核传递需要关注的事件,故没有描述符个数的限制;其中的fd表示一个个文件描述符,events表示注册的事件,而revents表示实际发生的事件,由内核来填充;由此pollfd数组只需要被初始化一次。
poll相较于select的优点
- 由于通过pollfd数组向内核传递事件表,所以没有文件描述符个数大小的限制
- 由于内部存在events和revents变量,因此pollfd数组只需初始化一次
poll的缺点
解决了上述select缺点的2和3,但没有解决1。
epoll
epoll为对select和poll的改进,解决了上述I/O多路复用函数的缺点。epoll有以下特点:
- 使用红黑树存储文件描述符集合
- 使用队列存储就绪的文件描述符
- 每个文件描述符只需在添加时被传入一次;通过事件改变文件描述符状态
epoll模型不同于select和poll,它使用三个函数:epoll_create
,epoll_ctl
和epoll_wait
。
epoll_create
int epoll_create(int size);
epoll_create会创建一个epoll实例,同时返回一个引用该实例的文件描述符。
返回的文件描述符仅仅指向对应的epoll实例,并不表示真实的磁盘节点。其它API例如epoll_ctl,epoll_wait会使用这个文件描述符来操作相应的epoll实例。
当创建好epoll句柄后,就会占用一个fd值,在linux可以查看/proc/进程id/fd/
。所以在使用完epoll后,必须调用close(epfd)
关闭对应的文件描述符,否则可能导致fd被耗尽。当指向同一个epoll实例的所有文件描述符都被关闭后,操作系统会销毁这个epoll实例。
epoll内部存储:
- 监听列表:所有要监听的文件描述符(红黑树)
- 就绪列表:所有就绪文件描述符(双向链表)
epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll_ctl会监听文件描述符fd上发生的event事件。
参数说明:
- epfd即epoll_create返回的文件描述符
- fd表示要监听的目标文件描述符
- event表示要监听的事件(可读,可写,发送错误…)
- op表示要对fd执行的操作,分为:
EPOLL_CTL_ADD
:为fd添加一个监听事件eventEPOLL_CTL_MOD
:更改与文件描述符fd关联的事件event(event为一个结构体变量,相当于event本身没变,更改了其内部字段的值)EPOLL_CTL_DEL
:删除fd所有监听事件,此情况event参数无用
epoll_ctl会将文件描述符fd添加到epoll实例的监听列表里,同时为fd设置一个回调函数,并监听事件event。当fd上发生相应事件时,会调用回调函数,将fd添加到epoll实例的就绪队列中。
epoll_wait
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
此函数为epoll模型的主函数,其功能相当于select。
参数说明:
- epfd即epoll_create返回的文件描述符,指向一个epoll实例
- events是一个数组,保存就绪状态的文件描述符,其空间由调用者负责申请
- maxevents指定events大小
- timeout类似于select中的timeout。若设为-1,一直阻塞,直到有文件描述符就绪;设为0,会立即返回
返回值表示events中存储的就绪描述符个数,最大不超过maxevents
epoll的优点
首先epoll是对select和poll的改进,所以就避免了“性能开销大”和“文件描述符数量少”两个缺点。
- 文件描述符数量少:select使用整型数组存储文件描述符集合;epoll使用红黑树存储,数量大
- 性能开销大:epoll_ctl中为每个文件描述符指定了回调函数,并在就绪时将其加入到就绪列表,因此epoll不像select那样遍历每个文件描述符来判断是否就绪,只需要判断就绪列表为空即可。这样在没有文件描述符就绪时,epoll能更早让出系统资源。[O(n)->O(1)]
- 另外,epoll对于每个描述符,只需在epoll_ctl传递一次,之后epoll_wait无需再次传递;不像每次调用select都需要向内核拷贝所有要监听的描述符集合,大大提高效率。
水平触发(LT) 边缘触发(ET)
select与poll只支持LT;epoll支持LT和ET。(信号驱动的I/O也是边缘触发)
- 水平触发(Level Trigger):当文件描述符就绪时,会触发通知,若用户程序没有一次性把数据读/写完毕,下次还会发出可读/可写信号进行通知。
- 假设委托内核检测读(read)事件->检测fd的读缓冲区
- 读缓冲区有数据->epoll检测到后会给用户通知
- a.用户不读数据,数据一直在缓冲区,epoll一直通知
- b.用户只读了一部分数据,epoll会通知
- c.缓冲区数据读完了,不通知
- 边缘触发(Edge Trigger):当且仅当描述符从未就绪变为就绪时,通知一次,之后不再通知。
- 假设委托内核检测读(read)事件->检测fd的读缓冲区
- 读缓冲区有数据->epoll检测到后会给用户通知
- a.用户不读数据,数据一直在缓冲区,epoll下次检测时就不通知了
- b.用户只读了一部分数据,epoll不通知
- c.缓冲区数据读完了,不通知
区别:边缘触发的效率更高,减少事件被重复触发的次数,函数不会返回大量用户程序可能不需要的文件描述符。
边缘触发要使用非阻塞I/O
- 每次调用read系统调用读取数据时,最多只能读取缓冲区大小的字节数;若某个描述符一次性收到的数据超过了缓冲区的大小,那么需对其read多次才能全部读取完毕
select的LT模式可以使用阻塞I/O或非阻塞I/O
- select可以使用阻塞I/O(上文select示例就是用的阻塞I/O)。通过select获取到所有的可读文件描述符后,遍历每个文件描述符,read一次数据
- 由于这些文件描述符均可读,因此即使read是阻塞I/O,也一定可读到数据,不会一直阻塞
- select采用LT模式,因此若第一次read没有读取完全部数据,那么下次调用select时依然会返回这个文件描述符,再次read直到读完
- select也可以使用非阻塞I/O。当遍历某个可读文件描述符时,使用for循环调用read多次,直到读取完所有数据为止(返回
EWOULDBLOCK
)。这样虽然会多一个read调用,但可以减少select的次数
epoll的ET模式必须使用非阻塞I/O
- 在epoll的ET模式下,只会在文件描述符可读/可写状态发生切换时,才会收到操作系统的通知
- 因此,若使用epoll的ET模式,在收到通知时,必须使用非阻塞I/O,并且必须循环调用read或write多次,直到返回
EWOULDBLOCK
为止,然后再调用epoll_wait等待操作系统下一次通知 - 若没有一次性读/写完所有数据,那么在操作系统看来这个文件描述符的状态未改变,将不再发起通知,调用epoll_wait会使得该文件描述符一直等待下去,服务端也会一直等待客户端的响应,业务流程无法走完
- ET模式的好处是每次调用epoll_wait都是有效的—-已保证数据全部读写完毕,等待下次通知。而在LT模式下,若调用epoll_wait时数据未读/写完毕,会直接返回,再次通知。因此ET模式能显著减少事件被触发的次数
- 显然,ET模式需要循环读/写一个文件描述符的所有数据。若使用阻塞I/O,那么一定会在最后一次调用(没有数据可读/写)时阻塞,导致无法正常结束
- 因此,若使用epoll的ET模式,在收到通知时,必须使用非阻塞I/O,并且必须循环调用read或write多次,直到返回
三者对比
- select:调用开销大(需要拷贝集合);集合大小有限制;需要遍历整个集合找到就绪的描述符(只支持LT模式)
- poll:poll采用数组(内核用链表)方式存储文件描述符集合,没有最大存储数量限制,且只需对数组初始化一次;其它与select无区别(LT)
- epoll:调用开销小(无需拷贝);集合大小无限制;采用回调机制,不需要遍历整个集合(支持LT和ET模式)
- select和poll在用户态维护文件描述符集合,因此每次将完整集合拷贝给内核
- epoll由操作系统内核维护文件描述符集合(epoll_event结构体),因此只需在创建时传入文件描述符
适用场景
- select和poll:连接数较少且均十分活跃,由于epoll需要很多回调,这两者可能性能更佳
- epoll:连接数较多且有较多不活跃的连接,epoll效率比其它两者高很多