当前位置: 代码迷 >> 综合 >> Linux网络编程 - epoll
  详细解决方案

Linux网络编程 - epoll

热度:20   发布时间:2024-01-26 19:52:32.0

下面有一张图,这张图来自 The Linux Programming Interface(No Starch Press)。它直观地为我们展示了 select、poll、epoll 几种不同的 I/O 复用技术在面对不同文件描述符大小时的表现差异。

                  

从图中可以明显地看到,epoll 的性能是最好的,即使在多达 10000 个文件描述的情况下,其性能的下降和有 10 个文件描述符的情况相比,差别也不是很大。而随着文件描述符的增大,常规的 select 和 poll 方法性能逐渐变得很差。

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。

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() 的输入 flags 为 0,则和 epoll_create() 一样,内核自动忽略。可以增加如 EPOLL_CLOEXEC 的额外选项,如果你有兴趣的话,可以研究一下这个选项有什么意义。

epoll_ctl:

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

在创建完 epoll 实例之后,可以通过调用 epoll_ctl 往这个 epoll 实例增加或删除监控的事件。

第一个参数 epfd 是刚刚调用 epoll_create 创建的 epoll 实例描述字,可以简单理解成是 epoll 句柄。

第二个参数表示增加还是删除一个监控事件,它有三个选项可供选择:

  • EPOLL_CTL_ADD: 向 epoll 实例注册文件描述符对应的事件。
  • EPOLL_CTL_DEL:向 epoll 实例删除文件描述符对应的事件。
  • EPOLL_CTL_MOD: 修改文件描述符对应的事件。

第三个参数是注册的事件的文件描述符,比如一个监听套接字。

第四个参数表示的是注册的事件类型,并且可以在这个结构体里设置用户需要的数据,其中最为常见的是使用联合结构里的 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 例子

#define MAXEVENTS 128char 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;elsereturn 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 = socket(AF_INET, SOCK_STREAM, 0);fcntl(listen_fd, F_SETFL, O_NONBLOCK);struct sockaddr_in server_addr;bzero(&server_addr, sizeof(server_addr));server_addr.sin_family = AF_INET;server_addr.sin_addr.s_addr = htonl(INADDR_ANY);server_addr.sin_port = htons(port);int on = 1;setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));int rt1 = bind(listen_fd, (struct sockaddr *) &server_addr, sizeof(server_addr));if (rt1 < 0) {printf("bind failed \n");exit(0);}int rt2 = listen(listenfd, LISTENQ);if (rt2 < 0) {printf("listen failed \n");exit(0);}signal(SIGPIPE, SIG_IGN);efd = epoll_create1(0);if (efd == -1) {printf("epoll create failed\n");return 0;}event.data.fd = listen_fd;event.events = EPOLLIN | EPOLLET;/*调用 epoll_ctl 将监听套接字对应的 I/O 事件进行了注册,这样在有新的连接建立之后,就可以感知到。注意这里使用的是 edge-triggered(边缘触发)*/if (epoll_ctl(efd, EPOLL_CTL_ADD, listen_fd, &event) == -1) {printf("epoll_ctl add listen fd failed\n");}/* Buffer where events are returned */events = calloc(MAXEVENTS, sizeof(event));/*主循环调用 epoll_wait 函数分发 I/O 事件,当 epoll_wait 成功返回时,通过遍历返回的 event 数组,就直接可以知道发生的 I/O 事件*/while (1) {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);if (fd < 0) {printf("accept failed\n");} else {fcntl(fd, F_SETFL, O_NONBLOCK);event.data.fd = fd;event.events = EPOLLIN | EPOLLET; //edge-triggered/*调用 epoll_ctl 把已连接套接字对应的可读事件注册到 epoll 实例中*/if (epoll_ctl(efd, EPOLL_CTL_ADD, fd, &event) == -1) {printf("epoll_ctl add connection fd failed\n");}}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) {printf("read error\n");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) {printf("write error\n");}}}}}}free(events);close(listen_fd);
}

edge-triggered VS level-triggered

两者的区别,条件触发的意思是只要满足事件的条件,比如有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。一般我们认为,边缘触发的效率比条件触发的效率要高,这一点也是 epoll 的杀手锏之一。

 

总之, Linux 中 epoll 的出现,为高性能网络编程补齐了最后一块拼图。epoll 通过改进的接口设计,避免了用户态 - 内核态频繁的数据拷贝,大大提高了系统性能。在使用 epoll 的时候,我们一定要理解条件触发和边缘触发两种模式。条件触发的意思是只要满足事件的条件,比如有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。

 

温故而知新 !