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