关于epoll和select的区别

问题:关于 epoll 和 select 的区别,哪些说法是正确的?(多选)

A. epoll 和 select 都是 I/O 多路复用的技术,都可以实现同时监听多个 I/O 事件的状态。

B. epoll 相比 select 效率更高,主要是基于其操作系统支持的I/O事件通知机制,而 select 是基于轮询机制。

C. epoll 支持水平触发和边沿触发两种模式。

D. select 能并行支持 I/O 比较小,且无法修改。

出题人:阿里巴巴出题专家:寈峰/阿里技术专家

参考答案:A,B,C

【延伸】那在高并发的访问下,epoll使用那一种触发方式要高效些?当使用边缘触发的时候要注意些什么东西,下面将详细讲解epoll

  • epoll是什么

    • epoll 是Linux下多路复用IO接口 select/poll 的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率,因为它会复用文件描述符集用来传递结果而不用迫使开发者每次等待事件之前都必须重新准备要被侦听的文件描述符集合,另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。

    • epoll除了提供 select/poll 那种IO事件的电平触发 (Level Triggered)外,还提供了边沿触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少 epoll_wait/epoll_pwait 的调用,提高应用程序效率。

epoll函数

  • 说明:epoll对IO接口进行多路复用,提高CPU的利用率

  • 头文件:#include <sys/epoll.h>

  • 函数1:int epoll_create1(int flags);(向内核注册并打开一个 epoll 描述符)

    • 这个函数是在linux 2.6.27中加入的,它和 epoll_create 差不多,不同的是 epoll_create1 函数的参数是 flag,当 flag 是 0 时,表示和 epoll_create 函数完全一样,不需要 size 的提示了。

      • 当 flag = EPOLL_CLOEXEC,创建的epfd会设置FD_CLOEXEC(一般使用这种)

        • FD_CLOEXEC:它是 fd 的一个标识说明,用来设置文件 close-on-exec 状态的。当 close-on-exec 状态为 0 时,调用 exec 时,fd 不会被关闭;状态非零时则会被关闭,这样做可以防止 fd 泄露给执行 exec 后的进程。
      • 当 flag = EPOLL_NONBLOCK,创建的 epfd 会设置为非阻塞

    • int epoll_create(int size);

      • 创建一个 epoll 的句柄,size用来告诉内核这个监听的数目最大值。

      • 当创建好 epoll 句柄后,它就是会占用一个 fd 值,所以在使用完 epoll 后,必须调用 close() 关闭,否则可能导致 fd 被耗尽。

      • 注意:是数量的最大值,不是 fd 的最大值(通知内核需要监听 size 个fd)。

      • 自从Linux2.6.8版本以后,size值其实是没什么用的,不过要大于0,因为内核可以动态的分配大小,所以不需要 size 这个提示了。

    • 为什么使用 epoll_create1 函数而不用以前的 epoll_create 函数:

      • epoll_create 的参数 size 是老版本的实现,使用的是 hash 表, size 应该是用来算 bucket 数目,后面因为使用红黑树,这个参数不再使用, 可以忽略。
    • 返回值:成功返回一个非负的文件描述符,出错返回-1,并设置errno:

      • EINVAL : 无效的标志
      • EMFILE : 用户打开的文件超过了限制
      • ENFILE : 系统打开的文件超过了限制
      • ENOMEM : 没有足够的内存完成当前操作
  • 函数2:int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);(epoll 的事件注册函数)

    • epfd参数:epoll_create1 函数返回的的 epoll 描述符

    • op参数:操作值(想要注册的动作)

      • EPOLL_CTL_ADD: 注册目标fd到epfd中,同时关联内部event到fd上
      • EPOLL_CTL_MOD: 修改已经注册到fd的监听事件
      • EPOLL_CTL_DEL: 从epfd中删除/移除已注册的fd,event可以被忽略,也可以为NULL
    • fd参数:需要监听的套接字描述符

    • event参数:设定监听事件的结构体

      • typedef union epoll_data {
            void        *ptr;
            int          fd;
            __uint32_t   u32;
            __uint64_t   u64;
        } epoll_data_t;
        
        struct epoll_event {
            __uint32_t   events; /* Epoll events */
            epoll_data_t data;   /* User data variable */
        };

        data是一个联合体,能够存储fd或其它数据,我们需要根据自己的需求定制。events表示监控的事件的集合,是一个状态值,通过状态位来表示,可以设置如下事件:

        • EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
        • EPOLLOUT:表示对应的文件描述符可以写;
        • EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
        • EPOLLERR:表示对应的文件描述符发生错误;
        • EPOLLHUP:表示对应的文件描述符被挂断;(并不代表对端结束了连接,通常情况下 EPOLLHUP 表示的是本端挂断)
        • EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
        • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,就会把这个fd从epoll的队列中删除。如果还需要继续监听这个socket的话,需要再次把这个fd加入到EPOLL队列里
    • 返回值:成功返回0,失败返回-1,并设置errno:

      • EBADF : epfd 或者 fd 不是一个有效的文件描述符
      • EEXIST : op 为 EPOLL_CTL_ADD,但 fd 已经被监控
      • EINVAL : epfd 是无效的 epoll 文件描述符
      • ENOENT : op 为 EPOLL_CTL_MOD 或者 EPOLL_CTL_DEL,并且 fd 未被监控
      • ENOMEM : 没有足够的内存完成当前操作
      • ENOSPC : epoll 实例超过了 /proc/sys/fs/epoll/max_user_watches 中限制的监听数量
  • 函数3:int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); int epoll_pwait(int epfd, struct epoll_event *events, int maxevents, int timeout, const sigset_t *sigmask);

    • epfd参数:epoll 的描述符( epoll_create1 函数的返回值)

    • events参数:events 指针携带有 epoll_data_t 数据

    • maxevents参数:告诉内核 events 有多大,该值必须大于0,但是这个 maxevents 的值不能大于创建 epoll_create() 时的 size

    • timeout参数(毫秒):表示超时时间。0 表示不等待立即返回,-1 代表永久阻塞,大于 0 表示等待的最大时间

    • epoll_pwait(since linux 2.6.19)允许一个应用程序安全的等待,直到 fd 设备准备就绪,或者捕获到一个信号量。其中 sigmask 表示要捕获的信号量。

    • 返回值:成功返回准备好事件要求的描述符的个数,返回0代表在超时时间内没有准备好的描述符;失败返回 -1 并设置 errno:

      • EBADF:epfd 不是一个有效的描述符
      • EFAULT:events 指向的内存区域没有写的权限
      • EINTR:在事件发生任何请求或超时过期之前,调用被信号处理程序中断
      • EINVAL:epfd 不是一个 epoll 描述符,或者 maxevents 参数小于或等于 0
  • 示例:

    这里使用伪代码,具体实现的代码请移步

    epfd = epoll_create1(EPOLL_CLOEXEC);
    event.events = EPOLLET | EPOLLIN;
    event.data.fd = serverfd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, serverfd, &event);
    // 主循环
    while(true) {
        // 这里的timeout很重要,实际使用中灵活调整
        count = epoll_wait(epfd, &events, MAXEVENTS, timeout);
        for(i = 0; i < count; ++i) {
            if(events[i].events & EPOLLERR || events[i].events & EPOLLHUP)
                // 处理错误
            if(events[i].data.fd == serverfd)
                // 为接入的连接注册事件
            else if(events[i].events & EPOLLIN)
                // 处理可读的缓冲区
                read(events[i].data.fd, buf, len);
                event.events = EPOLLET | EPOLLOUT;
                event.data.fd = events[i].data.fd;
                epoll_ctl(epfd, EPOLL_CTL_MOD, events[i].data.fd, &event);
            else
                // 处理可写的缓冲区
                write(events[i].data.fd, buf, len);
                // 后续可以关闭fd或者MOD至EPOLLOUT
        }
    }

说明

  • 为什么用epoll

    • 支持监听大数目的socket描述符

      • 一个进程内,select能打开的fd是有限制的,由宏FD_SETSIZE 设置,默认值是 1024.在某些时候,这个数值是远远不够用的。解决办法有两种,一是修改宏然后重新编译内核,但与此同时会引起网络效率的下降;二是使用多进程来解决,但是创建多个进程是有代价的,而且进程间数据同步没有多线程间方便。

      • epoll没有这个限制,它所支持的最大 fd 上限远远大于1024,在1GB内存的机器上是10万左右(具体数目可以cat/proc/sys/fs/file-max查看)

        //我的是centos6.9,内存2GB
        [roux@Vkey mnt]$ cat /proc/sys/fs/file-max 
        187510
        [roux@Vkey mnt]$
    1. 效率的提高

      • select 函数每次都当监听的套接组有事件产生时就会返回,但却不能将有事件产生的套接字筛选出来,而是改变其在套接组的标志量,所以每次监听到事件,都需要将套接组整个遍历一遍。时间复杂度是O(n)。当fd数目增加时,效率会线性下降。

      • epoll 函数每次会将监听套结字中产生事件的套接字加到一列表中,然后我们可以直接对此列表进行操作,而没有产生事件的套接字会被过滤掉,极大的提高了IO效率。这一点尤其在套接字监听数量巨大而活跃数量很少的时候很明显。

    2. 内存处理

      • 不管是哪种I/O机制,都无法避免fd在操作过程中拷贝的问题,而 epoll 使用了 mmap (是指文件/对象的内存映射,被映射到多个内存页上),所以同一块内存(共享内存)就可以避免这个问题(详细看前面的零拷贝技术及 mmap)。
  • epoll的工作方式

    • epoll有两种触发模式,LT(Level-Triggered,水平触发),ET(Edge–Triggered边缘触发)。其中LT编程模型和poll是一样的。

    • LT(level triggered) 是默认/缺省的工作方式,同时支持 block 和 no_block socket。这种工作方式下,内核会通知你一个 fd 是否就绪,然后才可以对这个就绪的 fd 进行I/O操作。就算你没有任何操作,系统还是会继续提示 fd 已经就绪,不过这种工作方式出错会比较小,传统的 select/poll 就是这种工作方式的代表。(水平触发,只要有数据可以读,不管怎样都会通知)

    • ET(edge-triggered) 是高速工作方式 仅支持 no_block socket(非阻塞),这种工作方式下,当 fd 从未就绪变为就绪时,内核会通知 fd 已经就绪,并且内核认为你知道该 fd 已经就绪,不会再次通知了,除非因为某些操作导致fd就绪状态发生变化。如果一直不对这个 fd 进行I/O操作,导致 fd 变为未就绪时,内核同样不会发送更多的通知,因为 only once。所以这种方式下,出错率比较高,需要增加一些检测程序。(边缘触发,只有状态发生变化时才会通知,可以理解为电平变化)

    • 举例:

      • LT 水平触发

        儿子:“妈妈,我收到了5000元压岁钱。”
        妈妈:“恩,省着点花!”
        儿子:“妈妈,我今天买了个ipad,花了3000元。”
        妈妈:“噢,这东西真贵。”
        儿子:“妈妈,我今天买好多吃的,还剩1000元。”
        妈妈:“用完了这些钱,我可不会再给你了。”
        儿子:“妈妈,那1000元我没花,零花钱够用了。”
        妈妈:“恩,这才是明智的做法!”
        儿子:“妈妈,那1000元我没花,我要攒起来。”
        妈妈:“恩,加油!”

        • 是不是没完没了?只要儿子手中还有钱,他就会一直汇报,这就是LT模式。有钱就是1,没钱就是0,那么只要儿子还有钱,这种事件就是1->1类型事件,自然是LT。
      • ET 边缘触发

        儿子:“妈妈,我收到了5000元压岁钱。”
        妈妈:“恩,省着点花!”
        儿子:“……”
        妈妈:“你倒是说话啊?压岁钱呢?!”

        • 这个就是ET模式,简洁得有点过头,但很高效!虽然妈妈可能并不这么认为。。。儿子从没钱到有钱,是一个0->1的过程,因此为ET。儿子和妈妈说过自己拿到了压岁钱就完事了,至于怎么花钱,还剩多少钱,一概不说。
    • LT 模式和 ET 模式的区别是 ET 模式是高电平到低电平切换的时候或者低电平切换高电平才会触发。

      • EPOLLIN事件:

        • 内核的输入缓冲区 为空 低电平
        • 内核的输入缓冲区 不为空 高电平(一直触发EPOLLIN)
      • EPOLLOUT事件:

        • 内核发送缓冲区不满 高电平(一直触发EPOLLOUT)
        • 内核发送缓冲区满 低电平
    • 关于 LT 及 ET 的详细知识请点击此处

  • 什么时候关注EPOLLOUT事件呢?(如果在得到一个套接字马上关注,就会出现busy loop的状态)

    • LT 模式:在write的时候关注EPOLLOUT事件,如果数据没有写完,我们就需要把未发送完的数据添加到应用层缓冲区,然后关注这个连接套接字的EPOLLOUT事件,等到EPOLLOUT事件到来,取出应用层缓冲区的数据发送,如果应用层缓冲区数据发送完成,取消关注EPOLLOUT事件。

    • ET 模式:ET表示边缘触发,是电平从低到高或者从高到低才会触发。我们一开始就可以关注EPOLLIN事件和EPOLLOUT事件,不会出现busy loop。所以当接收缓冲区处于高电平状态时,一定要一次性把数据全部读完。因为如果一次没有读完,接收缓冲区仍然处于高电平状态,下次不会在触发EPOLLIN事件。同理,发送缓冲区的处理类似。

注意

  • epoll 和 select/poll 本质上是一样的,性能上提升较大,都属于I/O多路复用模型(I/O Multiplexing Model),和阻塞,非阻塞两大I/O模型是并列的。只是对于事件处理函数角度而言,看起来是异步的(实际上是同步的回调而已)。和 epoll 组合使用大多数是非堵塞I/O方式,这时候就是同步非堵塞,也可以有堵塞I/O的情况。比如多线程server可以在 accept 上采用堵塞I/O,accept后的 socket 用 epoll 管理非堵塞的读写事件。

  • 使用 epoll 一定要加 定时器,否则后患无穷

  • 联合体 data 中的那个 ptr 是很有用的,只不过这就意味着你将该对象的生命周期交给了 epoll,不排除会有潜在bug的影响,需要辅以timeout

  • 多线程环境下使用epoll,多考虑 EPOLLONESHOT

  • EPOLLLT 也是一个不错的选择,除非你的框架能够确保每次事件触发后,都读/写至 EAGAIN

  • 使用前请仔细阅读 man 7 epoll

总结

  • epoll 实现机制(简单介绍)

    • epoll在被内核初始化时(操作系统启动),同时会开辟出epoll自己的内核高速cache区,用于安置每一个我们想监控的socket。这些socket会以红黑树的形式保存在内核cache里,以支持快速的查找、插入、删除。

    • 这个内核高速cache区,就是建立连续的物理内存页,然后在之上建立 slab 层。简单的说,就是物理上分配好你想要的size的内存对象,每次使用时都是使用空闲的已分配好的对象。

    • epoll的高效就在于,当我们调用 epoll_ ctl 往里塞入百万个句柄时,epoll_ wait 仍然可以飞快的返回,并有效的将发生事件的句柄给我们用户。

      • 这是由于我们在调用 epoll_ create 时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后 epoll_ ctl 传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件.
    • 当 epoll_ wait 调用时,仅仅观察这个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait 非常高效。 而且,通常情况下即使我们要监控百万计的句柄,大多一次也只返回很少量的准备就绪句柄而已,所以,epoll_wait 仅需要从内核态copy少量的句柄到用户态而已。

    • 那么,这个准备就绪list链表是怎么维护的呢?

      • 当我们执行 epoll_ctl 时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。

      • 所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了。如此,一颗红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。

    • 执行 epoll_ create 时,创建了红黑树和就绪链表,执行 epoll_ ctl 时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行 epoll_wait 时立刻返回准备就绪链表里的数据即可。

  • 三种IO复用函数的分析

参考

原文地址:https://www.cnblogs.com/hzcya1995/p/13312570.html