Epoll 小叙

一、概述

  • epoll 是 Linux 内核为了处理大批量的文件描述符而改进的 poll,是 Linux 下多路复用IO接口select/poll的增强版本。epoll可以显著提高程序在大量并发连接中只有少量活跃的情况下的系统的CPU利用率。

  • 服务器要管理多个客户端的连接,而 recv 函数只能监视单个 socket,因此引入了 select/poll。

    但 select 能监测的文件描述符个数限制在 FD_SETSIZE,通常是1024个。这对于并发量达到上万的服务器来说显然不够。与之相对的是,epoll所支持的fd上限是最大可以打开的数目,具体数字可以cat /proc/sys/fs/file-max查看(本人机器上该值为9223372036854775807)。

  • 当存在部分活跃socket时,传统 select/poll 会线性扫描整个socket集合,这会让效率随着socket数量的增加而线性下降,而这就是限制了 select 最大监视数量的原因,程序被唤醒后不知道是哪个 socket 处于活跃状态,需要遍历。

    而 epoll 是基于每个 fd 上面的 callback 函数实现,因此只会对活跃的 socket 进行处理,效率更高。

  • select、poll、epoll之间的区别

    • select:只知道有I/O事件发生,但不知道是具体哪一个,因此只能无差别遍历所有流。时间复杂度$O(n)$,fd数量限制在 FD_SETSIZWE。
    • poll:大致同上,轮询所有套接字,时间复杂度为 $O(n)$,但fd没有数量限制,因为它使用链表存储fd。
    • epoll: 事件驱动,epoll会把哪个流发生了怎样的IO事件通知给用户。时间复杂度$O(1)$。
  • epoll提供了两种触发方式

    • 一种是传统 select/poll 的水平触发(Level Triggered, LT),缺省工作模式,同时还支持阻塞非阻塞的socket。当某个文件描述符准备就绪后,内核会持续通知用户,直到重新变为未就绪状态。
    • 再一种是边缘触发(Edge Triggered),高速工作模式,只支持非阻塞的socket。当某个文件描述符从未就绪变成就绪时,内核会通过epoll通知用户。注意,只通知一次。通知动作只会在文件描述符从未就绪变成就绪这个时刻触发。如果一个已经就绪的文件描述符迟迟不被处理,即一直位于就绪状态,那么该文件描述符就一直不会触发通知。

二、相关函数 & 用法

1. epoll API 概述

  • epoll API 的核心概念为 epoll 实例,它是一个内核数据结构。从用户空间角度上考虑,可以将其视为两个列表:

    • The interest list (有时也称作 epoll set)。它是一个存放已经注册 interest 的文件描述符集合。

      这个 interest 不太好翻译。可以简单的认为是工作列表。

      为了便于说明,下文中关于 interest list相关的说明,一律以工作列表等价替换。

    • The ready list。即就绪列表,是工作列表中,一组文件描述符子集的引用。当工作列表中存在某个文件描述符有IO活动,内核将会动态的把当前文件描述符填充至就绪列表中。

  • 有些函数会涉及到关于 epoll 实例的操作

    • epoll_create:新建一个 epoll 实例,执行时会返回一个指向 epoll 实例的文件描述符。
    • epoll_ctl :动态设置某个 epoll 实例的工作列表上的条目。
    • epoll_wait:等待IO事件。如果当前没有事件(即就绪队列为空)则阻塞等待。
  • 水平触发边缘触发

    思考一下这个例子:

    1. 文件描述符 rfd 作为管道的读取端,被注册进 epoll 实例中。
    2. 管道的另一端写入2kb数据至管道。
    3. 调用 epoll_wait 等待IO事件,返回 rfd。
    4. 管道本地端通过 rfd 读取了1kb的数据。
    5. 继续调用epoll_wait。结果是?

    如果文件描述符 rfd 被注册进 epoll 实例时使用边缘触发模式(EPOLLET),那么第5步的函数调用将会被挂起即便有数据没有读完。因为边缘触发模式仅在受监视的文件描述符上发生更改才会传送事件。

    使用边缘触发模式时,最好使用非阻塞的文件描述符,这样可以避免阻塞其他等待读写的任务。

    水平触发模式在第5步的函数调用中不会被挂起,而是返回 rfd,因为缓冲区中仍然存在没有读完的数据。

  • 一个简单的例子

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    #define MAX_EVENTS 10
    struct epoll_event ev, events[MAX_EVENTS];
    int listen_sock, conn_sock, nfds, epollfd;

    /* Code to set up listening socket, 'listen_sock',
    (socket(), bind(), listen()) omitted */

    epollfd = epoll_create1(0);
    if (epollfd == -1) {
    perror("epoll_create1");
    exit(EXIT_FAILURE);
    }

    ev.events = EPOLLIN;
    ev.data.fd = listen_sock;
    if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
    perror("epoll_ctl: listen_sock");
    exit(EXIT_FAILURE);
    }

    for (;;) {
    nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
    if (nfds == -1) {
    perror("epoll_wait");
    exit(EXIT_FAILURE);
    }

    for (n = 0; n < nfds; ++n) {
    if (events[n].data.fd == listen_sock) {
    conn_sock = accept(listen_sock,
    (struct sockaddr *) &addr, &addrlen);
    if (conn_sock == -1) {
    perror("accept");
    exit(EXIT_FAILURE);
    }
    setnonblocking(conn_sock);
    ev.events = EPOLLIN | EPOLLET;
    ev.data.fd = conn_sock;
    if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock,
    &ev) == -1) {
    perror("epoll_ctl: conn_sock");
    exit(EXIT_FAILURE);
    }
    } else {
    do_use_fd(events[n].data.fd);
    }
    }
    }
  • 注意事项

    • 两个不同的 epoll 实例可以等待相同的文件描述符。这样当目标文件描述符存在事件时,两个 epoll 实例均会收到该事件。
    • 一个 epoll 实例的文件描述符可以被注册进另一个 epoll 实例的工作列表中,但是不能注册进自己的工作列表。
    • epoll API 是 Linux 平台独有的。

2. epoll_create

  • 函数声明

    1
    2
    3
    #include <sys/epoll.h>
    int epoll_create(int size);
    int epoll_create1(int flags); // 除了 flags 可以传 EPOLL_CLOEXEC,其他与epoll_create一样。
  • 功能:打开一个指向新的 epoll 实例的文件描述符,该描述符将会用在所有 epoll 族的函数中。当该文件描述符不再使用时,使用close函数关闭。

  • 函数参数size:调用者希望添加到epoll实例的文件描述符的数量。内核将使用该数量来提示初始时存放事件的内部数据结构中所分配的空间量。

    自 Linux 2.6.8 以后,epoll_create 中的 size参数被忽略,原因是现在的内核无需任何提示的size,即可动态调整所需数据结构的大小。

    但是,size 不能传0。这是为了向后兼容。

  • 返回值

    如果执行成功,则返回一个非负整数的文件描述符。否则返回-1并设置errno。

3. epoll_ctl

  • 函数声明

    1
    2
    3
    #include <sys/epoll.h>

    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  • 功能:添加、修改或移除 参数 epfd 维护的工作列表中的条目。其中要求对目标描述符 fd 执行操作 op

  • 参数说明

    • epfd:待操作的epoll 实例的文件描述符

    • op:操作码,有以下几个可选项:

      • EPOLL_CTL_ADD:将 fd 与事件 event 相联系,并添加进 epfd 的工作列表中。
      • EPOLL_CTL_MOD:修改 fd 相联系的事件为传入的 event。
      • EPOLL_CTL_DEL:将 fd 从工作列表中移除。event参数被忽略。
    • fd:待操作的目标文件描述符

    • event:epoll事件结构

      该结构如下所示:

      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 events */
      epoll_data_t data; /* User data variable */
      };

      其中的events参数是一系列事件枚举的OR运算结果。事件枚举主要有以下几种:

      • EPOLLIN:关联的 fd 可用于 read 操作。

      • EPOLLOUT:关联的 fd 可用于 write 操作。

      • EPOLLRDHUP:远程主机关闭了连接。该标志对于在边缘触发模式下检查连接是否被关闭,有很大的帮助。

      • EPOLLET:对关联的 fd 使用边缘触发模式,默认状态下是水平触发模式。

      • EPOLLONESHOT:设置关联 fd 的单发行为。当使用 epoll_wait 将目标fd提取事件以后,当前fd将会在内部被禁用,此时 epoll 不会再报告其他事件。

        这在 socket 里相当有用,即便使用ET模式,一个socket上的某个事件还是可能被触发多次。例如希望处理完当前socket的当前事件后,再来处理该socket的下一个事件,顺序处理。

        处理完成后,用户必须手动调用 epoll_ctl 重置该标志位。

      • EPOLLEXCLUSIVE:若有多个 epoll 实例关联当前 fd时,默认情况下当该fd有事件发生时,所有关联 epoll 实例均会收到事件。而倘若设置了EPOLLEXCLUSIVE标志,则每次事件来临时只会唤醒一个 epoll 实例,避免惊群效应

  • 返回值说明

    • 当 epoll_ctl 函数工作正常,则返回 0。
    • 当有错误发生时,返回 -1 并设置 errno。

4. epoll_wait

  • 函数声明

    1
    2
    3
    4
    5
    6
    7
    #include <sys/epoll.h>

    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);
  • 功能:等待 epoll 实例中的事件发生。

    epoll_waitepoll_pwait的不同点在于:epoll_pwait可以指定忽略部分信号。

  • 参数说明

    • epfd:待处理的 epoll 实例描述符。
    • events:用于存放 epoll_event 的数组指针。
    • maxevents:最多返回多少个事件至events数组中,注意该参数必须大于0
    • timeout:最长等待/阻塞时间(毫秒)。置为 -1 将导致该函数无限期阻塞;置为0将导致函数立即返回,而不管是否存在可用事件。该时间基于CLOCK_MONOTONIC时钟测量。
  • 返回值说明

    • 若执行成功,则该函数返回处于就绪状态的文件描述符个数(起始可以简单看作事件个数)。

    • 如果等待超时,则返回0,表示没有处于就绪状态的文件描述符。

    • 若有错误发生,则返回 -1 并设置 errno。

      注意:若wait被 signal 中断,则 errno 会设置为 EINTR。

  • 其他说明

    • 对于返回的事件中,某个epoll_event结构体中的 events 字段可能会被 epoll API 设置以下几种错误标志

      • EPOLLERR:相关联的 fd 发生了错误。若远程主机的读取端被关闭,则本地写入端会报告该错误。
  • EPOLLHUP:相关联的 fd 被中断。该事件只表示远程主机关闭了该连接。当读取完管道中剩余的数据后,读取端会收到一个 EOF。

  • 每个返回的 epoll_event 包含的 data 字段与最近调用 epoll_ctl(EPOLL_CTL_ADD,EPOLL_CTL_MOD)中为相应打开文件描述所指定的data 字段相同。 其中,events字段将会包含返回的事件位标志。

    epoll_event::data 是一个随着事件一起携带的字段,功能类似于多线程调用中的线程参数传递。

    • epoll 实例的就绪队列中可能会同时存在多个事件。那么在调用 epoll_wait 函数时,该函数将会把对应的所有事件中的一小部分(maxevents 限制)统一返回给用户,而不是调用一次返回一个事件。

    • 接上条,若事件个数超过 maxevents ,则接下来的epoll_wait调用将循环访问就绪文件描述符集。 此行为有助于避免出现饥饿的情况,即防止由于进程集中在一组已知的就绪文件描述符上,而无法注意到其他文件描述符进入就绪状态。

    • 若epoll 实例的工作队列为空,则仍然可以执行 epoll_wait函数,该函数的运行将被阻塞,直到有文件描述符放入该工作队列并转为就绪状态

三、参考链接

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2020-2022 Kiprey
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~