谈谈Linux epoll惊群问题的原因和解决方案

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

近期排查了一个问题epoll惊群的问题起初我并不认为这是惊群导致因为从现象上看只是体现了CPU不均衡。一共fork了20个Server进程在请求负载中等的时候有三四个Server进程呈现出比较高的CPU利用率其余的Server进程的CPU利用率都是非常低。

中断软中断都是均衡的网卡RSS和CPU之间进行了bind之后依然如故既然系统层面查不出个所以然只能从服务的角度来查了。

自上而下的排查首先就想到了strace没想到一下子就暴露了原形

accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)

如果仅仅strace accept即加上“-e trace=accept”参数的话偶尔会有accept成功的现象

accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, {sa_family=AF_INET, sin_port=htons(39306), sin_addr=inet_addr("172.16.1.202")}, [16]) = 19
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)

大量的CPU空转进一步加大请求负载CPU空转明显降低这说明在预期的空转期间新来的请求降低了空转率…现象明显偏向于这就是惊群导致的之判断

简单介绍惊群和事件模型

关于什么是惊群这里不再做概念上的解释。

惊群问题一般出现在那些web服务器上曾经Linux系统有个经典的accept惊群问题困扰了大家非常久的时间这个问题现在已经在内核曾经得以解决具体来讲就是当有新的连接进入到accept队列的时候内核唤醒且仅唤醒一个进程来处理这是通过以下的代码来实现的

    list_for_each_entry_safe(curr, next, &q->task_list, task_list) {
        unsigned flags = curr->flags;
        if (curr->func(curr, mode, wake_flags, key) &&
                (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
            break;
    }

是的添加了一个WQ_FLAG_EXCLUSIVE标记告诉内核进行排他性的唤醒即唤醒一个进程后即退出唤醒的过程问题得以解决。

然而没有哪个web服务器会傻到多个进程直接阻塞在accept上准备接收请求在更高层次上多路复用的需求让selectpollepoll等事件模型更为受到欢迎所谓的事件模型即阻塞在事件上而不是阻塞在事务上。内核仅仅通知发生了某件事具体发生了什么事则有处理进程或者线程自己来poll。如此一来这个事件模型(无论其实现是selectpoll还是epoll)便可以一次搜集多个事件从而满足多路复用的需求。

好了基本原理就介绍到这里下面我将来详细谈一下Linux epoll中的惊群问题我们知道epoll在实际中要比直接accept实用性强很多据我所知除非编程学习或者验证性小demo几乎没有直接accept的代码所有的线上代码几乎都使用了事件模型。然而由于selectpoll没有可扩展性存在O(n)O(n)问题因此在带宽越来越高服务器性能越来越强的趋势下越来越多的代码将收敛到使用epoll的情形所以有必要对其进行深入的讨论。

相关视频推荐

从“惊群”问题来看 高并发锁方案

6种epoll的设计方法单线程epoll、多线程epoll、多进程epoll及每种epoll的应用场景

网络原理tcp/udp网络编程epoll/reactor面试中正经“八股文”

学习地址c/c++ linux服务器开发/后台架构师

需要C/C++ Linux服务器架构师学习资料加qun812855908获取资料包括C/C++Linuxgolang技术NginxZeroMQMySQLRedisfastdfsMongoDBZK流媒体CDNP2PK8SDockerTCP/IP协程DPDKffmpeg等免费分享

 

Linux epoll惊群问题

某网站上有一个问题

3.x内核源码eventpoll.c中已经有如下代码那为什么还是会发生惊群
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
           int maxevents, long timeout)
{
....
        init_waitqueue_entry(&wait, current);
        __add_wait_queue_exclusive(&ep->wq, &wait); // **NOTICE**
....
}

下面我来就这个问题给一个答案这也是我自己思考的答案

在ep_poll的睡眠中加入WQ_FLAG_EXCLUSIVE标记确实实实在在解决了epoll的惊群问题

epoll_wait返回后确实也还有多个进程被唤醒只有一个进程能正确处理其他进程无事可做的情况发生但这不是因为惊群而是你的使用方法不对。

What使用方法不对

是的使用方法不对。若想了解Why则必须对epoll的实现细节以及其对外提供的API的语义有充分的理解接下来我们就循着这个思路来撸个所以然。请继续阅读。

Linux epoll的实现机制

说起实现原理很多人喜欢撸源码分析我并不喜欢我认为源码是自己看看就行了搞这个行业的能看懂代码是一个最最基本的能力我比较在意的是对某种机制内在逻辑的深入理解而这个通过代码是体现不出来的我一般会做下面几件事

  • 运行起来并测得预期的数据
  • 看懂代码并画出原理图
  • 自己重新实现一版(时间精力允许的情况下)
  • 写个demo验证一些具体逻辑细节

下面是我总结的一张关于Linux epoll的原理图

 要说代码实现上其实也比较简单大致有以下的几个逻辑

  1. 创建epoll句柄初始化相关数据结构
  2. 为epoll句柄添加文件句柄注册睡眠entry的回调
  3. 事件发生唤醒相关文件句柄睡眠队列的entry调用其回调
  4. 唤醒epoll睡眠队列的task搜集并上报数据

来一个一个说

1.创建epoll句柄初始化相关数据结构

这里主要就是创建一个epoll文件描述符注意后面操作epoll的时候就是用这个epoll的文件描述符来操作的所以这就是epoll的句柄精简过后的epoll结构如下

struct eventpoll {
// 阻塞在epoll_wait的task的睡眠队列
wait_queue_head_t wq;
// 存在就绪文件句柄的list该list上的文件句柄事件将会全部上报给应用
struct list_head rdllist;
// 存放加入到此epoll句柄的文件句柄的红黑树容器
struct rb_root rbr;
// 该epoll结构对应的文件句柄应用通过它来操作该epoll结构
struct file *file;
};

2.为epoll句柄添加文件句柄注册睡眠entry的回调

这个步骤中其实有两个子步骤

1). 添加文件句柄

将一个文件句柄比如socket添加到epoll的rbr红黑树容器中注意这里的文件句柄最终也是一个包装结构和epoll的结构体类似

struct epitem {
// 该字段链接入epoll句柄的红黑树容器
struct rb_node rbn;
// 当该文件句柄有事件发生时该字段链接入“就绪链表”准备上报给用户态
struct list_head rdllink;
// 该字段封装实际的文件我已经将其展开
struct epoll_filefd {
struct file *file;
int fd;
} ffd;
// 反向指向其所属的epoll句柄
struct eventpoll *ep;
};

以上结构实例就是epi将被添加到epoll的rbr容器中的逻辑如下

struct eventpoll *ep = 待加入文件句柄所属的epoll句柄;
struct file *tfile = 待加入的文件句柄file结构体;
int fd = 待加入的文件描述符ID;
struct epitem *epi = kmem_cache_alloc(epi_cache, GFP_KERNEL);
INIT_LIST_HEAD(&epi->rdllink);
INIT_LIST_HEAD(&epi->fllink);
INIT_LIST_HEAD(&epi->pwqlist);
epi->ep = ep;
ep_set_ffd(&epi->ffd, tfile, fd);
...
ep_rbtree_insert(ep, epi);

2). 注册睡眠entry回调并poll文件句柄

在第一个子步骤的代码逻辑中我有一段“…”省略掉了这部分比较关键所以我单独抽取了出来作为第二个子步骤。

我们知道Linux内核的sleep/wakeup机制非常重要几乎贯穿了所有的内核子系统值得注意的是这里的sleep/wakeup依然采用了OO的思想并没有限制睡眠的entry一定要是一个task而是将睡眠的entry做了一层抽象即

struct __wait_queue {
unsigned int flags;
// 至于这个private到底是什么内核并不限制显然它可以是task也可以是别的。
void *private;
wait_queue_func_t func;
struct list_head task_list;
};

以上的这个entry最终要睡眠在下面的数据结构实例化的一个链表上

struct __wait_queue_head {
spinlock_t lock;
struct list_head task_list;
};

显然在这里一个文件句柄均有自己睡眠队列用于等待自己发生事件的entry在没有发生事件时来歇息对于TCP socket而言该睡眠队列就是其sk_wq通过以下方式取到

static inline wait_queue_head_t *sk_sleep(struct sock *sk)
{
return &rcu_dereference_raw(sk->sk_wq)->wait;
}

我们需要一个entry将来在发生事件的时候从上述wait_queue_head_t中被唤醒执行特定的操作即将自己放入到epoll句柄的“就绪链表”中。下面的函数可以完成该逻辑的框架

// 此处的whead就是上面例子中的sk_sleep返回的wait_queue_head_t实例。
static void ep_ptable_queue_proc(struct file *file, wait_queue_head_t *whead,
poll_table *pt)
{
struct epitem *epi = ep_item_from_epqueue(pt);
struct eppoll_entry *pwq;
if (pwq = kmem_cache_alloc(pwq_cache, GFP_KERNEL)) {
// 发生事件即调用ep_poll_callback回调函数该回调函数会将自己这个epitem加入到epoll的“就绪链表”中去。
init_waitqueue_func_entry(&pwq->wait, ep_poll_callback);
// 是否排他唤醒取决于用户的配置有些IO是希望唤醒所有entry来处理有些则不必。注意这里是针对文件句柄IO而言的并不是针对epoll句柄的。
if (epi->event.events & EPOLLEXCLUSIVE)
add_wait_queue_exclusive(whead, &pwq->wait);
else
add_wait_queue(whead, &pwq->wait);
}
}

至于说什么时候调用上面的函数Linux的poll机制仍然是采用了分层抽象的思想即上述函数会作为另一个回调在相关文件句柄的poll函数中被调用。即

static inline unsigned int ep_item_poll(struct epitem *epi, poll_table *pt)
{
pt->_key = epi->event.events;
return epi->ffd.file->f_op->poll(epi->ffd.file, pt) & epi->event.events;
}

对于TCP socket而言其file_operations的poll回调即

unsigned int tcp_poll(struct file *file, struct socket *sock, poll_table *wait)
{
unsigned int mask;
struct sock *sk = sock->sk;
const struct tcp_sock *tp = tcp_sk(sk);
// 此函数会调用poll_wait->wait._qproc
// 而wait._qproc就是ep_ptable_queue_proc
sock_poll_wait(file, sk_sleep(sk), wait);
...
}

现在我们可以把子步骤1中的逻辑补全了

struct eventpoll *ep = 待加入文件句柄所属的epoll句柄;
struct file *tfile = 待加入的文件句柄file结构体;
int fd = 待加入的文件描述符ID;
struct epitem *epi = kmem_cache_alloc(epi_cache, GFP_KERNEL);
INIT_LIST_HEAD(&epi->rdllink);
INIT_LIST_HEAD(&epi->fllink);
INIT_LIST_HEAD(&epi->pwqlist);
epi->ep = ep;
ep_set_ffd(&epi->ffd, tfile, fd);
// 这里会将wait._qproc初始化成ep_ptable_queue_proc
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);
// 这里会调用wait._qproc即ep_ptable_queue_proc安排entry的回调函数ep_poll_callback并将entry“睡眠”在socket的sk_wq这个睡眠队列上。
revents = ep_item_poll(epi, &epq.pt);
ep_rbtree_insert(ep, epi);
// 如果刚才的ep_item_poll取出了事件随即将该item挂入“就绪队列”中并且wakeup阻塞在epoll_wait系统调用中的task
if ((revents & event->events) && !ep_is_linked(&epi->rdllink)) {
list_add_tail(&epi->rdllink, &ep->rdllist);
if (waitqueue_active(&ep->wq))
wake_up_locked(&ep->wq);
}

3.事件发生唤醒相关文件句柄睡眠队列的entry调用其回调

上面已经很详细地描述了epoll的基础设施了现在我们假设一个TCP Listen socket上来了一个连接请求已经完成了三次握手内核希望通知epoll_wait返回然后去取accept。

内核在wakeup这个socket的sk_wq时最终会调用到ep_poll_callback回调这个函数我们说了好几次了现在看看它的真面目

static int ep_poll_callback(wait_queue_t *wait, unsigned mode, int sync, void *key)
{
unsigned long flags;
struct epitem *epi = ep_item_from_wait(wait);
struct eventpoll *ep = epi->ep;
// 这个lock比较关键操作“就绪链表”相关的均需要这个lock以防丢失事件。
spin_lock_irqsave(&ep->lock, flags);
// 如果发生的事件我们并不关注则不处理直接返回即可。
if (key && !((unsigned long) key & epi->event.events))
goto out_unlock;
// 实际将发生事件的epitem加入到“就绪链表”中。
if (!ep_is_linked(&epi->rdllink)) {
list_add_tail(&epi->rdllink, &ep->rdllist);
}
// 既然“就绪链表”中有了新成员则唤醒阻塞在epoll_wait系统调用的task去处理。注意如果本来epi已经在“就绪队列”了这里依然会唤醒并处理的。
if (waitqueue_active(&ep->wq)) {
wake_up_locked(&ep->wq);
}
out_unlock:
spin_unlock_irqrestore(&ep->lock, flags);
...
}

没什么好多说的。现在“就绪链表”已经有epi了接下来就要唤醒epoll_wait进程去处理了。

4.唤醒epoll睡眠队列的task搜集并上报数据

这个逻辑主要集中在ep_poll函数精简版如下

static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
int maxevents, long timeout)
{
unsigned long flags;
wait_queue_t wait;
// 当前没有事件才睡眠
if (!ep_events_available(ep)) {
init_waitqueue_entry(&wait, current);
__add_wait_queue_exclusive(&ep->wq, &wait);
for (;;) {
set_current_state(TASK_INTERRUPTIBLE);
...// 例行的schedule timeout
}
__remove_wait_queue(&ep->wq, &wait);
set_current_state(TASK_RUNNING);
}
// 往用户态上报事件即那些epoll_wait返回后能获取的事件。
ep_send_events(ep, events, maxevents);
}

其中关键在ep_send_events这个函数实现了非常重要的逻辑包括LT和ET的逻辑我不打算深入去解析这个函数只是大致说下流程

ep_scan_ready_list()
{
// 遍历“就绪链表”
ready_list_for_each() {
// 将epi从“就绪链表”删除
list_del_init(&epi->rdllink);
// 实际获取具体的事件。
// 注意睡眠entry的回调函数只是通知有“事件”具体需要每一个文件句柄的特定poll回调来获取。
revents = ep_item_poll(epi, &pt);
if (revents) {
if (__put_user(revents, &uevent->events) ||
__put_user(epi->event.data, &uevent->data)) {
// 如果没有完成则将epi重新加回“就绪链表”等待下次。
list_add(&epi->rdllink, head);
return eventcnt ? eventcnt : -EFAULT;
}
// 如果是LT模式则无论如何都会将epi重新加回到“就绪链表”等待下次重新再poll以确认是否仍然有未处理的事件。这也符合“水平触发”的逻辑即“只要你不处理我就会一直通知你”。
if (!(epi->event.events & EPOLLET)) {
list_add_tail(&epi->rdllink, &ep->rdllist);
}
}
}
// 如果“就绪链表”上仍有未处理的epi且有进程阻塞在epoll句柄的睡眠队列则唤醒它(这将是LT惊群的根源)
if (!list_empty(&ep->rdllist)) {
if (waitqueue_active(&ep->wq))
wake_up_locked(&ep->wq);
}
}

这里的代码逻辑的分析过程就到此为止了。以对这个代码逻辑的充分理解为基础接下来我们就可以看具体的问题细节了。

下面一小节先从LT(水平触发模式)以及ET(即边沿触发模式)开始。

epoll的LT和ET以及相关细节问题

简单点解释

  • LT水平触发

如果事件来了不管来了几个只要仍然有未处理的事件epoll都会通知你。

  • ET边沿触发

如果事件来了不管来了几个你若不处理或者没有处理完除非下一个事件到来否则epoll将不会再通知你。

理解了上面说的两个模式便可以很明确地展示可能会遇到的问题以及解决方案了这将非常简单。

LT水平触发模式的问题以及解决

下面是epoll使用中非常常见的代码框架我将问题注释于其中

// 否则会阻塞在IO系统调用导致没有机会再epoll
set_socket_nonblocking(sd);
epfd = epoll_create(64);
event.data.fd = sd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sd, &event);
while (1) {
epoll_wait(epfd, events, 64, xx);
... // 危险区域如果有共享同一个epfd的进程/线程调用epoll_wait它们也将会被唤醒
// 这个accept将会有多个进程/线程调用如果并发请求数很少那么将仅有几个进程会成功
// 1. 假设accept队列中有n个请求则仅有n个进程能成功其它将全部返回EAGAIN (Resource temporarily unavailable)
// 2. 如果n很大(即增加请求负载)虽然返回EAGAIN的比率会降低但这些进程也并不一定取到了epoll_wait返回当下的那个预期的请求。
csd = accept(sd, &in_addr, &in_len);
...
}

这一切为什么会发生

我们结合理论和代码一起来分析。

再看一遍LT的描述“如果事件来了不管来了几个只要仍然有未处理的事件epoll都会通知你。”显然epoll_wait刚刚取到事件的时候的时候不可能马上就调用accept去处理事实上逻辑在epoll_wait函数调用的ep_poll中还没返回的这个时候显然符合“仍然有未处理的事件”这个条件显然这个时候为了实现这个语义需要做的就是通知别的同样阻塞在同一个epoll句柄睡眠队列上的进程在实现上这个语义由两点来保证

保证1在LT模式下“就绪链表”上取出的epi上报完事件后会重新加回“就绪链表”

保证2如果“就绪链表”不为空且此时有进程阻塞在同一个epoll句柄的睡眠队列上则唤醒它。

ep_scan_ready_list()
{
// 遍历“就绪链表”
ready_list_for_each() {
list_del_init(&epi->rdllink);
revents = ep_item_poll(epi, &pt);
// 保证1
if (revents) {
__put_user(revents, &uevent->events);
if (!(epi->event.events & EPOLLET)) {
list_add_tail(&epi->rdllink, &ep->rdllist);
}
}
}
// 保证2
if (!list_empty(&ep->rdllist)) {
if (waitqueue_active(&ep->wq))
wake_up_locked(&ep->wq);
}
}

我们来看一个情景分析。

假设LT模式下有10个进程共享同一个epoll句柄此时来了一个请求client进入到accept队列我们发现上述的1和2是一个循环唤醒的过程

1).假设进程a的epoll_wait首先被ep_poll_callback唤醒那么满足1和2则唤醒了进程B

2).进程B在处理ep_scan_ready_list的时候发现依然满足1和2于是唤醒了进程C….

3).上面1)和2)的过程一直到之前某个进程将client取出此时下一个被唤醒的进程在ep_scan_ready_list中的ep_item_poll调用中将得不到任何事件此时便不会再将该epi加回“就绪链表”了LT水平触发结束结束了这场悲伤的梦

问题非常明确了但是怎么解决呢也非常简单让不同进程的epoll_waitI调用互斥即可。

但是且慢

上面的情景分析所展示的是一个“惊群效应”吗其实并不是对于Listen socket当然要避免这种情景但是对于很多其它的I/O文件句柄说不定还指望着大家一起来read数据呢…所以说要说互斥也仅仅要针对Listen socket的epoll_wait调用而言。

换句话说这里epoll LT模式下有进程被不必要唤醒这一点并不是内核无意而为之的内核肯定是知道这件事的这个并不像之前accept惊群那样算是内核的一个缺陷。epoll LT模式只是提供了一种模式误用这种模式将会造成类似惊群那样的效应。但是不管怎么说为了讨论上的方便后面我们姑且将这种效应称作epoll LT惊群吧。

除了epoll_wait互斥之外还有一种解决问题的方案即使用ET边沿触发模式但是会遇到新的问题我们接下来来描述。

ET边沿触发模式的问题以及解决

ET模式不满足上述的“保证1”所以不会将已经上报事件的epi重新链接回“就绪链表”也就是说只要一个“就绪队列”上的epi上的事件被上报了它就会被删除出“就绪队列”。

由于epi entry的callback即ep_poll_callback所做的事情仅仅是将该epi自身加入到epoll句柄的“就绪链表”同时唤醒在epoll句柄睡眠队列上的task所以这里并不对事件的细节进行计数比如说如果ep_poll_callback在将一个epi加入“就绪链表”之前发现它已经在“就绪链表”了那么就不会再次添加因此可以说一个epi可能pending了多个事件注意到这点非常重要

一个epi上pending多个事件这个在LT模式下没有任何问题因为获取事件的epi总是会被重新添加回“就绪链表”那么如果还有事件在下次check的时候总会取到。然而对于ET模式仅仅将epi从“就绪链表”删除并将事件本身上报后就返回了因此如果该epi里还有事件则只能等待再次发生事件进而调用ep_poll_callback时将该epi加入“就绪队列”。这意味着什么

这意味着应用程序即epoll_wait的调用进程必须自己在获取事件后将其处理干净后方可再次调用epoll_wait否则epoll_wait不会返回而是必须等到下次产生事件的时候方可返回。即依然以accept为例必须这样做

// 否则会阻塞在IO系统调用导致没有机会再epoll
set_socket_nonblocking(sd);
epfd = epoll_create(64);
event.data.fd = sd;
// 添加ET标记
event.events |= EPOLLET;
epoll_ctl(epfd, EPOLL_CTL_ADD, sd, &event);
while (1) {
epoll_wait(epfd, events, 64, xx);
while ((csd = accept(sd, &in_addr, &in_len)) > 0) {
do_something(...);
}
...
}

好了解释完了。

以上就是epoll的LTET相关的两个问题和解决方案。接下来的一节我将用一个小小的简单Demo来重现上面描述的理论和代码。

测试demo

是时候给出一个实际能run的代码了

#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <netdb.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <time.h>
#include <signal.h>
#define COUNT 1
int mode = 0;
int slp = 0;
int pid[COUNT] = {0};
int count = 0;
void server(int epfd)
{
struct epoll_event *events;
int num, i;
struct timespec ts;
events = calloc(64, sizeof(struct epoll_event));
while (1) {
int sd, csd;
struct sockaddr in_addr;
num = epoll_wait(epfd, events, 64, -1);
if (num <= 0) {
continue;
}
/*
ts.tv_sec = 0;
ts.tv_nsec = 1;
if(nanosleep(&ts, NULL) != 0) {
perror("nanosleep");
exit(1);
}
*/
// 用于测试ET模式下丢事件的情况
if (slp) {
sleep(slp);
}
sd = events[0].data.fd;
socklen_t in_len = sizeof(in_addr);
csd = accept(sd, &in_addr, &in_len);
if (csd == -1) {
// 打印这个说明中了epoll LT惊群的招了。
printf("shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:%d\n", getpid());
continue;
}
// 本进程一共成功处理了多少个请求。
count ++;
printf("get client:%d\n", getpid());
close(csd);
}
}
static void siguser_handler(int sig)
{
// 在主进程被Ctrl-C退出的时候每一个子进程均要打印自己处理了多少个请求。
printf("pid:%d count:%d\n", getpid(), count);
exit(0);
}
static void sigint_handler(int sig)
{
int i = 0;
// 给每一个子进程发信号要求其打印自己处理了多少个请求。
for (i = 0; i < COUNT; i++) {
kill(pid[i], SIGUSR1);
}
}
int main (int argc, char *argv[])
{
int ret = 0;
int listener;
int c = 0;
struct sockaddr_in saddr;
int port;
int status;
int flags;
int epfd;
struct epoll_event event;
if (argc < 4) {
exit(1);
}
// 0为LT模式1为ET模式
mode = atoi(argv[1]);
port = atoi(argv[2]);
// 是否在处理accept之前耽搁一会儿这个参数更容易重现问题
slp = atoi(argv[3]);
signal(SIGINT, sigint_handler);
listener = socket(PF_INET, SOCK_STREAM, 0);
saddr.sin_family = AF_INET;
saddr.sin_port = htons(port);
saddr.sin_addr.s_addr = INADDR_ANY;
bind(listener, (struct sockaddr*)&saddr, sizeof(saddr));
listen(listener, SOMAXCONN);
flags = fcntl (listener, F_GETFL, 0);
flags |= O_NONBLOCK;
fcntl (listener, F_SETFL, flags);
epfd = epoll_create(64);
if (epfd == -1) {
perror("epoll_create");
abort();
}
event.data.fd = listener;
event.events = EPOLLIN;
if (mode == 1) {
event.events |= EPOLLET;
} else if (mode == 2) {
event.events |= EPOLLONESHOT;
}
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, listener, &event);
if (ret == -1) {
perror("epoll_ctl");
abort();
}
for(c = 0; c < COUNT; c++) {
int child;
child = fork();
if(child == 0) {
// 安装打印count值的信号处理函数
signal(SIGUSR1, siguser_handler);
server(epfd);
}
pid[c] = child;
printf("server:%d pid:%d\n", c+1, child);
}
wait(&status);
sleep(1000000);
close (listener);
}

编译之为a.out。

测试客户端选用了简单webbench首先我们看一下LT水平触发模式下的问题

[zhaoya@shit ~/test]$ sudo ./a.out 0 112 0
server:1 pid:9688
server:2 pid:9689
server:3 pid:9690
server:4 pid:9691
server:5 pid:9692
server:6 pid:9693
server:7 pid:9694
server:8 pid:9695
server:9 pid:9696
server:10 pid:9697

另起一个终端运行webbench并发10测试5秒

[zhaoya@shit ~/test]$ webbench -c 10 -t 5 http://127.0.0.1:112/
Webbench - Simple Web Benchmark 1.5
Copyright (c) Radim Kolar 1997-2004, GPL Open Source Software.
Benchmarking: GET http://127.0.0.1:112/
10 clients, running 5 sec.

而a.out的终端有以下输出

...
get client:9690
get client:9688
get client:9691
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:9693
get client:9692
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:9689
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:9697
get client:9691
get client:9696
get client:9690
get client:9690
get client:9695
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:9697
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:9689
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:9692
get client:9696
get client:9688
get client:9695
get client:9693
get client:9689
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:9691
get client:9695
get client:9691
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:9692
get client:9690
get client:9694
get client:9693
...

所有的“shit 
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:”的行均是被epoll LT惊群不必要唤醒的进程打印的。

接下来用ET模式运行

[zhaoya@shit ~/test]$ sudo ./a.out 1 112 0

对应的输出如下

...
get client:14462
get client:14462
get client:14464
get client:14464
get client:14462
get client:14462
get client:14467
get client:14469
get client:14468
get client:14468
get client:14464
get client:14467
get client:14467
get client:14469
get client:14469
get client:14469
get client:14464
get client:14464
get client:14466
get client:14466
get client:14469
get client:14469
...

没有任何一行是shit即没有被不必要唤醒的惊群现象发生。

以上两个case确认了epoll LT模式的惊群效应是可以通过改用ET模式来解决的接下来我们确认ET模式非循环处理会丢失事件。

用ET模式运行a.out这时将slp参数设置为1即在epoll_wait返回和实际accept之间耽搁1秒这样可以让一个epi在被加入到“就绪链表”中之后在其被实际accept处理之前积累更多的未决事件即未处理的请求而我们实验的目的则是epoll ET会丢失这些事件。

webbench的参数依然如故a.out的输出如下

[zhaoya@shit ~/test]$ sudo ./a.out 1 114 1
server:1 pid:31161
server:2 pid:31162
server:3 pid:31163
server:4 pid:31164
server:5 pid:31165
server:6 pid:31166
server:7 pid:31167
server:8 pid:31168
server:9 pid:31169
server:10 pid:31170
get client:31170
get client:31170
get client:31167
get client:31169
get client:31166
get client:31165
get client:31170
get client:31167
get client:31169
get client:31165
get client:31168
get client:31170
get client:31167
get client:31165
get client:31169
get client:31170
get client:31167
get client:31169
get client:31170
get client:31167
get client:31169
^Cpid:31170 count:6
pid:31169 count:5
pid:31163 count:0
pid:31168 count:1
pid:31167 count:5
pid:31165 count:3
pid:31166 count:1
pid:31161 count:0
pid:31162 count:0
pid:31164 count:0
User defined signal 1

同样的webbench参数仅仅处理了十几个请求可见大多数都丢掉了。如果我们用LT模式同样在sleep 1秒导致事件挤压的情况下是不是会多处理一些呢我们的预期应该是肯定的因为LT模式在事件被处理完之前会一直促使epoll_wait返回继续处理那么让我们试一下

[zhaoya@shit ~/test]$ sudo ./a.out 0 115 1
server:1 pid:363
server:2 pid:364
server:3 pid:365
server:4 pid:366
server:5 pid:367
server:6 pid:368
server:7 pid:369
server:8 pid:370
server:9 pid:371
server:10 pid:372
get client:372
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:371
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:365
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:366
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:363
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:367
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:369
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:364
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:368
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:370
get client:370
get client:364
get client:367
get client:368
get client:369
get client:365
get client:371
get client:372
get client:363
get client:366
get client:370
get client:367
get client:364
get client:369
get client:371
get client:368
get client:366
get client:363
get client:365
get client:372
get client:370
get client:367
get client:364
get client:371
get client:369
get client:366
get client:368
get client:363
get client:365
get client:372
get client:370
get client:367
get client:371
get client:364
get client:369
get client:366
get client:365
get client:368
get client:363
get client:372
get client:370
get client:364
get client:371
get client:367
get client:366
get client:369
get client:365
get client:363
get client:368
get client:372
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:371
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:370
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:364
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:367
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:366
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:369
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:365
shit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:363
^Cpid:363 count:5
pid:368 count:5
pid:372 count:6
pid:369 count:5
pid:366 count:5
pid:370 count:5
pid:367 count:5
pid:371 count:5
pid:365 count:5
pid:364 count:5
User defined signal 1

是的多处理了很多但是出现了LT惊群这也是意料之中的事。

最后让我们把这个Demo代码小改一下改成循环处理依然采用ET模式sleep 1秒看看情况会怎样。修改后的代码如下

void server(int epfd)
{
struct epoll_event *events;
int num, i;
struct timespec ts;
events = calloc(64, sizeof(struct epoll_event));
while (1) {
int sd, csd;
struct sockaddr in_addr;
num = epoll_wait(epfd, events, 64, -1);
if (num <= 0) {
continue;
}
if (slp)
sleep(slp);
sd = events[0].data.fd;
socklen_t in_len = sizeof(in_addr);
// 这里循环处理一直到空。
while ((csd = accept(sd, &in_addr, &in_len)) > 0) {
count ++;
printf("get client:%d\n", getpid());
close(csd);
}
}
}

改完代码后再做同样参数的测试结果大大不同

[zhaoya@shit ~/test]$ sudo ./a.out 0 116 1
...
get client:3640
get client:3645
get client:3640
get client:3641
get client:3641
get client:3641
^Cpid:3642 count:14
pid:3647 count:33531
pid:3646 count:21824
pid:3648 count:22
pid:3644 count:32219
pid:3645 count:94449
pid:3641 count:8
pid:3640 count:85385
pid:3643 count:13
pid:3639 count:10
User defined signal 1

可以看到大多数的请求都得到了处理同样的逻辑epoll_wait返回后的循环读和一次读结果显然不同。

问题和解决方案都很明确了可以结单了吗我想是的但是在终结这个话题之前我还想说一些结论性的东西以供备忘和参考。

结论

曾经为了实现并发服务器出现了很多的所谓范式比如下面的两个很常见

  1. 范式1设置多个IP地址多个IP地址同时侦听相同的端口前端用4层负载均衡或者反向代理来对这些IP地址进行请求分发
  2. 范式2Master进程创建一个Listen socket然后fork出来N个worker进程这N个worker进程同时侦听这个socket。

第一个范式与本文讲的epoll无关更多的体现一种IP层的技术这里不谈这里仅仅说一下第二个范式。

为了保证元组的唯一性以及处理的一致性很长时间以来对于服务器而言是不允许bind同一个IP地址和端口对的。然而为了可以并发处理多个连接请求则必须采用某种多处理的方式为了多个进程可以同时侦听同一个IP地址端口对便出现了create listener+fork这种模型具体来讲就是

sd = create_listen_socket();
for (i = 0; i < N; i++) {
if (fork() == 0) {
// 继承了父进程的文件描述符
server(sd);
}
}

然而这种模式仅仅是做到了进程级的可扩展性即一个进程在忙时其它进程可以介入帮忙处理底层的socket句柄其实是同一个简单点说这是一个沙漏模型

这种模型在处理同一个socket的时候必须互斥同时内核必须防止潜在的惊群效应因为互斥的要求有且仅有一个进程可以处理特定的请求。这就对编程造成了极大的干扰。

以本文所描述的case为例如果不清楚epoll LT模式和ET模式潜在的问题那么就很容易误用epoll导致比较令人头疼的后果。

非常幸运reuseport出现后模型彻底变成了桶状

 于是乎使用了reuseport一切都变得明朗了

不再依赖mem模型

不再担心惊群

为什么reuseport没有惊群首先我们要知道惊群发生的原因就是同时唤醒了多个进程处理一个事件导致了不必要的CPU空转。为什么会唤醒多个进程因为发生事件的文件描述符在多个进程之间是共享的。而reuseport呢侦听同一个IP地址端口对的多个socket本身在socket层就是相互隔离的在它们之间的事件分发是TCP/IP协议栈完成的所以不会再有惊群发生。

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: linux

“谈谈Linux epoll惊群问题的原因和解决方案” 的相关文章