场景:
100w用户同时与服务器(一个进程)保持TCP连接,但每一时刻只有几十或几百个TCP连接是活跃的(收发TCP包),如何实现?
select/poll实现:用户发送100w个fd给内核去查找活跃的TCP(有事件发生)——用户态到内核态内存拷贝——最多只能处理几千(一般1024)并发连接
epoll实现:在内核申请简易文件系统。在启动时创建epoll对象,需要时添加删除fd(100w),就绪fd存队列中,epoll_wait时无需传100wfd,也无需遍历这100wfd,返回队列即可
概念
基于内核2.6,相比select、poll,无描述符数量限制。epoll使用一个文件描述符管理多个文件描述符,将相应事件存储在内核的事件表中,在用户空间和内核空间的copy只需一次???
Linux API(system call)
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll_create
原型
int epoll_create(int size);
功能
在内核创建epoll实例,并返回相应epfd给用户态
参数
- size linux2.6后已忽略,默认填1
返回值
0 epfd
- -1 errno
备注
close(epfd)或内核自动释放
epoll_ctl
原型
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
功能
为每个fd单独注册事件(不同于select、poll为fd_set注册事件)
参数
- epfd 相当于红黑树根节点
- op 控制操作
- EPOLL_CTL_ADD 注册事件,即添加节点
- EPOLL_CTL_DEL 移除事件,即删除节点
- EPOLL_CTL_MOD 修改事件,即变更节点状态
- fd 目标文件描述符(connfd、listenfd、pipefd)
- event 事件(EPOLLIN、EPOLLOUT、EPOLLET等)
返回值
成功返回0,失败返回-1并设置errno
备注
epoll_wait
原型
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
功能
获取就绪事件
参数
- epfd
- events 保存就绪事件数组(用户态)
- maxevents 数组最大空间
- timeout 超时时间,超时后将阻塞,单位毫秒
- -1 无超时限制
- 0 非阻塞,调用后立即返回
0
返回值
成功返回就绪事件fd数量(可用于遍历数组),失败返回-1并设置errno
备注
struct epoll_event
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 */
} __EPOLL_PACKED;
EPOLL事件类型
EPOLLIN | 可读事件 | fd缓冲区有数据;sockfd正常关闭 |
---|---|---|
EPOLLOUT | 可写事件 | fd缓冲区空 |
EPOLLET | 边沿触发模式 | |
EPOLLERR | fd发送错误 | |
EPOLLHUP | fd被挂断 | |
EPOLLONESHOT | 仅监听一次,如需继续监听该sockfd需再次添加(保证一个socket连接在任一时刻只被一个线程处理) | |
EPOLLPRI | fd有紧急数据可读(带外数据) |
EPOLL工作模式
LT:Level trigger,水平触发,默认
ET:Edge trigger,边沿触发
区别:为什么都是针对读而不是写
LT:当检测事件就绪并通知应用程序后,APP可不立即处理而支持再次通知(相当于未处理会一直通知)
只要缓冲区有剩余未读数据都会通知即epoll_wait均会返回
读写:read 操作比较简单,有 read 事件就读,读多读少都没有问题,但是 write 就不那么容易了,一般来说 socket 在空闲状态时发送缓冲区一定是不满的,假如 fd 一直在监控中,那么会一直通知写事件,不胜其烦。
所以必须保证没有数据要发送的时候,要把 fd 的写事件监控从 epoll 列表中删除,需要的时候再加入回去,如此反复
支持阻塞和非阻塞套接字
ET:当检测事件就绪并通知应用程序后,APP需立即处理,若不处理不会被再次通知(相当于未处理会被丢弃)
只有数据到来才会触发
如该fd可读,则必须一直读空,让errno返回EAGAIN为止,否则下次会丢掉。若ET未设置成非阻塞,则会一直读/写直到阻塞
读写:fd 可读则返回可读事件,若开发者没有把所有数据读取完毕,epoll 不会再次通知 read 事件,也就是说如果没有全部读取所有数据,那么导致 epoll 不会再通知该 socket 的 read 事件,事实上一直读完很容易做到。
若发送缓冲区未满,epoll 通知 write 事件,直到开发者填满发送缓冲区,epoll 才会在下次发送缓冲区由满变成未满时通知 write 事件。
ET模式下只有 socket 的状态发生变化时才会通知,也就是读取缓冲区由无数据到有数据时通知 read 事件,发送缓冲区由满变成未满通知 write 事件。
线程饥饿:如果某个 socket 源源不断地收到非常多的数据,在试图读取完所有数据的过程中,有可能会造成其他的 socket 得不到处理,从而造成饥饿问题。
解决办法:
为每个已经准备好的描述符维护一个队列,这样程序就可以知道哪些描述符已经准备好了但是并没有被读取完,然后程序定时或定量的读取,如果读完则移除,直到队列为空,这样就保证了每个 fd 都被读到并且不会丢失数据。
流程如图:
最好可以有案例跑一遍
如果采用EPOLLLT模式的话,系统中一旦有大量你不需要读写的就绪文件描述符(什么是不需要读写的fd,有事件的不应该都是需要读写的吗???)**,它们每次调用epoll_wait都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率
ET减少了epoll事件被重复触发的次数,故效率更高。(必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死???**)
惊群效应
你在广场喂鸽子,你只投喂了一份食物,却引来一群鸽子争抢,最终还是只有一只鸽子抢到了食物,对于其他鸽子来说是徒劳的。
2016 年 Linux 4.5 内核新添加的一个 epoll 的标识 EPOLLEXCLUSIVE,保证一个事件发生时候只有一个线程会被唤醒,以避免多侦听下的惊群问题
对比select
- 对 fd 数量没有限制(当然这个在 poll 也被解决了)
- 抛弃了 bitmap 数组实现了新的结构来存储多种事件类型
- 无需重复拷贝 fd 随用随加 随弃随删
- 采用事件驱动避免轮询查看可读写事件
原理
调用epoll_create后,内核创建 eventpoll,着重两个成员:rdllist、rbr
- 在epoll文件系统中创建file结点——对应 epfd
- 内核cache中创建红黑树——存注册的sockfd(epoll_ctl的参数)
- 内核cache中创建双向链表——存准备就绪事件
调用epoll_ctl,即红黑树中插入删除修改sockfd(key)
- 所有添加到epoll中的事件都会与设备(如网卡)驱动程序建立回调关系,也就是说相应事件的发生时会调用这里的回调方法。这个回调方法在内核中叫做ep_poll_callback,它会把这样的事件放到上面的rdllist双向链表中
- 当添加sockfd(注册)时,会检查是否在红黑树中存在,存在立即返回,不存在则添加并向内核注册回调函数——用于当中断事件发生时向就绪链表中插入数据
调用epoll_wait,即判断就绪链表中是否有数据,有则返回,没有则等timeout后返回——高效原因
- 即检查rdllist中是否含有 epitem 元素
- 若不为空,会将事件(思考是否就是epitem)复制到用户态内存,并返回事件数量——使用共享内存提升效率???
每个事件都建立一个epitem结构,不是每个fd只建立一个epitem,一个fd可以同时反生EPOLLIN和EPOLLOUT吗?
// linux-5.9.8/fs/eventpoll.c
struct eventpoll {
...
/* List of ready file descriptors */
struct list_head rdllist; // 双向链表,存就绪fd
/* RB tree root used to store monitored fd structs */
struct rb_root_cached rbr; // 存注册的fd 以前版本:struct rb_root rbr;
...
};
struct rb_root_cached {
struct rb_root rb_root;
struct rb_node *rb_leftmost; // 新加的
};
// linux-5.9.8/fs/eventpoll.c
struct epitem { // 每个事件都建立一个epitem结构
union {
/* RB tree node links this structure to the eventpoll RB tree */
struct rb_node rbn; // 红黑树中的每个结点
/* Used to free the struct epitem */
struct rcu_head rcu;
};
/* List header used to link this structure to the eventpoll ready list */
struct list_head rdllink; // 就绪双向链表中的每个结点
/*
* Works together "struct eventpoll"->ovflist in keeping the
* single linked chain of items.
*/
struct epitem *next;
/* The file descriptor information this item refers to */
struct epoll_filefd ffd; // 事件句柄信息
/* Number of active wait queue attached to poll operations */
int nwait;
/* List containing poll wait queues */
struct list_head pwqlist;
/* The "container" of this item */
struct eventpoll *ep; // 对应的eventpoll——epfd
/* List header used to link this item to the "struct file" items list */
struct list_head fllink;
/* wakeup_source used when EPOLLWAKEUP is set */
struct wakeup_source __rcu *ws;
/* The structure that describe the interested events and the source fd */
struct epoll_event event; // 期待事件
};
struct epoll_filefd {
struct file *file;
int fd;
} __packed;
static int ep_poll_callback(wait_queue_entry_t *wait, unsigned mode, int sync, void *key)
{
int pwake = 0;
struct epitem *epi = ep_item_from_wait(wait);
struct eventpoll *ep = epi->ep;
__poll_t pollflags = key_to_poll(key);
unsigned long flags;
int ewake = 0;
read_lock_irqsave(&ep->lock, flags);
ep_set_busy_poll_napi_id(epi);
/*
* If the event mask does not contain any poll(2) event, we consider the
* descriptor to be disabled. This condition is likely the effect of the
* EPOLLONESHOT bit that disables the descriptor when an event is received,
* until the next EPOLL_CTL_MOD will be issued.
*/
if (!(epi->event.events & ~EP_PRIVATE_BITS))
goto out_unlock;
/*
* Check the events coming with the callback. At this stage, not
* every device reports the events in the "key" parameter of the
* callback. We need to be able to handle both cases here, hence the
* test for "key" != NULL before the event match test.
*/
if (pollflags && !(pollflags & epi->event.events))
goto out_unlock;
/*
* If we are transferring events to userspace, we can hold no locks
* (because we're accessing user memory, and because of linux f_op->poll()
* semantics). All the events that happen during that period of time are
* chained in ep->ovflist and requeued later on.
*/
if (READ_ONCE(ep->ovflist) != EP_UNACTIVE_PTR) {
if (chain_epi_lockless(epi))
ep_pm_stay_awake_rcu(epi);
} else if (!ep_is_linked(epi)) {
/* In the usual case, add event to ready list. */
if (list_add_tail_lockless(&epi->rdllink, &ep->rdllist))
ep_pm_stay_awake_rcu(epi);
}
/*
* Wake up ( if active ) both the eventpoll wait list and the ->poll()
* wait list.
*/
if (waitqueue_active(&ep->wq)) {
if ((epi->event.events & EPOLLEXCLUSIVE) &&
!(pollflags & POLLFREE)) {
switch (pollflags & EPOLLINOUT_BITS) {
case EPOLLIN:
if (epi->event.events & EPOLLIN)
ewake = 1;
break;
case EPOLLOUT:
if (epi->event.events & EPOLLOUT)
ewake = 1;
break;
case 0:
ewake = 1;
break;
}
}
wake_up(&ep->wq);
}
if (waitqueue_active(&ep->poll_wait))
pwake++;
out_unlock:
read_unlock_irqrestore(&ep->lock, flags);
/* We have to call this outside the lock */
if (pwake)
ep_poll_safewake(ep, epi);
if (!(epi->event.events & EPOLLEXCLUSIVE))
ewake = 1;
if (pollflags & POLLFREE) {
/*
* If we race with ep_remove_wait_queue() it can miss
* ->whead = NULL and do another remove_wait_queue() after
* us, so we can't use __remove_wait_queue().
*/
list_del_init(&wait->entry);
/*
* ->whead != NULL protects us from the race with ep_free()
* or ep_remove(), ep_remove_wait_queue() takes whead->lock
* held by the caller. Once we nullify it, nothing protects
* ep/epi or even wait.
*/
smp_store_release(&ep_pwq_from_wait(wait)->whead, NULL);
}
return ewake;
}
此图需要去理清,且需要自己重新画一幅关于epoll图
epoll反应堆模型
与epoll接口最本质区别在于:
- 传入联合体的是自定义结构指针 struct myevent_t
- 直接调用事件对应回调函数而非事件分类处理
struct myevent {
int fd; // 需添加到epfd中的fd
void *args; // 回调函数参数
void (*callback)(void *args); // 回调函数
...
// 其他非必需参数:用户缓冲区、节点状态、节点创建时间等
} myevent_t;
epoll原生模型
- epoll_create,创建epoll
- epoll_ctl,添加listenfd
- epoll_wait,检车listenfd是否有事件
- 返回数组并遍历
- listenfd事件,则接受连接即accept,成功并将connfd注册到epoll中
- connfd事件,则进行read/write
epoll反应堆模型
- epoll_create,创建epoll
- epoll_ctl,添加listenfd
- epoll_wait,检车listenfd是否有事件
错误观点:epoll_wait 返回时,对于就绪的事件,epoll 使用的是共享内存的方式,即用户态和内核态都指向了就绪链表,所以就避免了内存拷贝消耗
实际拷贝实现:
revents = ep_item_poll(epi, &pt);//获取就绪事件
if (revents) {
if (put_user(revents, &uevent->events) ||
put_user(epi->event.data, &uevent->data)) {
list_add(&epi->rdllink, head);//处理失败则重新加入链表
ep_pm_stay_awake(epi);
return eventcnt ? eventcnt : -EFAULT;
}
eventcnt++;
uevent++;
if (epi->event.events & EPOLLONESHOT)
epi->event.events &= EP_PRIVATE_BITS;//EPOLLONESHOT标记的处理
else if (!(epi->event.events & EPOLLET)) {
list_add_tail(&epi->rdllink, &ep->rdllist);//LT模式处理
ep_pm_stay_awake(epi);
}
}
面试
问题:使用 Linux epoll 模型的 LT 水平触发模式,当 socket 可写时,会不停的触发 socket 可写的事件,如何处理?
普通做法:
当需要向 socket 写数据时,将该 socket 加入到 epoll 等待可写事件。接收到 socket 可写事件后,调用 write 或 send 发送数据,当数据全部写完后, 将 socket 描述符移出 epoll 列表,这种做法需要反复添加和删除。
改进做法:
向 socket 写数据时直接调用 send 发送,当 send 返回错误码 EAGAIN,才将 socket 加入到 epoll,等待可写事件后再发送数据,全部数据发送完毕,再移出 epoll 模型,改进的做法相当于认为 socket 在大部分时候是可写的,不能写了再让 epoll 帮忙监控。
上面两种做法是对 LT 模式下 write 事件频繁通知的修复,本质上 ET 模式就可以直接搞定,并不需要用户层程序的补丁操作
参考
epoll原理详解及epoll反应堆模型_青萍之末的博客-CSDN博客_epoll
徒手造了个轮子 — 实现epoll - 知乎
如果这篇文章说不清epoll的本质,那就过来掐死我吧! (1) - 知乎
如果这篇文章说不清epoll的本质,那就过来掐死我吧! (2) - 知乎
如果这篇文章说不清epoll的本质,那就过来掐死我吧! (3) - 知乎