天天看点

epoll "惊群"问题分析

epoll “惊群”问题分析

最近在项目中发现,将haproxy作为服务器前端, haproxy以多进程模式运行,并且将每个进程绑定到不同的核上,每个核上不运行其他应用程序,但子进程跑的并不均衡,某些进程会获得到到绝大部分的连接,某些进程获得到较少的连接,在高峰期时候导致高负载的进程cpu 100%,而获得连接少的进程的cpu则很空闲。

linux 2-6.x 以来内核已解决了部分socket接口的惊群问题,如accept在多进程环境中阻塞方式使用,每次新来的新连接,只会唤醒一个进程,这个查看内核代码比较好理解:

多进程共享监听端口阻塞式:

bind()
listen()
for () 
{
    fork ()
    if (child) { 
          epoll_wait()
          if (fd_is_set)
              accept()   //blocking here
      }
    }
           

每个进程都以独占模式加入到监听连接的sk->sleep队列中,过程如下

inet_accept
inet_csk_accept 
inet_csk_wait_for_connect  
prepare_to_wait_exclusive(sk->sk_sleep, &wait, TASK_INTERRUPTIBLE);   schedule_timeout(-)
           

连接三次握手完成后,唤醒监听进程后,只会唤醒一个进程

tcp_child_process

if (state == TCP_SYN_RECV && child->sk_state != state)

-> parent->sk_data_ready(parent, 0);

-> sock_def_readable

->wake_up_interruptible(x) __wake_up(x, TASK_INTERRUPTIBLE, 1, NULL)

__wake_up 中指定了唤醒进程为1个,唤醒后:

static void __wake_up_common(wait_queue_head_t *q, unsigned int mode,
        int nr_exclusive, int sync, void *key)
{
 wait_queue_t *curr, *next;

 list_for_each_entry_safe(curr, next, &q->task_list, task_list) {
  unsigned flags = curr->flags;

  if (curr->func(curr, mode, sync, key) &&
    (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
   break;
 }
}
           

这里curr->func 回调函数是autoremove_wake_function,调用就会将所等待的进程放到运行队列中,等待cpu调度;

accept 阻塞模式,每个进程按调用顺序每次加入到sk等待队列的最后一个,而每次唤醒则唤醒队列中的第一个进程,可以保证每个进程分配均衡;

但大部分多并发程序,不会采用阻塞式,而是使用(poll/select/epoll)的io复用模式,io复用模式内核并没有解决好惊群问题,可能会唤醒多个进程,多个进程都会得到该监听端口的读事件,之后第一去accept的进程会拿到连接,其他进程accept以EAGAIN失败,继续io复用中等待,下面以epoll 模式分析(不具体详解epoll的原理,会抽离出与惊群相关的逻辑分析解):

haproxy使用的是独享模式,每个进程都会epoll_create 一次, 所谓的独享/共享就是epoll_create 在fork前调用,还是fork后调用。

独享模式分析

调用步骤大致是:

bind 
listen
fork() {
    //子进程中
    epoll_create
    epoll_ctr(add listen_fd)
    while (
        epoll_wait
        if (listen fd有读事件)
            accept()
        ......
    }
}
           

每个进程都会创建一个epollevent,当epoll_ctl 将listen fd加入监听树中,过程如下:

epoll_ctrl
ep_insert()
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);   // 注册poll_wait的回调函数
tfile->f_op->poll->sock_poll->tcp_poll 
           

poll_wait 中调用ep_ptable_queue_proc 将该监听句柄对应的 (epitem->eppoll_entry->wait)的等待消息挂到listen 句柄的等待队列中

注意这里不是以exclusive方式加入等待队列

fd—-sk->sleep <–epitem->eppoll_entry->wait process1

<–epitem->eppoll_entry->wait process2

<–epitem->eppoll_entry->wait process3

之后应用层调用epoll_wait

sys_epoll_wait
ep_poll
  if (list_empty(&ep->rdllist)) {
/* epoll控制块 有监听句柄, 则将当前进程挂入 控制块的等待队列,之后进入schedule */
       init_waitqueue_entry(&wait, current);
       wait.flags |= WQ_FLAG_EXCLUSIVE;
       __add_wait_queue(&ep->wq, &wait);
       schedule_timeout
   }
           

当新连接完成三次握手tcp_child_process调用parent->sk_data_ready(parent, 0)-》__wake_up_common; 由于不是exclusive模式,会调用每个等待进程的回调函数ep_poll_callback,

ep_poll_callback

list_add_tail(&epi->rdllink, &ep->rdllist);

if (waitqueue_active(&ep->wq))

__wake_up_locked(&ep->wq, TASK_UNINTERRUPTIBLE | TASK_INTERRUPTIBLE);

这里调用在ep_poll注册等待的回调函数init_waitqueue_entry,回调函数是default_wake_function 将等待进程放入运行队列,所以每个进程都会被唤醒,但不一定会返回进程空间,因为在ep_poll 中有如下处理

if (!res && eavail &&

!(res = ep_send_events(ep, events, maxevents)) && jtimeout)

goto retry;

每个进程都会调ep_send_events,将根据ep->rdllist(协议栈反馈的结果)再去poll一次,这里有两种情况:

1、如果某个进程“动作快”ep_send_event返回应用空间,触发去accept,后面的其他进程 其他进程只能在goto retry 继续schedule_timeout 中挂起等待,如果有多个进程在这里都发现有事件,会返回应用空间,就将发生“惊群”,但只有一个进程才能accept成功,其他返回应用空间的进程将accept 返回EAGAIN, 单核的cpu很大概率上只有一个回进程空间,因为ep_send_events到accept 过程路径一般比较短,不会被其他进程抢(不一定绝对,看ep下面的事件数以及进程的处理逻辑) 双核cpu很有两个进程返回应用空间, 将通过实验证实这个逻辑:

双核cpu

cat /proc/cpuinfo | grep processor

processor : 0

processor : 1

在双核环境中,以上面独享的模式启动8个子进程,epoll_wait超时为-1,阻塞模式启动

启动服务端:

./alarmsrv 12345

worker process started, pid = 2010

worker process started, pid = 2011

worker process started, pid = 2012

worker process started, pid = 2013

worker process started, pid = 2014

worker process started, pid = 2015

worker process started, pid = 2016

worker process started, pid = 2017

先查看每个进程的被唤醒的总次数, 刚启动没有任何连接,为0

pids=

ps axu | grep alarm | grep -v grep | awk '{print $2}' | xargs

for p in pids;doecho−n" p “; cat /proc/$p/sched | grep “nr_wakeups>”; done

2009 se.nr_wakeups : 0

2010 se.nr_wakeups : 0

2011 se.nr_wakeups : 0

2012 se.nr_wakeups : 0

2013 se.nr_wakeups : 0

2014 se.nr_wakeups : 0

2015 se.nr_wakeups : 0

2016 se.nr_wakeups : 0

2017 se.nr_wakeups : 0

第一次 nc 127.0.0.1 12345 后

服务端,起来两个进程,但只有一个进程accept,之后处理完后,每个进程处理完继续调用epoll_wait,这两个进程将有挂到监听连接的sleep队列中,并且挂在最前面,所以下一次先被唤醒的进程 很大可能是这两个进程:

process wake up, pid = 2014

accepted, pid = 2014

process wake up, pid = 2017

这种情况是:进程2014 先起来ep_send_events 在accept过程中,进程2017也起来 后也ep_send_events拿到了事件

for p in pids;doecho−n" p “; cat /proc/$p/sched | grep “nr_wakeups>”; done

2010 se.nr_wakeups : 1

2011 se.nr_wakeups : 1

2012 se.nr_wakeups : 1

2013 se.nr_wakeups : 1

2014 se.nr_wakeups : 2

2015 se.nr_wakeups : 1

2016 se.nr_wakeups : 1

2017 se.nr_wakeups : 1

第二次 nc 127.0.0.1 12345 后

服务端起来仍然是在队列前两个进程先起来:

process wake up, pid = 2014

process wake up, pid = 2017

accepted, pid = 2014

for p in pids;doecho−n" p “; cat /proc/$p/sched | grep “nr_wakeups>”; done

2010 se.nr_wakeups : 2

2011 se.nr_wakeups : 2

2012 se.nr_wakeups : 2

2013 se.nr_wakeups : 2

2014 se.nr_wakeups : 3

2015 se.nr_wakeups : 2

2016 se.nr_wakeups : 2

2017 se.nr_wakeups : 2

服务端:./alarmsrv 12346 > /tmp/epsrv.log

客户端:

for((i=1;i<=10000;i++)); do echo ” $i” | nc 127.0.0.1 12346; done

结果统计:

for p in pids;doecho−n“ p " ; grep "accept" /tmp/epsrv.log | grep $p | wc -l ; done

9655 0

9656 2335

9657 0

9658 5684

9659 0

9660 1738

9661 0

9662 38

结论:

1、fork + epoll 的ep独享模式,不能保证连接分配的均匀;

2、在监听连接sk-》sleep队列前面的几个等待进程,将会分配到更多的子连接

3、cpu 核数越多,将有更多的子进程拿到连接,

解决方案:

1、使用内核(2.6.32以上版本)reuseport特性, 每个子进程独立创建监听端口,tcp根据四元组哈希分配给某个进程的监听端口,tcp哈希分配比较均衡;

2、nginx 做法 同一时刻只有一个worker子进程监听端口,当某个工作进程达到到最大connection的7/8时,此工作进程不会再去拿accept锁

继续阅读