引言:
Epoll 是 Linux IO 多路复用的管理机制。作为现在 Linux 平台高性能网络 IO 必要的组件。内核的实现可以参照Linux内核下的:fs/eventpoll.c。 epoll的重要性和使用方法在我之前的文章中也谈过,感兴趣的朋友可以去看看 《深入剖析linux 网络io多路复用》,网址:深入剖析linux 网络io多路复用_全宇宙最帅的程序员的博客-CSDN博客
突然兴起,想要研究一下epoll内核代码实现,接下来把我的学习成果与大家分享。
内核的 epoll 可以从四方面来理解:
1. epoll 的数据结构,rbtree 对的存储,ready 队列存储就绪 io;
2. epoll 的线程安全,SMP 的运行,以及防止死锁;
3. epoll 内核回调;
4. epoll 的 LT(水平触发)与 ET(边沿触发)。 下面从这四个方面来谈谈epoll:
一. epoll 数据结构
epoll给调用者提供三个接口:
int epoll_create(int size)
- 参数
size
指定了epoll
实例所能处理的最大文件描述符数目,但实际上这个参数已经不再起作用,通常可以将其设置为任意值。 - 返回值是一个非负整数,称为
epoll
实例的文件描述符,用于后续的操作。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
- 参数
epfd
是epoll
实例的文件描述符。 - 参数
op
表示要执行的操作类型,可以是以下三种之一:EPOLL_CTL_ADD
:向epoll
实例中添加新的文件描述符。EPOLL_CTL_MOD
:修改已有文件描述符的监听事件。EPOLL_CTL_DEL
:从epoll
实例中删除文件描述符。
- 参数
fd
是要进行操作的文件描述符。 - 参数
event
是一个指向struct epoll_event
结构体的指针,用于指定相关的事件和数据。 - 返回值为0表示操作成功,-1表示操作失败。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
- 参数
epfd
是epoll
实例的文件描述符。 - 参数
events
是一个指向struct epoll_event
结构体数组的指针,用于接收就绪的事件。 - 参数
maxevents
表示最多可以接收的事件数目。 - 参数
timeout
表示等待的超时时间,单位为毫秒。如果设置为负数,则表示永久等待直到有事件到来。 - 返回值为就绪事件的数目,如果返回值为0,表示超时但没有事件到来,-1表示出现错误。
那么,这个三个函数时是怎么实现和为什么怎么实现呢?
当调用者使用epoll_create去创建一个epoll的时候,内核会初始化一个结构体:eventpoll;
该结构体如下:
struct eventpoll{
ep_rb_tree rbr; //epoll中的红黑树
LIST_HEAD(,epitem) rdlist //epoll中的就绪链表
int rdnum; //处于就绪状态的事件数量
int waiting; //正在等待的处理事件的线程数量
pthread_mutex_t mtx; //锁住红黑树
pthread_spinlock_t lock; // 锁住就绪链表
pthread_cond_t cond; //通知和阻塞事件
pthread_mutex_t cdmtx //给condition加锁
}
当调用者使用epoll_ctl去操作epoll中的事件时:内核又会初始化一个结构体:epitem;
该结构体用于表示一个文件描述符及其关联的事件,在epoll
实例中维护文件描述符和事件之间的对应关系,包括需要监控的事件、当前状态等信息。
该结构体如下:
struct epitem {
RB_ENTRY(epitem) rbn; //该事件在红黑树中的节点
LIST_ENTRY(epitem) rblink; //该事件在双向链表中的节点
int rdy; //标志位,该事件就绪时,rdy为1,未就绪时,为0
int socket; //该事件对应的文件socketfd
struct epoll_event event; /*
epoll_event:监控的事件类型和用户数据。它是struct epoll_event类型的结构体
,用于描述需要监控的事件及用户自定义的数据。
该结构体包括两个对象:
1. events:表示监控的事件类型,可包含多个事件类型。
2 .data:用户自定义的数据,这些数据与文件描述符关联,
当相应的事件就绪时,将传递给用户程序。
*/
}
用一个形象的图表示这些数据结构:
最上面的list 用来存储准备就绪的 IO。对于数据结构主要讨论两方面:insert和remove;
何时将数据插入到 rdlist 中呢?当内核 IO 准备就绪的时候,则会执行 epoll_event_callback 的回调函数,将 epitem添加到 list 中。 那何时删除 list 中的数据呢?当 epoll_wait 激活重新运行的时候,将 rdlist 的 epitem 逐一 copy 到 events 参数中。 rbtree 用来存储所有 io 的数据,方便快速通过io_fd 查找. 对于 rbtree 何时添加:执行 epoll_ctl EPOLL_CTL_ADD 操作,将 epitem 添加到 rbtree 中。何时删除呢?执行 epoll_ctl EPOLL_CTL_DEL 操作,将 epitem 从rbtree 中删除。 list 与 rbtree 的操作又如何做到线程安全?
二. epoll锁机制
rdlist 的操作,rbtree 的操作,epoll_wait 的等待。 rdist 使用最小粒度的锁 spinlock,便于在多线程下,添加操作的时候,能够快速操作rdlist:
1. rdlist的添加:
pthread_spin_lock(&ep->lock); //获取 spinlock。
ep->rdy=1; //epitem 的 rdy 置为 1,代表 epitem 已经在就绪队列中,后续再触发相同事件就只需更改 event
LIST_INSERT_HEAD(&ep->rdlist,epi,rdlink); //添加到rdlist中
ep->rdnum++; //将eventpoll 的rdnum域加1
pthread_spin_unlock(&ep->lock); //释放 spinlock
2. rdlist的删除:
pthread_spin_lock(&ep->lock);
int cnt = 0;
int num =(ep->rdnum > maxevents ? maxevents : ep->rdnum); //判读 rdnum与maxevents的大小,避免 event 溢出
int i = 0;
while (num != O && !LIST_EMPTY(&ep->rdlist)){ //循环遍历 rdlist,判断添加 rdlist 不能为空
struct epitem *epi = LIST_FIRST(&ep->rdlist); //获取 rdlist 首个结点
LIST_REMOVE(epi, rdlink); //移除 rdlist 首个结点
epi->rdy =0; //将 epitem 的 rdy 域置为 0,标识 epitem 不再就绪队列中
memcpy(&events[i++], &epi->event, sizeof(struct epoll_event));//copy epitem 的 event 到用户空间的 events
num --;
cnt ++;
ep->rdnum --;
}
pthread_spin_unlock(&ep->lock);
3.rbtree的添加:
pthread mutex lock(&ep->mtx); //获取互斥锁
struct epitem tmp;
tmp.sockfd = sockid;
struct epitem *epi = RB_FIND( epoll rb socket, &ep->rbr, &tmp);//查找 sockid 的 epitem 是否存在。存在则不能添加,不存在则可以添加
if (epi){
pthread mutex unlock(&ep->mtx);
return -1;
}
epi = (struct epitem*)calloc(1, sizeof(struct epitem));
if (!epi) {
pthread mutex unlock(&ep->mtx);
errno = -ENOMEM:
return -1;
}
epi->sockfd = sockid;
memcpy(&epi->event, event, sizeof(struct epoll event)); //将设置的 event 添加到 epitem 的 event 域
epi = RB_INSERT( epoll rb socket,&ep->rbr, epi);
assert(epi == NULL);
pthread mutex unlock(&ep->mtx);
4.rbtree的删除:
pthread mutex_lock(&ep->mtx); //获取互斥锁
struct epitem tmp;
tmp .sockfd = sockid;
//删除 sockid 的结点,如果不存在,则 rbtree 返回-1
struct epitem *epi = RB_REMOVE(_epoll_rb_socket, &ep->rbr, &tmp);
if (!epi) {
pthread mutex unlock(&ep->mtx);
return -1;
}
free(epi);
pthread mutex unlock(&ep->mtx);
三:epoll_wait()的挂起状态, epoll_callback()这两者的机制相对复杂,我将在以后的文章中,单独阐述。
四:epoll的水平触发(LT)和边缘触发(ET):
LT(水平触发)与 ET(边沿触发)是电子信号里面的概念。不清楚可以 man epoll 查看的。 如下图所示:
比如:event = EPOLLIN | EPOLLLT,将 event 设置为 EPOLLIN 与水平触发。只要 event 为 EPOLLIN 时就能不断调用 epoll 回调函数。 比如: event = EPOLLIN | EPOLLET,event 如果从 EPOLLOUT 变化为 EPOLLIN 的时候,就会触 发。在此情形下,变化只发生一次,故只调用一次 epoll 回调函数。关于水平触发与边沿触 发放在 epoll 回调函数执行的时候,如果为 EPOLLET(边沿触发),与之前的 event 对比,如 果发生改变则调用 epoll 回调函数,如果为 EPOLLLT(水平触发),则查看 event 是否为 EPOLLIN, 即可调用 epoll 回调函数。
- 水平触发(Level-Triggered)模式:
在水平触发模式下,当 I/O 事件就绪时,epoll_wait
将立即返回该事件,并且一直保持就绪状态,直到应用程序处理完该事件。如果应用程序没有读取所有可用的数据或没有执行相应的操作,epoll_wait
会一直返回该事件。
特点:
- 直到事件被处理完成之前,内核会持续不断地通知应用程序该事件的状态。
- 应用程序需要及时处理事件并执行相应的操作,否则可能导致资源浪费。
- 因为事件在就绪时一直保持,所以应用程序可以多次调用
epoll_wait
来获取事件状态。
- 边缘触发(Edge-Triggered)模式:
在边缘触发模式下,当 I/O 事件就绪时,epoll_wait
只会通知一次该事件,即使应用程序没有处理完该事件,也不会再次通知。
特点:
epoll_wait
只通知应用程序一次事件就绪,不会重复通知。- 如果应用程序没有及时处理事件,可能会错过一些事件。
- 应用程序需要确保在每次
epoll_wait
返回后,处理完所有就绪事件,防止事件丢失。
边缘触发模式相比水平触发模式,可以减少事件通知的次数,降低系统资源消耗。但它也要求应用程序能够高效地处理事件,以免错过事件。
为了使用边缘触发模式,需要通过 epoll_ctl
函数将关联的文件描述符设置为边缘触发模式。
总结:
- 水平触发模式下,事件就绪后会持续通知,直到应用程序处理完成。
- 边缘触发模式下,事件就绪只会通知一次,应用程序需要确保及时处理事件,以免错过。
- 边缘触发模式相对于水平触发模式,可以减少事件通知次数和系统开销,但要求应用程序能够高效处理事件。
-------------------------------------------------------------------------------------------------------------------end