1.从内核看epoll机制
select和poll虽然能够实现IO复用的功能,但是由于设计的缺陷,select和poll无法处理海量的网络连接,并且随着网络连接数量的增加,select和poll效率越来越低。
此时急需一种更为高效的IO复用机制解决海量并发请求问题,epoll机制就是为了解决该问题而诞生的。要理解epoll机制并不容易,很多同学一直学不好epoll,一个很重要的原因是不理解底层实现原理,我们从内核的角度观察epoll具体做了哪些事情,有了这个基础再去学习epoll编程,学习过程将会变得非常简单。
图1 epoll内核实现原理
如图1所示,epoll机制分为两个部分:用户态部分和内核态部分。
用户态部分通过3个系统调用:epoll_create,epoll_ctl,epoll_wait和内核进行交互。内核态部分实现比较复杂,我们将围绕struct eventpoll内核对象来讲解。struct eventpoll对象是epoll机制实现的关键数据结构,包含三个重要成员:rbr(红黑树),rdlist(就绪队列),wq(等待队列)。
-
红黑树:用于记录用户程序注册的epoll事件。
-
等待队列:epoll线程休眠后,用于唤醒epoll线程。
-
就绪队列:socket接收和发送数据后,就绪队列会记录socket读事件和写事件。
用户程序调用epoll_create函数后,会在内核创建struct eventpoll对象,同时会返回一个文件描述符给用户,该文件描述符用于查询进程文件表,找到对应的文件,再通过文件找到struct eventpoll对象。
用户程序通过epoll_ctl函数添加,修改,删除socket事件,注册成功的socket事件会插入红黑树。socket事件添加成功后,epoll才能监听socket读写事件。
如果epoll就绪队列有就绪事件,用户程序调用epoll_wait函数会成功获取到就绪事件。如果没有就绪事件,则epoll线程陷入休眠。
当socket接收到数据后,通过socket等待队列可以唤醒休眠的epoll线程,并将socket封装成epoll就绪事件插入就绪队列。此时epoll线程已经被唤醒,epoll线程可以将就绪事件拷贝至用户程序。
以上就是epoll内核工作原理,该部分建议反复阅读。
2.epoll编程实战
有了前面epoll内核工作原理的分析,我们对epoll有了更深入的理解。学习epoll编程需要熟练掌握epoll 3个接口:epoll_create、epoll_ctl、epoll_wait。
2.1 编程接口
(1)epoll_create函数
epoll_create函数是一个系统调用,用于在内核创建struct eventpoll实例。
#include <sys/epoll.h>
int epoll_create(int size);
参数:size参数并没有实际意义,但一定要大于0。
返回值:成功返回epoll文件描述符;失败返回-1,并设置errno。
我们看一下内核源码实现:
SYSCALL_DEFINE1(epoll_create, int, size)
{
if (size <= 0) return -EINVAL; //size小于等于0,返回错误
return do_epoll_create(0); //传入参数没用到size
}
(2)epoll_ctl函数
epoll_ctl函数用于向 epoll 实例中添加、修改或删除文件描述符(通常代表一个网络连接或者文件),并设置这些文件描述符感兴趣的事件类型,如可读、可写或者有异常发生。
如图2所示,epoll_ctl函数添加socket事件时,主要做了两件事:
-
插入socket事件节点至红黑树。
-
创建一个等待队列项插入socket等待队列,用于socket接收数据时唤醒epoll线程。
图 2 epoll_ctl工作原理
epoll_ctl函数原型:
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数:
epfd:指向由epoll_create 创建的 epoll 实例的文件描述符。
op:表示要对目标文件描述符执行的操作,可以是以下几个值之一:
-
EPOLL_CTL_ADD:向 epoll 实例中添加一个新的文件描述符。
-
EPOLL_CTL_MOD:修改已存在文件描述符的事件类型。
-
EPOLL_CTL_DEL:从 epoll 实例中删除一个文件描述符。
fd:需要操作的目标文件描述符。
event:指向struct epoll_event结构的指针,该结构指定了需要监听的事件类型。
返回值:成功返回0;失败返回-1,并设置errno。
struct epoll_event结构体定义如下:
struct epoll_event {
uint32_t events;
epoll_data_t data;
};
**events:指定要监听的事件类型,**常见事件类型见表1。
表 1 epoll事件表
data:用户自定义的数据,通常用于存储与文件描述符相关的上下文信息,获取就绪事件成功后,事件数组会记录data数据。
struct epoll_data结构体定义如下:
typedefunion epoll_data {
void *ptr;
int fd; //设置socket文件描述符
uint32_t u32;
uint64_t u64;
} epoll_data_t;
(3)epoll_wait函数
epoll_wait函数用于等待在 epoll 实例上注册的文件描述符上发生的事件。这个函数会阻塞调用线程,直到有事件发生或超时。
图 3 epoll_wait工作原理
如图3所示,用户程序调用epoll_wait后,内核循环检测就绪队列是否有就绪事件,如果有就绪事件,将就绪事件返回给用户,否则继续往下执行,判断epoll是否超时,超时返回0,如果没有超时则将epoll线程挂起,epoll线程陷入休眠状态,同时插入一个epoll等待队列项。
当socket接收到数据后,会通过socket等待队列回调函数去检测epoll等待队列项,并将epoll线程唤醒,epoll线程被唤醒成功后,epoll线程再次查询就绪队列,此时就能成功返回socket事件。
epoll_wait函数原型:
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
参数:
epfd:epoll文件描述符。
events:epoll事件数组。
maxevents:指定events 数组的大小,即可以存储的最大事件数。
timeout:超时时间。
-
-1:表示无限等待,直到有事件发生。
-
0:表示立即返回,不等待任何事件。
-
正数:表示等待的最大时间(毫秒)。
返回值:小于0表示出错;等于0表示超时;大于0表示获取事件成功,返回就绪事件个数。
3.epoll编程流程
前面我们已经学会了使用epoll 3个接口,接下来我们要实现一个完整epoll编程示例,如图4所示,该流程是一个epoll编程流程,我们按照这样一个流程去编写代码,思路会很清晰,不容易出错。
图 4 epoll编程流程图
epoll示例代码如下,为了节省篇幅和易于理解,部分非关键代码已省略。
intmain(int argc, char *argv[]){
structepoll_eventev, events[MAX_EVENTS];
int sock_fd, ret = 0;
int efd = epoll_create(10); //创建epoll实例
ev.data.fd = sock_fd;
ev.events = EPOLLIN;
//注册监听套接字事件
epoll_ctl(efd, EPOLL_CTL_ADD, sock_fd, &ev);
while (1) {
//超时1000毫秒,获取就绪事件
int nfds = epoll_wait(efd, events, MAX_EVENTS, 1000);
if (nfds == -1) return-1; //获取失败退出
elseif (nfds == 0) continue; //超时,继续下一轮事件获取
for (int i = 0; i < nfds; i++) {//轮询就绪事件数组
int fd = events[i].data.fd;
if (fd == sock_fd) { //监听套接字
new_fd = accept(sock_fd, (struct sockaddr *)&peer, &addrlen);
setnonblocking(new_fd); //设置新套接字为非阻塞模式
ev.data.fd = new_fd;
ev.events = EPOLLIN|EPOLLET;
//添加新套接字
epoll_ctl(efd, EPOLL_CTL_ADD, new_fd, &ev);
} else { //业务套接字
if (events[i].events & EPOLLIN) { //EPOLLIN事件
recv(fd, recv_buf, len, 0); //业务套接字接收数据
}
}
}
}
return0;
}
4.epoll常见问题?
(1)ET模式和LT模式区别?
ET模式称为边缘触发,LT模式称为水平触发。
添加socket事件时如果设置为ET模式,当socket接收数据后,epoll就绪队列只会插入一次socket就绪事件,epoll_wait检测到socket读事件后,必须一次性把socket缓冲区数据全部读完,否则数据可能丢失。
如果设置为LT模式,此次调用epoll_wait没有读socket缓冲区,下一次调用epoll_wait依然能够检测到socket就绪事件,直到socket缓冲区数据被读完。
我们通过内核源码观察二者区别。
.......
if(!(epi->event.events & EPOLLET)) { // LT模式
//将取出来的就绪事件,继续插入就绪队列
list_add_tail(&epi->rdllink, &ep->rdllist);
}
(2)epoll高效的秘密?
epoll之所以高效,主要有以下原因:
-
epoll等待队列机制,当就绪队列没有socket事件时主动让出CPU,阻塞进程,提高CPU利用率,就绪队列收到socket事件后,唤醒epoll线程处理。
-
红黑树提高epoll事件增加,删除,修改效率。
-
任务越多,进程出让CPU概率越小,epoll线程工作效率越高,所以epoll非常适合高并发场景。
(3)epoll为阻塞模式是否影响性能?
当epoll_wait未检测到epoll事件时会出让CPU并阻塞进程,这种阻塞是非常有必要的,如果不及时出让CPU会浪费CPU资源,导致其他任务无法抢占CPU,只要socket接收到数据后,及时唤醒epoll进程,就不会影响epoll性能。
(4)socket设置成阻塞和非阻塞?
socket采用非阻塞方式。socket设置成阻塞模式会存在以下几个问题:
-
IO复用通常是一个进程处理多个网络连接,如果socket为阻塞模式,那么其中一个socket阻塞会导致进程阻塞,其他socket也无法读写数据。
-
阻塞的本质是进程状态和上下文切换,频繁的阻塞会把让CPU一直处于上下文切换的状态中,浪费CPU资源。