21.epoll简介

150 阅读7分钟

epoll 的用法

epoll 可以说是和 poll 非常相似的一种 I/O 多路复用技术,有些朋友将 epoll 归为异步 I/O,我觉得这是不正确的。本质上 epoll 还是一种 I/O 多路复用技术, epoll 通过监控注册的多个描述字,来进行 I/O 事件的分发处理。不同于 poll 的是,epoll 不仅提供了默认的 level-triggered(条件触发)机制,还提供了性能更为强劲的 edge-triggered(边缘触发)机制。至于这两种机制的区别,我会在后面详细展开。

使用 epoll 进行网络程序的编写,需要三个步骤,分别是 epoll_create,epoll_ctl 和 epoll_wait。接下来我对这几个 API 详细展开讲一下。

epoll_create

int epoll_create(int size);
int epoll_create1(int flags);

        返回值: 若成功返回一个大于 0 的值,表示 epoll 实例;若返回 -1 表示出错

epoll_create() 方法创建了一个 epoll 实例,从 Linux 2.6.8 开始,参数 size 被自动忽略,但是该值仍需要一个大于 0 的整数。这个 epoll 实例被用来调用 epoll_ctl 和 epoll_wait,如果这个 epoll 实例不再需要,比如服务器正常关机,需要调用 close() 方法释放 epoll 实例,这样内核可以回收 epoll 实例所分配使用的内核资源。

关于参数 size,在一开始的 epoll_create 实现中,是用来告知内核期望监控的文件描述字大小,然后内核使用这部分的信息来初始化内核数据结构,在新的实现中,这个参数不再被需要,因为内核可以动态分配需要的内核数据结构。我们只需要注意,每次将 size 设置成一个大于 0 的整数就可以了。

epoll_create1() 的用法和 epoll_create() 基本一致,如果 epoll_create1() 的输入 size 大小为 0,则和 epoll_create() 一样,内核自动忽略。可以增加如 EPOLL_CLOEXEC 的额外选项

epoll_ctl

 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
	
        返回值: 若成功返回 0;若返回 -1 表示出错

通过调用 epoll_ctl 往这个 epoll 实例增加或删除监控的事件。函数 epll_ctl 有 4 个入口参数。

  1. epfd 是刚刚调用 epoll_create 创建的 epoll 实例描述字,可以简单理解成是 epoll 句柄。
  2. 表示增加还是删除一个监控事件,它有三个选项可供选择:
    • EPOLL_CTL_ADD: 向 epoll 实例注册文件描述符对应的事件;
    • EPOLL_CTL_DEL:向 epoll 实例删除文件描述符对应的事件;
    • EPOLL_CTL_MOD: 修改文件描述符对应的事件。
  3. 注册的事件的文件描述符,比如一个监听套接字。
  4. 表示的是注册的事件类型,并且可以在这个结构体里设置用户需要的数据,其中最为常见的是使用联合结构里的 fd 字段,表示事件所对应的文件描述符。
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 */
 };

在前面介绍 poll 的时候已经接触过基于 mask 的事件类型了,这里 epoll 仍旧使用了同样的机制,我们重点看一下这几种事件类型:

  • EPOLLIN:表示对应的文件描述字可以读;
  • EPOLLOUT:表示对应的文件描述字可以写;
  • EPOLLRDHUP:表示套接字的一端已经关闭,或者半关闭;
  • EPOLLHUP:表示对应的文件描述字被挂起;
  • EPOLLET:设置为 edge-triggered,默认为 level-triggered。

epoll_wait

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

  返回值: 成功返回的是一个大于 0 的数,表示事件的个数;返回 0 表示的是超时时间到;若出错返回 -1.

epoll_wait() 函数类似之前的 poll 和 select 函数,调用者进程被挂起,在等待内核 I/O 事件的分发。

这个函数的第一个参数是 epoll 实例描述字,也就是 epoll 句柄。

第二个参数返回给用户空间需要处理的 I/O 事件,这是一个数组,数组的大小由 epoll_wait 的返回值决定,这个数组的每个元素都是一个需要待处理的 I/O 事件,其中 events 表示具体的事件类型,事件类型取值和 epoll_ctl 可设置的值一样,这个 epoll_event 结构体里的 data 值就是在 epoll_ctl 那里设置的 data,也就是用户空间和内核空间调用时需要的数据。

第三个参数是一个大于 0 的整数,表示 epoll_wait 可以返回的最大事件值。

第四个参数是 epoll_wait 阻塞调用的超时值,如果这个值设置为 -1,表示不超时;如果设置为 0 则立即返回,即使没有任何 I/O 事件发生。

epoll 例子

代码解析

下面我们把原先基于 poll 的服务器端程序改造成基于 epoll 的:

#include "common.h"

#define MAXEVENTS 128

char rot13_char(char c) {
    if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
        return c + 13;
    else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
        return c - 13;
    else
        return c;
}

int main(int argc, char **argv) {
    int listen_fd, socket_fd;
    int n, i;
    int efd;
    struct epoll_event event;
    struct epoll_event *events;

    listen_fd = tcp_nonblocking_server_listen(SERV_PORT);

    efd = epoll_create1(0);//创建 epoll 实例。
    if (efd == -1) {
        error(1, errno, "epoll create failed");
    }

    ////将监听套接字对应的 I/O 事件进行了注册。这里使用的是 edge-triggered(边缘触发)。
    event.data.fd = listen_fd;
    event.events = EPOLLIN | EPOLLET;
    if (epoll_ctl(efd, EPOLL_CTL_ADD, listen_fd, &event) == -1) {
        error(1, errno, "epoll_ctl add listen fd failed");
    }

  //为返回的 event 数组分配了内存。
    events = calloc(MAXEVENTS, sizeof(event));

    while (1) {//主循环调用 epoll_wait 分发 I/O 事件,成功时遍历返回的 event 数组,可以知道发生的 I/O 事件。
        n = epoll_wait(efd, events, MAXEVENTS, -1);
        printf("epoll_wait wakeup\n");
        for (i = 0; i < n; i++) {
            if ((events[i].events & EPOLLERR) || (events[i].events & EPOLLHUP) || (!(events[i].events & EPOLLIN))){
                fprintf(stderr, "epoll error\n");
                close(events[i].data.fd);
                continue;
            } else if (listen_fd == events[i].data.fd) {//监听套接字上有事件发生 
                struct sockaddr_storage ss;
                socklen_t slen = sizeof(ss);
                int fd = accept(listen_fd, (struct sockaddr *) &ss, &slen);//调用 accept 获取已建立连接
                if (fd < 0) {
                    error(1, errno, "accept failed");
                } else {
                    make_nonblocking(fd);//将该连接设置为非阻塞
                    event.data.fd = fd;
                    event.events = EPOLLIN | EPOLLET; //edge-triggered
                    if (epoll_ctl(efd, EPOLL_CTL_ADD, fd, &event) == -1) {//把已连接套接字对应的可读事件注册到 epoll 实例中
                        error(1, errno, "epoll_ctl add connection fd failed");
                    }
                }
                continue;
            } else {//处理已连接套接字上的可读事件,读取字节流,编码后再回应给客户端。
                socket_fd = events[i].data.fd;
                printf("get event on socket fd == %d \n", socket_fd);
                while (1) {
                    char buf[512];
                    if ((n = read(socket_fd, buf, sizeof(buf))) < 0) {
                        if (errno != EAGAIN) {
                            error(1, errno, "read error");
                            close(socket_fd);
                        }
                        break;
                    } else if (n == 0) {
                        close(socket_fd);
                        break;
                    } else {
                        for (i = 0; i < n; ++i) {
                            buf[i] = rot13_char(buf[i]);
                        }
                        if (write(socket_fd, buf, n) < 0) {
                            error(1, errno, "write error");
                        }
                    }
                }
            }
        }
    }

    free(events);
    close(listen_fd);
}

实验

启动该服务器:

$./epoll01
epoll_wait wakeup
epoll_wait wakeup
epoll_wait wakeup
get event on socket fd == 6
epoll_wait wakeup
get event on socket fd == 5
epoll_wait wakeup
get event on socket fd == 5
epoll_wait wakeup
get event on socket fd == 6
epoll_wait wakeup
get event on socket fd == 6
epoll_wait wakeup
get event on socket fd == 6
epoll_wait wakeup
get event on socket fd == 5

再启动几个 telnet 客户端,可以看到有连接建立情况下,epoll_wait 迅速从挂起状态结束;并且套接字上有数据可读时,epoll_wait 也迅速结束挂起状态,这时候通过 read 可以读取套接字接收缓冲区上的数据。

edge-triggered VS level-triggered

对于 edge-triggered 和 level-triggered, 官方的说法是一个是边缘触发,一个是条件触发。我们用程序来说明一下这两者之间的不同。

在这两个程序里,即使已连接套接字上有数据可读,我们也不调用 read 函数去读,只是简单地打印出一句话。

第一个程序我们设置为 edge-triggered,即边缘触发。开启这个服务器程序,用 telnet 连接上,输入一些字符,我们看到,服务器端只从 epoll_wait 中苏醒过一次,就是第一次有数据可读的时候。

$./epoll02
epoll_wait wakeup
epoll_wait wakeup	
get event on socket fd == 5
$telnet 127.0.0.1 43211	
Trying 127.0.0.1...
Connected to 127.0.0.1.	
Escape character is '^]'.
asfafas

第二个程序我们设置为 level-triggered,即条件触发。然后按照同样的步骤来一次,观察服务器端,这一次我们可以看到,服务器端不断地从 epoll_wait 中苏醒,告诉我们有数据需要读取。

$./epoll03	
epoll_wait wakeup	
epoll_wait wakeup	
get event on socket fd == 5	
epoll_wait wakeup	
get event on socket fd == 5
epoll_wait wakeup	
get event on socket fd == 5
epoll_wait wakeup
get event on socket fd == 5	
...

条件触发的意思是只要满足事件的条件,比如有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。

即因为我们并不读取输入,所以会一直处于就绪状态,条件触发在每次主循环epoll_wait时都将事件传递给用户,而边缘触发则只在第一次传给用户,不管你是否读取,只有在状态改变后才会再次触发,即从就绪态转变为非就绪态。

边缘触发模式的优点是可以减少应用程序的系统调用次数,提高系统的性能。但是,由于epoll只在文件描述符状态发生变化时通知应用程序,因此应用程序需要及时处理所有就绪的文件描述符,否则可能会导致数据丢失或者阻塞。

总的来说,边缘触发适用于需要高性能的场景,条件触发适用于需要保证数据完整性的场景。应用程序需要根据自己的需求选择合适的工作模式