Linux epoll

130 阅读10分钟

epoll 是Linux下多路复用IO接口 select / poll 的增强版本,它能显著减少程序在大量并发连接中只有少量活跃的情况下的系统 CPU 利用率,

  1. 因为它不会复用文件描述符集合来传递结果而迫使开发者每次等待事件之前都必须重新准备要被侦听的文件描述符集合,
  2. 另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入ready队列的描述符集合就行了。
  3. epoll除了提供select/poll 那种IO事件的水平触发(Level Triggered)外,还提供了边沿触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait的调用,提高应用程序效率。

创建 epoll 实例:epoll_create()

#include <sys/epoll.h>

// 调用成功返回文件描述符,失败返回 -1 
int epoll_create (int __size);
/*
 参数 size 指定epoll实例来检查的文件描述符个数,该参数并不是一个上限,而是告诉内核应该如何为内部数据结构划分初始大小。
 从 Linux 2.6.8 版以来,size 参数被忽略不用
*/

 int epoll_create1 (int __flags)

从 2.6.27 版内核以来,Linux 支持了一个新的系统调用 epoll_create1()。该系统调用执行的 任务同 epoll_create()一样,但是去掉了无用的参数 size,并增加了一个可用来修改系统调用行 为的 flags 参数。目前只支持一个 flag 标志:EPOLL_CLOEXEC,它使得内核在新的文件描述 符上启动了执行即关闭(close-on-exec)标志(FD_CLOEXEC) 。出于同样的原因,这个标志同 open()的 O_CLOEXEC 标志一样有用。

修改 epoll 的兴趣列表:epoll_ctl()

系统调用 epoll_ctl()能够修改由文件描述符 epfd 所代表的 epoll 实例中的兴趣列表

/* Valid opcodes ( "op" parameter ) to issue to epoll_ctl().  */
#define EPOLL_CTL_ADD 1 /* Add a file descriptor to the interface.  */
#define EPOLL_CTL_DEL 2 /* Remove a file descriptor from the interface.  */
#define EPOLL_CTL_MOD 3 /* Change file descriptor epoll_event structure.  */

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 */
};


// 调用成功返回文件描述符,失败返回 -1 
int epoll_ctl (int __epfd, int __op, int __fd,
             struct epoll_event *__event);
/*
 - epfd : epoll 文件描述符
 - op : 用来指定需要执行的操作,它可以是如下几种值
     EPOLL_CTL_ADD 将描述符 fd 添加到 epoll 实例 epfd 中的兴趣列表中去,
                   如果我们试图向兴趣列表中添加一个已存在的文件描述符,epoll_ctl()将出现 EEXIST 错误。
     EPOLL_CTL_DEL 修改描述符 fd 上设定的事件,需要用到由 ev 所指向的结构体中的信息。
                   如果我们试图修改不在兴趣列表中的文件描述符,epoll_ctl()将出现 ENOENT 错误。
     EPOLL_CTL_MOD 将文件描述符 fd 从 epfd 的兴趣列表中移除。该操作忽略参数 ev。
                   如果我们试图移除一个不在 epfd 的兴趣列表中的文件描述符,epoll_ctl()将出现 ENOENT 错误。
                   关闭一个文件描述符会自动将其从所有的 epoll 实例的兴趣列表中移除。
                   
 - fd : 参数 fd 指明了要修改兴趣列表中的哪一个文件描述符的设定。该参数可以是代表管道、
FIFO、套接字、POSIX 消息队列、inotify 实例、终端、设备,甚至是另一个 epoll 实例的文件描述符(例如,我们可以为受检查的描述符建立起一种层次关系)。但是,这里 fd 不能作为普
通文件或目录的文件描述符(会出现 EPERM 错误)。
 - ev : 
     - 结构体 epoll_event 中的 events 字段是一个位掩码,它指定了我们为待检查的描述
符 fd 上所感兴趣的事件集合。
    - data 字段是一个联合体,当描述符 fd 稍后成为就绪态时,联合体的成员可用来指定传
回给调用进程的信息。
*/

max_user_watches 上限

因为每个注册到 epoll 实例上的文件描述符需要占用一小段不能被交换的内核内存空间, 因此内核提供了一个接口用来定义每个用户可以注册到 epoll 实例上的文件描述符总数。这个 上限值可以通过 max_user_watches 来查看和修改。max_user_watches 是专属于 Linux 系统的 /proc/sys/fd/epoll 目录下的一个文件

事件等待:epoll_wait()

系统调用 epoll_wait()返回 epoll 实例中处于就绪态的文件描述符信息。单个 epoll_wait()调用能返回多个就绪态文件描述符的信息。


/* 成功 返回已经准备好的文件描述符个数
   超时 返回 0 
   失败 返回 -1 
*/
int epoll_wait (int __epfd, struct epoll_event *__events,
              int __maxevents, int __timeout);
/*
 - epfd : epoll 文件描述符
 - events: 所指向的结构体数组中返回的是有关就绪态文件描述符的信息。数组 events 的空间由调用者负责申请,所包含的元素个数
 - maxevents : events 元素个数
 - timeout : 用来确定 epoll_wait()的阻塞行为
     - 如果 timeout 等于−1,调用将一直阻塞,直到兴趣列表中的文件描述符上有事件产生,或者直到捕获到一个信号为止。
     - 如果 timeout 等于 0,执行一次非阻塞式的检查,看兴趣列表中的文件描述符上产生了哪个事件。
     - 如果 timeout 大于 0,调用将阻塞至多 timeout 毫秒,直到文件描述符上有事件发生,或者直到捕获到一个信号为止。
*/
位掩码作为 epoll_ctl()的输入?由 epoll_wait()返回?描述
EPOLLIN可读取非高优先级的数据
EPOLLPRI可读取高优先级数据
EPOLLRDHUP套接字对端关闭(始于 Linux2.6.17 版)
EPOLLOUT普通数据可写
EPOLLET采用边缘触发事件通知
EPOLLONESHOT在完成事件通知之后禁用检查
EPOLLERR有错误发生
EPOLLHUP出现挂断

EPOLLONESHOT 标志

默认情况下,一旦通过 epoll_ctl()的 EPOLL_CTL_ADD 操作将文件描述符添加到 epoll 实 例的兴趣列表中后,它会保持激活状态(即,之后对 epoll_wait()的调用会在描述符处于就绪 态时通知我们)直到我们显式地通过 epoll_ctl()的 EPOLL_CTL_DEL 操作将其从列表中移除。 如果我们希望在某个特定的文件描述符上只得到一次通知,那么可以在传给 epoll_ctl()的 ev.events 中指定 EPOLLONESHOT(从 Linux 2.6.2 版开始支持)标志。如果指定了这个标志, 那么在下一个 epoll_wait()调用通知我们对应的文件描述符处于就绪态之后,这个描述符就会 在兴趣列表中被标记为非激活态,之后的 epoll_wait()调用都不会再通知我们有关这个描述符 的状态了。如果需要,我们可以稍后通过 epoll_ctl()的 EPOLL_CTL_ MOD 操作重新激活对这 个文件描述符的检查。(这种情况下不能用 EPOLL_CTL_ADD 操作,因为非激活态的文件描 述符仍然还在 epoll 实例的兴趣列表中。)

epoll 工作模式

epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:

  • LT(level-triggered)是默认的工作方式,并且同时支持 block 和 no-block 文件描述符。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表。
  • ET (edge-triggered)是高速工作方式,只支持 no-block 文件描述符。 在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。

ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

要使用边缘触发通知,我们在调用 epoll_ctl()时在 ev.events 字段中指定 EPOLLET 标志。

struct epoll_event event;
event.data.fd = sfd;
event.events = EPOLLIN | EPOLLET;
epoll_ctl (efd, EPOLL_CTL_ADD, sfd, &event);

使用示例


#include <sys/eventfd.h>
#include <sys/epoll.h>
#include <pthread.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

#define MAX_EVENTS 5

int eventFd;

void *run(void *args) {
    pthread_detach(pthread_self());
    sleep(1);
    time_t t ;
    srand((unsigned) time(&t));

    for (int i = 0; i < 5; ++i) {
        // 随机写入 0 或者 1
        eventfd_t et = rand() % 2;
        int result = eventfd_write(eventFd, et);
        printf("thread write value result = %d ,et = %d\n", result, et);
        sleep(1);
    }
}

int main(int argc, char *argv[]) {
    // 创建 eventfd 文件描述符
    eventFd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);
    // 创建 epoll 文件描述符
    int epollFd = epoll_create1(EPOLL_CLOEXEC);
    
    struct epoll_event ev;
    struct epoll_event events[MAX_EVENTS];
    
    ev.events = EPOLLIN; // 监听可读事件
    ev.data.fd = eventFd;
    // 把 eventfd 文件描述符 添加到 epoll 感兴趣的文件描述符列表
    epoll_ctl(epollFd, EPOLL_CTL_ADD, eventFd, &ev);

    pthread_t tid;
    pthread_create(&tid, NULL, run, NULL);
    while (1) {
       // 等待 eventfd 文件描述符有新的可读事件
        int count = epoll_wait(epollFd, events, MAX_EVENTS, -1);
        printf("epoll_wait count = %d\n", count);
        for (int i = 0; i < count; ++i) {
            struct epoll_event epollEvent = events[i];
            if (epollEvent.events == EPOLLIN && epollEvent.data.fd == eventFd) {
                eventfd_t et;
                // 读取 eventFd 
                int result = eventfd_read(eventFd, &et);
                printf("event value result = %d , value = %d\n", result, et);
            }
        }
    }
}

输出

写入 1 eventfd_read 可读, 0 不可读取

thread write value result = 0 ,et = 0
thread write value result = 0 ,et = 0 
thread write value result = 0 ,et = 1  
epoll_wait count = 1
event value result = 0 , value = 1
thread write value result = 0 ,et = 1
epoll_wait count = 1
event value result = 0 , value = 1
thread write value result = 0 ,et = 1
epoll_wait count = 1
event value result = 0 , value = 1

其它

epoll API 的核心数据结构称作 epoll 实例,它和一个打开的文件描述符相关联。这个文件 描述符不是用来做 I/O 操作的,相反,它是内核数据结构的句柄,这些内核数据结构实现了两个目的。

  • 记录了在进程中声明过的感兴趣的文件描述符列表—interest list(兴趣列表)。
  • 维护了处于 I/O 就绪态的文件描述符列表—ready list(就绪列表,ready list 中的成员是 interest list 的子集)。

一旦所有指向打开的文件描述的文件描述符都被关闭后,这个打开的文件描述将从 epoll 的兴趣列表中移除。这表示如果我们通过 dup()(或类似的函数)或者 fork()为打开的文件创建了描述符副本,那么这个打开的文件只会在原始的描述符以及所有其他的副本都被关闭时才会移除 。

文件描述(file description)实际上是内核中的一个数据结构,而用户空间中的文件描述符(file descriptor)只不过是一个整数,epoll 的兴趣列表实际关注的是内核中的数据结构