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锁