IO多路转接:epoll
什么是EPOLL?
本质是一颗红黑树。监听事件的文件描述符则是红黑树的子节点,提高程序在大量并发连接中只有少量活跃的情况下 CPU的利用率
(比如我有1000个并发连接,但只有[3, 500, 1000] 有数据传输,此时使用select()轮询则效率过低,而使用红黑树的epoll则可以快速返回响应的fd)
epoll是select和poll的升级版,相较于这两个前辈,epoll改进了工作方式,因此它更加高效。
- select和poll是基于线性方式处理的,epoll是基于**
红黑树
**来管理待检测集合的。- select和poll每次都会线性扫描整个待检测集合,集合越大速度越慢,epoll使用的是
回调机制
,效率高- select和poll工作过程中存在内核/用户空间数据的频繁拷贝问题,在epoll中内核和用户区使用的是
共享内存
(基于mmap内存映射区实现),省去了不必要的内存拷贝。- select和poll需要对返回的集合进行判断才能知道哪些文件描述符是就绪的,epoll可以
直接得到已就绪
的文件描述符集合,无需再次检测
select/poll低效的原因之一是将“添加/维护待检测任务”和“阻塞进程/线程”两个步骤合二为一。每次调用select都需要这两步操作,然而大多数应用场景中,需要监视的socket个数相对固定,并不需要每次都修改。epoll将这两个操作分开,先用epoll_ctl()维护等待队列,再调用epoll_wait()阻塞进程(解耦)。
如下图所示,每次select/poll的操作都是将添加待检测任务(green)和阻塞(yellow)合在一起
一、 API函数
-
int epoll_create(int size);
返回一个文件描述符,作为红黑树的根节点size
:创建的红黑树的监听节点数量。(Linux内核2.6.8版本以后,这个参数是被忽略,大于0即可)- 返回值:指向新创建的红黑树的根节点的 fd。
-
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
管理红黑树上的文件描述符(添加、修改、删除)-
epfd
:epoll_create()的返回值 -> 代表红黑树的根节点 -
op
:对该监听红黑树所做的操作EPOLL_CTL_ADD
:添加fd到监听红黑树EPOLL_CTL_MOD
:修改fd在监听红黑树的监听事件(读、写、异常事件)EPOLL_CTL_DEL
:从监听红黑树上删除fd(取消监听)
-
fd
:待监听的fd (监听连接事件->lfd, 监听读写事件->cfd) -
event
:epoll事件,用来修饰第三个参数对应的文件描述符,指定检测这个文件描述符的什么事件,struct epoll_event
结构体如下:struct epoll_event { uint32_t events; epoll_data_t data; }; /*events:委托epoll检测的事件 EPOLLIN 读 EPOLLOUT 写 EPOLLERR 异常 ☆data:用户数据变量,这是一个联合体类型,通常情况下使用里边的fd成员,用于存储待检测的文件描述符的值,在调用epoll_wait()函数的时候这个值会被传出。 */ typedef union epoll_data { void *ptr; //反应堆模型->用作回调函数 int fd; //对应监听事件的fd,与epoll_ctl第三参数对应 } epoll_data_t;
-
-
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
检测epoll树中是否有就绪的文件描述符epfd
:epoll_create()的返回值 -> 代表红黑树的根节点events
:传出参数,这是一个结构体数组的地址,,存储满足监听条件的fd结构体(struct epoll_event
)。- 只要是在数组里的全部都是满足监听条件的fd。则无需轮询查询fd
maxevents
:第二个参数中结构体数组的大小(元素个数)。timeout
:超时阻塞- 返回值: 满足监听事件的总个数,可用作循环上限
二、 epoll的使用
操作步骤:
-
创建监听的套接字,绑定,监听三件套
int lfd = socket(AF_INET, SOCK_STREAM, 0); //设置端口复用(可选) setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); int ret = bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)); listen(lfd, 128);
-
创建epoll实例对象
int epfd = epoll_create(100);
-
将用于监听的套接字添加到epoll实例中
struct epoll_event ev; ev.events = EPOLLIN; // 检测lfd读读缓冲区是否有数据 ev.data.fd = lfd; int ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
-
检测添加到epoll实例中的文件描述符是否已就绪,并将这些已就绪的文件描述符进行处理
int num = epoll_wait(epfd, evs, size, -1);
-
如果是监听文件描述符
lfd
,和新客户端建立连接,将得到的文件描述符添加到epoll实例中。for(int i=0; i<num; ++i) { // 取出当前的文件描述符,其中evs是epoll_wait的第二个参数 int curfd = evs[i].data.fd; if(curfd == lfd){ int cfd = accept(curfd, NULL, NULL); ev.events = EPOLLIN; ev.data.fd = cfd; // 新得到的文件描述符添加到epoll模型中, 下一轮循环的时候就可以被检测了 epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev); }
-
如果是通信的文件描述符,和对应的客户端通信,如果连接已断开,将该文件描述符从epoll实例中删除
int len = recv(curfd, buf, sizeof(buf), 0); if(len == 0){ // 将这个文件描述符从epoll模型中删除 epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL); close(curfd); }
-
三、epoll的工作模式
3.1 水平模式(LT模式)
LT(level triggered)是默认的工作方式,缓冲区剩余未读尽的数据会导致
epoll_wait()
的返回。并且同时支持block和no-block socket
-
触发条件:只要文件描述符(fd)上有未处理的数据,就会触发事件通知。
-
处理逻辑:可以多次触发通知,直到数据被完全读取或写入。
-
特性:
-
默认模式:
epoll
默认工作在 LT 模式。 -
适用场景:适合对事件不敏感的场景,编程简单,容易调试。
-
可能的缺点:容易出现性能问题,因为可能反复触发同一事件。
-
3.2 边沿模式(ET模式)
高速工作方式,缓冲区剩余未读尽的数据不会导致
epoll_wait()
的返回。只支持no-block socket
-
触发条件:只有文件描述符状态从未就绪变为就绪时,才会触发一次事件通知。
- ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。
-
处理逻辑:事件不会重复触发,程序必须一次性将数据全部处理完。
-
特性:
-
更高性能:减少了事件触发的次数,适合高性能场景。
-
更复杂:需要使用非阻塞 I/O,并确保数据被完整处理,容易出现问题。
-
适用场景:对事件处理要求严格、注重性能的场景。
-
epoll的边沿模式下 epoll_wait()检测到文件描述符有新事件才会通知,如果不是新的事件就不通知,通知的次数比水平模式少,效率比水平模式要高。
epoll在边沿模式下,必须要将套接字设置为非阻塞模式。 -> 需要处理读完的异常(当缓冲区数据被读完了,调用的read()/recv()函数还会继续从缓冲区中读数据,此时函数调用就失败了,返回-1)
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 设置边沿模式、
// 将文件描述符设置为非阻塞
// 得到文件描述符的属性
int cfd = accept(curfd, NULL, NULL);
int flag = fcntl(cfd, F_GETFL);
flag |= O_NONBLOCK;
fcntl(cfd, F_SETFL, flag);
//...
int len = recv(curfd, buf, sizeof(buf), 0);
if(len < 0){
if(errno == EAGAIN){
printf("数据读完了...\n");
break;
}
else{
perror("recv");
exit(0);
}
}
}