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

差别是什么?差别只是西装!

缘起

近期排查了一个问题,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空转明显降低,这说明在预期的空转期间,新来的请求降低了空转率…现象明显偏向于这就是惊群导致的之判断!

本文将详细说一下关于epoll的细节。现在开始!


关于epoll的文章,我很早前写过一篇总结性的,可以参考这里:
Linux内核中网络数据包的接收-第二部分 select/poll/epollhttps://blog.csdn.net/dog250/article/details/50528373
不过这篇文章主要是原理性的介绍,对于一开始并不充分理解epoll机制的人来讲,可读性并不强,所以我准备写一篇稍微接地气的,比如带有一些“源码分析”的文章,虽然我并不是很喜欢源码分析,但有时对于快速理解为什么这样还是必要的。

题目中为什么是“再谈”,因为这个话题别人已经聊过很多了,我顺势继续下去而已。

简单介绍惊群和事件模型

关于什么是惊群,这里不再做概念上的解释,能搜到这篇文章的想必已经有所了解,如果仍有概念上的疑惑,自行百度或者谷歌。


惊群问题一般出现在那些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上准备接收请求,在更高层次上,多路复用的需求让select,poll,epoll等事件模型更为受到欢迎,所谓的事件模型即阻塞在事件上而不是阻塞在事务上。内核仅仅通知发生了某件事,具体发生了什么事,则有处理进程或者线程自己来poll。如此一来,这个事件模型(无论其实现是select,poll,还是epoll)便可以一次搜集多个事件,从而满足多路复用的需求。

好了,基本原理就介绍到这里,下面我将来详细谈一下Linux epoll中的惊群问题,我们知道epoll在实际中要比直接accept实用性强很多,据我所知,除非编程学习或者验证性小demo,几乎没有直接accept的代码,所有的线上代码几乎都使用了事件模型。然而由于select,poll没有可扩展性,存在
O(n)

O

(

n

)

问题,因此在带宽越来越高,服务器性能越来越强的趋势下,越来越多的代码将收敛到使用epoll的情形,所以有必要对其进行深入的讨论。


Linux epoll惊群问题

知乎上有一个问题:
Linux 3.x 中epoll的惊群问题?https://www.zhihu.com/question/24169490/answers/created
建议先看一下,但不要看回答,因为知乎上上的很多回答往往会让事情变得更加混乱,除非你自己对这个问题已经有了自己的答案或者观点,否则还是不要去指望在诸多的答案中选一个自己满意的来用,还是要自己先思考。

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

  1. 在ep_poll的睡眠中加入WQ_FLAG_EXCLUSIVE标记,确实实实在在解决了epoll的惊群问题
  2. 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. 保证1:在LT模式下,“就绪链表”上取出的epi上报完事件后会重新加回“就绪链表”;
  2. 保证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的LT,ET相关的两个问题和解决方案。接下来的一节,我将用一个小小的简单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:%dn", getpid()); 
            continue;
        }
        // 本进程一共成功处理了多少个请求。
        count ++;
        printf("get client:%dn", getpid()); 
        close(csd);
    }
}

static void siguser_handler(int sig)
{
    // 在主进程被Ctrl-C退出的时候,每一个子进程均要打印自己处理了多少个请求。
    printf("pid:%d  count:%dn", 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:%dn", 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:%dn", 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:设置多个IP地址,多个IP地址同时侦听相同的端口,前端用4层负载均衡或者反向代理来对这些IP地址进行请求分发;
  • 范式2:Master进程创建一个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协议栈完成的,所以不会再有惊群发生。

所以,结论是什么?

结论就是全部统一采用reuseport的方式吧,彻底解决惊群问题。

后记

浙江温州皮鞋湿!

原文链接: https://blog.csdn.net/dog250/article/details/80837278

欢迎关注

微信关注下方公众号,第一时间获取干货硬货;公众号内回复【pdf】免费获取数百本计算机经典书籍;

也有高质量的技术群,里面有嵌入式、搜广推等BAT大佬

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

原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/406869

非原创文章文中已经注明原地址,如有侵权,联系删除

关注公众号【高性能架构探索】,第一时间获取最新文章

转载文章受原作者版权保护。转载请注明原作者出处!

(0)
上一篇 2023年4月26日 上午10:22
下一篇 2023年4月26日 上午10:22

相关推荐