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

主旨思想:

  1. 首先构造一个关于文件描述符的列表,将要监听的文件描述符添加到该列表中。
  2. 调用一个系统函数,监听该列表中的文件描述符,直到这些描述符中的一个或多个进行I/O操作时,该函数才返回。
    • 此函数是阻塞的
    • 函数对文件描述符检测的操作由内核完成
  3. 在返回时,它会告诉进程有多少描述符要进行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的缺点

  1. 性能开销大:
    • 调用select时陷入内核,这时需将参数中的fd_set从用户空间拷贝到内核空间
    • 内核需遍历传递进来的所有fd_set每一bit,不管它们是否就绪;不断轮询所有fd集合,直到存在就绪,期间可能存在睡眠和唤醒多次交替。
  2. 同时监听的文件描述符数量太少。受限于sizeof(fd_set),在编译内核时就已经确定并无法更改,一般为1024,不同操作系统不一样。
  3. fds集合不能重用,每次都需要重置。

poll

poll的主旨思想大致与select相同。poll在用户态通过数组方式传递文件描述符,在内核态会转为链表方式存储,没有最大数量限制。

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

poll通过pollfd数组向内核传递需要关注的事件,故没有描述符个数的限制;其中的fd表示一个个文件描述符,events表示注册的事件,而revents表示实际发生的事件,由内核来填充;由此pollfd数组只需要被初始化一次。

poll相较于select的优点

  1. 由于通过pollfd数组向内核传递事件表,所以没有文件描述符个数大小的限制
  2. 由于内部存在events和revents变量,因此pollfd数组只需初始化一次

poll的缺点

解决了上述select缺点的2和3,但没有解决1。

epoll

epoll为对select和poll的改进,解决了上述I/O多路复用函数的缺点。epoll有以下特点:

  • 使用红黑树存储文件描述符集合
  • 使用队列存储就绪的文件描述符
  • 每个文件描述符只需在添加时被传入一次;通过事件改变文件描述符状态
    epoll模型不同于select和poll,它使用三个函数:epoll_create,epoll_ctlepoll_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添加一个监听事件event
    • EPOLL_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也是边缘触发)

  1. 水平触发(Level Trigger):当文件描述符就绪时,会触发通知,若用户程序没有一次性把数据读/写完毕,下次还会发出可读/可写信号进行通知。
  • 假设委托内核检测读(read)事件->检测fd的读缓冲区
  • 读缓冲区有数据->epoll检测到后会给用户通知
    • a.用户不读数据,数据一直在缓冲区,epoll一直通知
    • b.用户只读了一部分数据,epoll会通知
    • c.缓冲区数据读完了,不通知
  1. 边缘触发(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,那么一定会在最后一次调用(没有数据可读/写)时阻塞,导致无法正常结束

三者对比

  • select:调用开销大(需要拷贝集合);集合大小有限制;需要遍历整个集合找到就绪的描述符(只支持LT模式)
  • poll:poll采用数组(内核用链表)方式存储文件描述符集合,没有最大存储数量限制,且只需对数组初始化一次;其它与select无区别(LT)
  • epoll:调用开销小(无需拷贝);集合大小无限制;采用回调机制,不需要遍历整个集合(支持LT和ET模式)
  • select和poll在用户态维护文件描述符集合,因此每次将完整集合拷贝给内核
  • epoll由操作系统内核维护文件描述符集合(epoll_event结构体),因此只需在创建时传入文件描述符

适用场景

  • select和poll:连接数较少且均十分活跃,由于epoll需要很多回调,这两者可能性能更佳
  • epoll:连接数较多且有较多不活跃的连接,epoll效率比其它两者高很多

参考资料

posted @ 2022-11-11 17:30  yytarget  阅读(747)  评论(0编辑  收藏  举报