epoll的历史
早在Linux实现epoll之前,Windows系统就已经在1994年引入了IOCP,这是一个异步I/O模型,用来支持高 并发的网络I/O,而著名的FreeBSD在2000年引入了Kqueue⸺一个I/O事件分发框架。
Linux在2002年引入了epoll,不过相关工作的讨论和设计早在2000年就开始了。如果你感兴趣的话,可以 http://lkml.iu.edu/hypermail/linux/kernel/0010.3/0003.html">点击这里看一下里面的讨论。
为什么Linux不把FreeBSD的kqueue直接移植过来,而是另辟蹊径创立了epoll呢?
让我们先看下kqueue的用法,kqueue也需要先创建一个名叫kqueue的对象,然后通过这个对象,调用 kevent函数增加感兴趣的事件,同时,也是通过这个kevent函数来等待事件的发生。
int kqueue(void);
int kevent(int kq, const struct kevent *changelist, int nchanges,
struct kevent *eventlist, int nevents,
const struct timespec *timeout);
void EV_SET(struct kevent *kev, uintptr_t ident, short filter,
u_short flags, u_int fflags, intptr_t data, void *udata);
struct kevent {
uintptr_t ident; /* identifier (e.g., file descriptor) */
short filter; /* filter type (e.g., EVFILT_READ) */
u_short flags; /* action flags (e.g., EV_ADD) */
u_int fflags; /* filter-specific flags */
intptr_t data; /* filter-specific data */
void *udata; /* opaque user data */
};
Linus在他最初的设想里,提到了这么一句话,也就是说他觉得类似select或poll的数组方式是可以的,而队列方式则是不可取的。
So sticky arrays of events are good, while queues are bad. Let’s take that as one of the fundamentals.
在最初的设计里,Linus等于把keque里面的kevent函数拆分了两个部分,一部分负责事件绑定,通过 bind_event函数来实现;另一部分负责事件等待,通过get_events来实现。
struct event {
unsigned long id; /* file descriptor ID the event is on */
unsigned long event; /* bitmask of active events */
};
int bind_event(int fd, struct event *event);
int get_events(struct event * event_array, int maxnr, struct timeval *tmout);
和最终的epoll实现相比,前者类似epoll_ctl,后者类似epoll_wait,不过原始的设计里没有考虑到创建 epoll句柄,在最终的实现里增加了epoll_create,支持了epoll句柄的创建。2002年,epoll最终在Linux 2.5.44中首次出现,在2.6中趋于稳定,为Linux的高性能网络I/O画上了一段句号。
epoll的用法
epoll可以说是和poll非常相似的一种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实例之后,可以通过调用epoll_ctl往这个epoll实例增加或删除监控的事件。函数epll_ctl有4 个入口参数。
第一个参数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_data_t data;
};
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事件发生。
Demo
#include
#include "lib/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_create0创建了一个epoll实例。
if (efd == -1) {
error(1, errno, "epoll create failed");
}
event.data.fd = listen_fd;
event.events = EPOLLIN | EPOLLET;
if (epoll_ctl(efd, EPOLL_CTL_ADD, listen_fd, &event) == -1) {//调用epoll_ctl将监听套接字对应的I/O事件进行了注册
error(1, errno, "epoll_ctl add listen fd failed");
}
/* Buffer where events are returned */
events = calloc(MAXEVENTS, sizeof(event));//返回的event数组分配了内存
while (1) {
n = epoll_wait(efd, events, MAXEVENTS, -1);//主循环调用epoll_wait函数分发I/O事件
printf("epoll_wait wakeup\n");//当epoll_wait成功返回时,通过遍历返回的event数组,就直接可 以知道发生的I/O事件。
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_ctl把已连接套接字对应的可读事件注册到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);
}
epoll的性能分析
epoll的性能凭什么就要比poll或者select好呢?这要从两个⻆度来说明。
第一个⻆度是事件集合。在每次使用poll或select之前,都需要准备一个感兴趣的事件集合,系统内核拿到事件集合,进行分析并在内核空间构建相应的数据结构来完成对事件集合的注册。而epoll则不是这样,epoll维护了一个全局的事件集合,通过epoll句柄,可以操纵这个事件集合,增加、删除或修改这个事件集合里的某个元素。要知道在绝大多数情况下,事件集合的变化没有那么的大,这样操纵系统内核就不需要每次重新扫描事件集合,构建内核空间数据结构。
第二个⻆度是就绪列表。每次在使用poll或者select之后,应用程序都需要扫描整个感兴趣的事件集合,从中找出真正活动的事件,这个列表如果增⻓到10K以上,每次扫描的时间损耗也是惊人的。事实上,很多情况下扫描完一圈,可能发现只有几个真正活动的事件。而epoll则不是这样,epoll返回的直接就是活动的事件列表,应用程序减少了大量的扫描时间。
此外, epoll还提供了更高级的能力⸺边缘触发。
如果某个套接字有100个字节可以读,边缘触发和条件触发都会产生read ready notification事件,如果应用程序只读取了50个字节,边缘触发就会陷入等待;而条件触发则会因为还有50个字节没有读取完,不断地产生read ready notification事件。
在条件触发下,如果某个套接字缓冲区可以写,会无限次返回write ready notification事件,在这种情况下,如果应用程序没有准备好,不需要发送数据,一定需要解除套接字上的ready notification事件,否则CPU就直接跪了。边缘触发只会产生一次活动事件,性能和效率更高。不过,程序处理起来要更为小心。