accept 精群现象


https://blog.csdn.net/dog250/article/details/50528280

http://blog.csdn.net/russell_tao/article/details/7204260

http://blog.163.com/pandalove@126/blog/static/9800324520122633515612/

程序设想如下:
  1. 主进程先监听端口, listen_fd = socket(...);
  2. 创建epoll,epoll_fd = epoll_create(...);
  3. 然后开始fork(),每个子进程进入大循环,去等待new  accept,epoll_wait(...),处理事件等。
 
    接着就遇到了“惊群”现象:当listen_fd有新的accept()请求过来,操作系统会唤醒所有子进程(因为这些进程都epoll_wait()同一个listen_fd,操作系统又无从判断由谁来负责accept,索性干脆全部叫醒……),但最终只会有一个进程成功accept,其他进程accept失败。外国IT友人认为所有子进程都是被“吓醒”的,所以称之为 Thundering Herd(惊群)

 

什么是惊群

        举一个很简单的例子,当你往一群鸽子中间扔一块食物,虽然最终只有一个鸽子抢到食物,但所有鸽子都会被惊动来争夺,没有抢到食物的鸽子只好回去继续睡觉,等待下一块食物到来。这样,每扔一块食物,都会惊动所有的鸽子,即为惊群。对于操作系统来说,多个进程/线程在等待同一资源是,也会产生类似的效果,其结果就是每当资源可用,所有的进程/线程都来竞争资源,造成的后果:
1)系统对用户进程/线程频繁的做无效的调度、上下文切换,系统系能大打折扣。
2)为了确保只有一个线程得到资源,用户必须对资源操作进行加锁保护,进一步加大了系统开销。

        最常见的例子就是对于socket描述符的accept操作,当多个用户进程/线程监听在同一个端口上时,由于实际只可能accept一次,因此就会产生惊群现象,当然前面已经说过了,这个问题是一个古老的问题,新的操作系统内核已经解决了这一问题。

 

在说nginx前,先来看看什么是“惊群”?简单说来,多线程/多进程(linux下线程进程也没多大区别)等待同一个socket事件,当这个事件发生时,这些线程/进程被同时唤醒,就是惊群。可以想见,效率很低下,许多进程被内核重新调度唤醒,同时去响应这一个事件,当然只有一个进程能处理事件成功,其他的进程在处理该事件失败后重新休眠(也有其他选择)。这种性能浪费现象就是惊群。

 

惊群通常发生在server 上,当父进程绑定一个端口监听socket,然后fork出多个子进程,子进程们开始循环处理(比如accept)这个socket。每当用户发起一个TCP连接时,多个子进程同时被唤醒,然后其中一个子进程accept新连接成功,余者皆失败,重新休眠。

 

那么,我们不能只用一个进程去accept新连接么?然后通过消息队列等同步方式使其他子进程处理这些新建的连接,这样惊群不就避免了?没错,惊群是避免了,但是效率低下,因为这个进程只能用来accept连接。对多核机器来说,仅有一个进程去accept,这也是程序员在自己创造accept瓶颈。所以,我仍然坚持需要多进程处理accept事件。

 

其实,在linux2.6内核上,阻塞版本的accept系统调用已经不存在惊群了(至少我在2.6.18内核版本上已经不存在)。大家可以写个简单的程序试下,在父进程中bind,listen,然后fork出子进程,所有的子进程都accept这个监听句柄。这样,当新连接过来时,大家会发现,仅有一个子进程返回新建的连接,其他子进程继续休眠在accept调用上,没有被唤醒。

 

但是很不幸,通常我们的程序没那么简单,不会愿意阻塞在accept调用上,我们还有许多其他网络读写事件要处理,linux下我们爱用epoll解决非阻塞socket。所以,即使accept调用没有惊群了,我们也还得处理惊群这事,因为epoll有这问题。上面说的测试程序,如果我们在子进程内不是阻塞调用accept,而是用epoll_wait,就会发现,新连接过来时,多个子进程都会在epoll_wait后被唤醒!

 

nginx就是这样,master进程监听端口号(例如80),所有的nginx worker进程开始用epoll_wait来处理新事件(linux下),如果不加任何保护,一个新连接来临时,会有多个worker进程在epoll_wait后被唤醒,然后发现自己accept失败。现在,我们可以看看nginx是怎么处理这个惊群问题了。

 ===========================http://www.cppblog.com/isware/archive/2011/07/20/151470.aspx

服务器主进程:

listen_fd = socket(...);
bind(listen_fd, ...);
listen(listen_fd, ...);
pre_fork_children(...);
close(listen_fd);
wait_children_die(...);


服务器服务子进程:

while (1) {
conn_fd = accept(listen_fd, ...);
do_service(conn_fd, ...);
}


初 识上述代码,真有眼前一亮的感觉,也正如作者所说,以上代码确实很少见(反正我读此书之前是确实没见过)。作者真是构思精巧,巧妙地绕过了常见的预先创建 子进程的多进程服务器当主服务进程接收到新的连接必须想办法将这个连接传递给服务子进程的“陷阱”,上述代码通过共享的倾听套接字,由子进程主动地去向内 核“索要”连接套接字,从而避免了用UNIX域套接字传递文件描述符的“淫技”。

不过,当接着往下读的时候,作者谈到了“惊群” (Thundering herd)问题。所谓的“惊群”就是,当很多进程都阻塞在accept系统调用的时候,即使只有一个新的连接达到,内核也会唤醒所有阻塞在accept上 的进程,这将给系统带来非常大的“震颤”,降低系统性能。

除了这个问题,accept还必须是原子操作。为此,作者在接下来的27.7节讲述了加了互斥锁的版本:

while (1) {
lock(...);
conn_fd = accept(listen_fd, ...);
unlock(...);
do_service(conn_fd, ...);
}


原 子操作的问题算是解决了,那么“惊群”呢?文中只是提到在Solaris系统上当子进程数由75变成90后,CPU时间显著增加,并且作者认为这是因为进 程过多,导致内存互换。对“惊群”问题回答地十分含糊。通过比较书中图27.2的第4列和第7列的内容,我们可以肯定“真凶”绝对不是“内存对换”。

“元凶”到底是谁?

仔 细分析一下,加锁真的有助于“惊群”问题么?不错,确实在同一时间只有一个子进程在调用accept,其它子进程都阻塞在了lock语句,但是,当 accept返回并unlock之后呢?unlock肯定是要唤醒阻塞在这个锁上的进程的,不过谁都没有规定是唤醒一个还是唤醒多个。所以,潜在的“惊 群”问题还是存在,只不过换了个地方,换了个形式。而造成Solaris性能骤降的“罪魁祸首”很有可能就是“惊群”问题。

崩溃了!这么说所有的锁都有可能产生惊群问题了?

似乎真的是这样,所以减少锁的使用很重要。特别是在竞争比较激烈的地方。

作者在27.9节所实现的“传递文件描述符”版本的服务器就有效地克服了“惊群”问题,在现实的服务器实现中,最常用的也是此节所提到的基于“分配”形式。

把“竞争”换成“分配”是避免“惊群”问题的有效方法,但是也不要忽视“分配”的“均衡”问题,不然后果可能更加严重哦!

=====================

nginx的每个worker进程在函数ngx_process_events_and_timers中处理事件,(void) ngx_process_events(cycle, timer, flags);封装了不同的事件处理机制,在linux上默认就封装了epoll_wait调用。我们来看看ngx_process_events_and_timers为解决惊群做了什么:

 

  1. void  
  2. ngx_process_events_and_timers(ngx_cycle_t *cycle)  
  3. {  
  4. 。。。 。。。  
  5.     //ngx_use_accept_mutex表示是否需要通过对accept加锁来解决惊群问题。当nginx worker进程数>1时且配置文件中打开accept_mutex时,这个标志置为1  
  6.     if (ngx_use_accept_mutex) {  
  7.             //ngx_accept_disabled表示此时满负荷,没必要再处理新连接了,我们在nginx.conf曾经配置了每一个nginx worker进程能够处理的最大连接数,当达到最大数的7/8时,ngx_accept_disabled为正,说明本nginx worker进程非常繁忙,将不再去处理新连接,这也是个简单的负载均衡  
  8.         if (ngx_accept_disabled > 0) {  
  9.             ngx_accept_disabled--;  
  10.   
  11.         } else {  
  12.                 //获得accept锁,多个worker仅有一个可以得到这把锁。获得锁不是阻塞过程,都是立刻返回,获取成功的话ngx_accept_mutex_held被置为1。拿到锁,意味着监听句柄被放到本进程的epoll中了,如果没有拿到锁,则监听句柄会被从epoll中取出。  
  13.             if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {  
  14.                 return;  
  15.             }  
  16.   
  17.              //拿到锁的话,置flag为NGX_POST_EVENTS,这意味着ngx_process_events函数中,任何事件都将延后处理,会把accept事件都放到ngx_posted_accept_events链表中,epollin|epollout事件都放到ngx_posted_events链表中  
  18.             if (ngx_accept_mutex_held) {  
  19.                 flags |= NGX_POST_EVENTS;  
  20.   
  21.             } else {  
  22.                     //拿不到锁,也就不会处理监听的句柄,这个timer实际是传给epoll_wait的超时时间,修改为最大ngx_accept_mutex_delay意味着epoll_wait更短的超时返回,以免新连接长时间没有得到处理  
  23.                 if (timer == NGX_TIMER_INFINITE  
  24.                     || timer > ngx_accept_mutex_delay)  
  25.                 {  
  26.                     timer = ngx_accept_mutex_delay;  
  27.                 }  
  28.             }  
  29.         }  
  30.     }  
  31. 。。。 。。。  
  32.         //linux下,调用ngx_epoll_process_events函数开始处理  
  33.     (void) ngx_process_events(cycle, timer, flags);  
  34. 。。。 。。。  
  35.         //如果ngx_posted_accept_events链表有数据,就开始accept建立新连接  
  36.     if (ngx_posted_accept_events) {  
  37.         ngx_event_process_posted(cycle, &ngx_posted_accept_events);  
  38.     }  
  39.   
  40.         //释放锁后再处理下面的EPOLLIN EPOLLOUT请求  
  41.     if (ngx_accept_mutex_held) {  
  42.         ngx_shmtx_unlock(&ngx_accept_mutex);  
  43.     }  
  44.   
  45.     if (delta) {  
  46.         ngx_event_expire_timers();  
  47.     }  
  48.   
  49.     ngx_log_debug1(NGX_LOG_DEBUG_EVENT, cycle->log, 0,  
  50.                    "posted events %p", ngx_posted_events);  
  51.         //然后再处理正常的数据读写请求。因为这些请求耗时久,所以在ngx_process_events里NGX_POST_EVENTS标志将事件都放入ngx_posted_events链表中,延迟到锁释放了再处理。  
  52.     if (ngx_posted_events) {  
  53.         if (ngx_threaded) {  
  54.             ngx_wakeup_worker_thread(cycle);  
  55.   
  56.         } else {  
  57.             ngx_event_process_posted(cycle, &ngx_posted_events);  
  58.         }  
  59.     }  
  60. }  

 

 

 

从上面的注释可以看到,无论有多少个nginx worker进程,同一时刻只能有一个worker进程在自己的epoll中加入监听的句柄。这个处理accept的nginx worker进程置flag为NGX_POST_EVENTS,这样它在接下来的ngx_process_events函数(在linux中就是ngx_epoll_process_events函数)中不会立刻处理事件,延后,先处理完所有的accept事件后,释放锁,然后再处理正常的读写socket事件。我们来看下ngx_epoll_process_events是怎么做的:

 

 

[cpp]  view plain copy
 
  1. static ngx_int_t  
  2. ngx_epoll_process_events(ngx_cycle_t *cycle, ngx_msec_t timer, ngx_uint_t flags)  
  3. {  
  4. 。。。 。。。  
  5.     events = epoll_wait(ep, event_list, (int) nevents, timer);  
  6. 。。。 。。。  
  7.     ngx_mutex_lock(ngx_posted_events_mutex);  
  8.   
  9.     for (i = 0; i < events; i++) {  
  10.         c = event_list[i].data.ptr;  
  11.   
  12. 。。。 。。。  
  13.   
  14.         rev = c->read;  
  15.   
  16.         if ((revents & EPOLLIN) && rev->active) {  
  17. 。。。 。。。  
  18. //有NGX_POST_EVENTS标志的话,就把accept事件放到ngx_posted_accept_events队列中,把正常的事件放到ngx_posted_events队列中延迟处理  
  19.             if (flags & NGX_POST_EVENTS) {  
  20.                 queue = (ngx_event_t **) (rev->accept ?  
  21.                                &ngx_posted_accept_events : &ngx_posted_events);  
  22.   
  23.                 ngx_locked_post_event(rev, queue);  
  24.   
  25.             } else {  
  26.                 rev->handler(rev);  
  27.             }  
  28.         }  
  29.   
  30.         wev = c->write;  
  31.   
  32.         if ((revents & EPOLLOUT) && wev->active) {  
  33. 。。。 。。。  
  34. //同理,有NGX_POST_EVENTS标志的话,写事件延迟处理,放到ngx_posted_events队列中  
  35.             if (flags & NGX_POST_EVENTS) {  
  36.                 ngx_locked_post_event(wev, &ngx_posted_events);  
  37.   
  38.             } else {  
  39.                 wev->handler(wev);  
  40.             }  
  41.         }  
  42.     }  
  43.   
  44.     ngx_mutex_unlock(ngx_posted_events_mutex);  
  45.   
  46.     return NGX_OK;  
  47. }  


看看ngx_use_accept_mutex在何种情况下会被打开:

 

 

[cpp]  view plain copy
 
  1. if (ccf->master && ccf->worker_processes > 1 && ecf->accept_mutex) {  
  2.     ngx_use_accept_mutex = 1;  
  3.     ngx_accept_mutex_held = 0;  
  4.     ngx_accept_mutex_delay = ecf->accept_mutex_delay;  
  5.   
  6. else {  
  7.     ngx_use_accept_mutex = 0;  
  8. }  


当nginx worker数量大于1时,也就是多个进程可能accept同一个监听的句柄,这时如果配置文件中accept_mutex开关打开了,就将ngx_use_accept_mutex置为1。

 

再看看有些负载均衡作用的ngx_accept_disabled是怎么维护的,在ngx_event_accept函数中:

 

[cpp]  view plain copy
 
  1. ngx_accept_disabled = ngx_cycle->connection_n / 8  
  2.                       - ngx_cycle->free_connection_n;  


表明,当已使用的连接数占到在nginx.conf里配置的worker_connections总数的7/8以上时,ngx_accept_disabled为正,这时本worker将ngx_accept_disabled减1,而且本次不再处理新连接。

 

 

最后,我们看下ngx_trylock_accept_mutex函数是怎么玩的:

 

[cpp]  view plain copy
 
  1. ngx_int_t  
  2. ngx_trylock_accept_mutex(ngx_cycle_t *cycle)  
  3. {  
  4. //ngx_shmtx_trylock是非阻塞取锁的,返回1表示成功,0表示没取到锁  
  5.     if (ngx_shmtx_trylock(&ngx_accept_mutex)) {  
  6.   
  7. //ngx_enable_accept_events会把监听的句柄都塞入到本worker进程的epoll中  
  8.         if (ngx_enable_accept_events(cycle) == NGX_ERROR) {  
  9.             ngx_shmtx_unlock(&ngx_accept_mutex);  
  10.             return NGX_ERROR;  
  11.         }  
  12. //ngx_accept_mutex_held置为1,表示拿到锁了,返回  
  13.         ngx_accept_events = 0;  
  14.         ngx_accept_mutex_held = 1;  
  15.   
  16.         return NGX_OK;  
  17.     }  
  18.   
  19. //处理没有拿到锁的逻辑,ngx_disable_accept_events会把监听句柄从epoll中取出  
  20.     if (ngx_accept_mutex_held) {  
  21.         if (ngx_disable_accept_events(cycle) == NGX_ERROR) {  
  22.             return NGX_ERROR;  
  23.         }  
  24.   
  25.         ngx_accept_mutex_held = 0;  
  26.     }  
  27.   
  28.     return NGX_OK;  
  29. }                                


OK,关于锁的细节是如何实现的,这篇限于篇幅就不说了,下篇帖子再来讲。现在大家清楚nginx是怎么处理惊群了吧?简单了说,就是同一时刻只允许一个nginx worker在自己的epoll中处理监听句柄。它的负载均衡也很简单,当达到最大connection的7/8时,本worker不会去试图拿accept锁,也不会去处理新连接,这样其他nginx worker进程就更有机会去处理监听句柄,建立新连接了。而且,由于timeout的设定,使得没有拿到锁的worker进程,去拿锁的频繁更高。

 

 

======================

https://www.zhihu.com/question/24169490

既然扯到epoll的代码实现,那就说说这里,惊群主要还是指在多线程共享一个epoll fd的时候,如果epoll_wait所等待的fd(比如一条tcp连接可读)有事件发生时,epoll不知道唤醒哪个线程,就会把所有线程都唤醒,但是最终只有一个线程能去处理,其他的线程都会返回。如果不是多线程共享一个epoll,那就不会有这样的问题。

 
所以,一句话,多个进程(或线程)一起wait同一个fd的时候,会有“惊群”发生。

最后说一句,epoll的实现中,每次epoll_ctl/add/del的时候,通过ep_modify/insert/unlink/remove实现,操作中先调用spin_lock和获得读写锁,所以 在用户态中epoll是无锁编程,线程安全的,在内核态中是有锁的。也就是说,即使惊群,也还是安全的。我认为,这是除了效率外,epoll的最大亮点。



惊群的解决办法就是:同一个fd只有一个线程监听其状态,或者参考ngxin的处理方式:加锁


免责声明!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系本站邮箱yoyou2525@163.com删除。



 
粤ICP备18138465号  © 2018-2025 CODEPRJ.COM