linux驚羣效應

轉載自:https://blog.csdn.net/lyztyycode/article/details/78648798

linux驚羣效應

詳細的介紹什麼是驚羣,驚羣在線程和進程中的具體表現,驚羣的系統消耗和驚羣的處理方法。

1、驚羣效應是什麼?

       驚羣效應也有人叫做雷鳴羣體效應,不過叫什麼,簡言之,驚羣現象就是多進程(多線程)在同時阻塞等待同一個事件的時候(休眠狀態),如果等待的這個事件發生,那麼他就會喚醒等待的所有進程(或者線程),但是最終卻只可能有一個進程(線程)獲得這個時間的“控制權”,對該事件進行處理,而其他進程(線程)獲取“控制權”失敗,只能重新進入休眠狀態,這種現象和性能浪費就叫做驚羣。
        爲了更好的理解何爲驚羣,舉一個很簡單的例子,當你往一羣鴿子中間扔一粒穀子,所有的各自都被驚動前來搶奪這粒食物,但是最終註定只可能有一個鴿子滿意的搶到食物,沒有搶到的鴿子只好回去繼續睡覺,等待下一粒穀子的到來。這裏鴿子表示進程(線程),那粒穀子就是等待處理的事件。


2.驚羣效應到底消耗了什麼?

     我想你應該也會有跟我一樣的問題,那就是驚羣效應到底消耗了什麼?
     (1)、系統對用戶進程/線程頻繁地做無效的調度,上下文切換系統性能大打折扣。
     (2)、爲了確保只有一個線程得到資源,用戶必須對資源操作進行加鎖保護,進一步加大了系統開銷。
     是不是還是覺得不夠深入,概念化?看下面:
         *1、上下文切換(context  switch)過高會導致cpu像個搬運工,頻繁地在寄存器和運行隊列之間奔波,更多的時間花在了進程(線程)切換,而不是在真正工作的進程(線程)上面。直接的消耗包括cpu寄存器要保存和加載(例如程序計數器)、系統調度器的代碼需要執行。間接的消耗在於多核cache之間的共享數據。
         *2、通過鎖機制解決驚羣效應是一種方法,在任意時刻只讓一個進程(線程)處理等待的事件。但是鎖機制也會造成cpu等資源的消耗和性能損耗。目前一些常見的服務器軟件有的是通過鎖機制解決的,比如nginx(它的鎖機制是默認開啓的,可以關閉);還有些認爲驚羣對系統性能影響不大,沒有去處理,比如lighttpd。

3.驚羣效應的廬山真面目。

讓我們從進程和線程兩個方面來揭開驚羣效應的廬山真面目:

*1)accept()驚羣:

       首先讓我們先來考慮一個場景
        主進程創建了socket、bind、listen之後,fork()出來多個進程,每個子進程都開始循環處理(accept)這個listen_fd。每個進程都阻塞在accept上,當一個新的連接到來時候,所有的進程都會被喚醒,但是其中只有一個進程會接受成功,其餘皆失敗,重新休眠。
       那麼這個問題真的存在嗎
       歷史上,Linux的accpet確實存在驚羣問題,但現在的內核都解決該問題了。即,當多個進程/線程都阻塞在對同一個socket的接受調用上時,當有一個新的連接到來,內核只會喚醒一個進程,其他進程保持休眠,壓根就不會被喚醒。
       不妨寫個程序測試一下,眼見爲實:
fork_thunder_herd.c:
  1. #include<stdio.h>
  2. #include<stdlib.h>
  3. #include<sys/types.h>
  4. #include<sys/socket.h>
  5. #include<sys/wait.h>
  6. #include<string.h>
  7. #include<netinet/in.h>
  8. #include<unistd.h>
  9. #define PROCESS_NUM 10
  10. int main()
  11. {
  12. int fd = socket(PF_INET, SOCK_STREAM, 0);
  13. int connfd;
  14. int pid;
  15. char sendbuff[1024];
  16. struct sockaddr_in serveraddr;
  17. serveraddr.sin_family = AF_INET;
  18. serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
  19. serveraddr.sin_port = htons(1234);
  20. bind(fd, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
  21. listen(fd, 1024);
  22. int i;
  23. for(i = 0; i < PROCESS_NUM; ++i){
  24. pid = fork();
  25. if(pid == 0){
  26. while(1){
  27. connfd = accept(fd, (struct sockaddr *)NULL, NULL);
  28. snprintf(sendbuff, sizeof(sendbuff), "接收到accept事件的進程PID = %d\n", getpid());
  29. send(connfd, sendbuff, strlen(sendbuff)+1, 0);
  30. printf("process %d accept success\n", getpid());
  31. close(connfd);
  32. }
  33. }
  34. }
  35. //int status;
  36. wait(0);
  37. return 0;
  38. }
這個程序模擬上面的場景,當我們用telnet連接該服務器程序時,會看到只返回一個進程pid,即只有一個進程被喚醒。
我們用strace -f來追蹤fork子進程的執行:
編譯:cc fork_thunder_herd.c -o server
           一個終端執行strace -f  ./server  你會看到如下結果(只截取部分可以說明問題的截圖,減小篇幅):
這裏我們首先看到系統創建了十個進程。下面這張圖你會看出十個進程阻塞在accept這個系統調用上面:
接下來在另一個終端執行telnet 127.0.0.1 1234:

很明顯當telnet連接的時候只有一個進程accept成功,你會不會和我有同樣的疑問,就是會不會內核中喚醒了所有的進程只是沒有獲取到資源失敗了,就好像驚羣被“隱藏”?

這個問題很好證明,我們修改一下代碼:

  1. connfd = accept(fd, (struct sockaddr *)NULL, NULL);
  2. if(connfd == 0){
  3. snprintf(sendbuff, sizeof(sendbuff), "接收到accept事件的進程PID = %d\n", getpid());
  4. send(connfd, sendbuff, strlen(sendbuff)+1, 0);
  5. printf("process %d accept success\n", getpid());
  6. close(connfd);
  7. }else{
  8. printf("process %d accept a connection failed: %s\n", getpid(), strerror(errno));
  9. close(connfd);
  10. }

沒錯,就是增加了一個accept失敗的返回信息,按照上面的步驟運行,這裏我就不截圖了,我只告訴你運行結果與上面的運行結果無異,增加的失敗信息並沒有輸出,也就說明了這裏並沒有發生驚羣,所以注意阻塞和驚羣的喚醒的區別。

Google了一下:其實在linux2.6版本以後,linux內核已經解決了accept()函數的“驚羣”現象,大概的處理方式就是,當內核接收到一個客戶連接後,只會喚醒等待隊列上的第一個進程(線程),所以如果服務器採用accept阻塞調用方式,在最新的linux系統中已經沒有“驚羣效應”了

accept函數的驚羣解決了,下面來讓我們看看存在驚羣現象的另一種情況:epoll驚羣

*2)epoll驚羣:

     
概述:
如果多個進程/線程阻塞在監聽同一個監聽socket fd的epoll_wait上,當有一個新的連接到來時,所有的進程都會被喚醒。
同樣讓我們假設一個場景:
主進程創建socket,bind,listen後,將該socket加入到epoll中,然後fork出多個子進程,每個進程都阻塞在epoll_wait上,如果有事件到來,則判斷該事件是否是該socket上的事件如果是,說明有新的連接到來了,則進行接受操作。爲了簡化處理,忽略後續的讀寫以及對接受返回的新的套接字的處理,直接斷開連接。
那麼,當新的連接到來時,是否每個阻塞在epoll_wait上的進程都會被喚醒呢?
很多博客中提到,測試表明雖然epoll_wait不會像接受那樣只喚醒一個進程/線程,但也不會把所有的進程/線程都喚醒。
這究竟是問什麼呢?

我們還是眼見爲實,一步步解決上面的疑問:

代碼實例:epoll_thunder_herd.c:

  1. #include<stdio.h>
  2. #include<sys/types.h>
  3. #include<sys/socket.h>
  4. #include<unistd.h>
  5. #include<sys/epoll.h>
  6. #include<netdb.h>
  7. #include<stdlib.h>
  8. #include<fcntl.h>
  9. #include<sys/wait.h>
  10. #include<errno.h>
  11. #define PROCESS_NUM 10
  12. #define MAXEVENTS 64
  13. //socket創建和綁定
  14. int sock_creat_bind(char * port){
  15. int sock_fd = socket(AF_INET, SOCK_STREAM, 0);
  16. struct sockaddr_in serveraddr;
  17. serveraddr.sin_family = AF_INET;
  18. serveraddr.sin_port = htons(atoi(port));
  19. serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
  20. bind(sock_fd, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
  21. return sock_fd;
  22. }
  23. //利用fcntl設置文件或者函數調用的狀態標誌
  24. int make_nonblocking(int fd){
  25. int val = fcntl(fd, F_GETFL);
  26. val |= O_NONBLOCK;
  27. if(fcntl(fd, F_SETFL, val) < 0){
  28. perror("fcntl set");
  29. return -1;
  30. }
  31. return 0;
  32. }
  33. int main(int argc, char *argv[])
  34. {
  35. int sock_fd, epoll_fd;
  36. struct epoll_event event;
  37. struct epoll_event *events;
  38. if(argc < 2){
  39. printf("usage: [port] %s", argv[1]);
  40. exit(1);
  41. }
  42. if((sock_fd = sock_creat_bind(argv[1])) < 0){
  43. perror("socket and bind");
  44. exit(1);
  45. }
  46. if(make_nonblocking(sock_fd) < 0){
  47. perror("make non blocking");
  48. exit(1);
  49. }
  50. if(listen(sock_fd, SOMAXCONN) < 0){
  51. perror("listen");
  52. exit(1);
  53. }
  54. if((epoll_fd = epoll_create(MAXEVENTS))< 0){
  55. perror("epoll_create");
  56. exit(1);
  57. }
  58. event.data.fd = sock_fd;
  59. event.events = EPOLLIN;
  60. if(epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sock_fd, &event) < 0){
  61. perror("epoll_ctl");
  62. exit(1);
  63. }
  64. /*buffer where events are returned*/
  65. events = calloc(MAXEVENTS, sizeof(event));
  66. int i;
  67. for(i = 0; i < PROCESS_NUM; ++i){
  68. int pid = fork();
  69. if(pid == 0){
  70. while(1){
  71. int num, j;
  72. num = epoll_wait(epoll_fd, events, MAXEVENTS, -1);
  73. printf("process %d returnt from epoll_wait\n", getpid());
  74. sleep(2);
  75. for(i = 0; i < num; ++i){
  76. if((events[i].events & EPOLLERR) || (events[i].events & EPOLLHUP) || (!(events[i].events & EPOLLIN))){
  77. fprintf(stderr, "epoll error\n");
  78. close(events[i].data.fd);
  79. continue;
  80. }else if(sock_fd == events[i].data.fd){
  81. //收到關於監聽套接字的通知,意味着一盒或者多個傳入連接
  82. struct sockaddr in_addr;
  83. socklen_t in_len = sizeof(in_addr);
  84. if(accept(sock_fd, &in_addr, &in_len) < 0){
  85. printf("process %d accept failed!\n", getpid());
  86. }else{
  87. printf("process %d accept successful!\n", getpid());
  88. }
  89. }
  90. }
  91. }
  92. }
  93. }
  94. wait(0);
  95. free(events);
  96. close(sock_fd);
  97. return 0;
  98. }

上面的代碼編譯gcc epoll_thunder_herd.c -o server 

一個終端運行代碼 ./server 1234  另一個終端telnet 127.0.0.1 1234

運行結果:

這裏我們看到只有一個進程返回了,似乎並沒有驚羣效應,讓我們用strace -f  ./server 8888追蹤執行過程(這裏只給出telnet之後的截圖,之前的截圖參考accept,不同的就是進程阻塞在epoll_wait)

截圖(部分):

運行結果顯示了部分個進程被喚醒了,返回了“process accept failed”只是後面因爲某些原因失敗了。所以這裏貌似存在部分“驚羣”。

怎麼判斷髮生了驚羣呢?

我們根據strace的返回信息可以確定:

1)系統只會讓一個進程真正的接受這個連接,而剩餘的進程會獲得一個EAGAIN信號。圖中有體現。

2)通過返回結果和進程執行的系統調用判斷。

這究竟是什麼原因導致的呢?

看我們的代碼,看似部分進程被喚醒了,而事實上其餘進程沒有被喚醒的原因是因爲某個進程已經處理完這個事件,無需喚醒其他進程,你可以在epoll獲知這個事件的時候sleep(2);這樣所有的進程都會被喚起。看下面改正後的代碼結果更加清晰:

代碼修改:

  1. num = epoll_wait(epoll_fd, events, MAXEVENTS, -1);
  2. printf("process %d returnt from epoll_wait\n", getpid());
  3. sleep(2);

運行結果:


如圖所示:所有的進程都被喚醒了。所以epoll_wait的驚羣確實存在。

爲什麼內核處理了accept的驚羣,卻不處理epoll_wait的驚羣呢?

我想,應該是這樣的:
accept確實應該只能被一個進程調用成功,內核很清楚這一點。但epoll不一樣,他監聽的文件描述符,除了可能後續被accept調用外,還有可能是其他網絡IO事件的,而其他IO事件是否只能由一個進程處理,是不一定的,內核不能保證這一點,這是一個由用戶決定的事情,例如可能一個文件會由多個進程來讀寫。所以,對epoll的驚羣,內核則不予處理。

*3)線程驚羣:

    進程的驚羣已經介紹的很詳細了,這裏我就舉一個線程驚羣的簡單例子,我就截取上次紅包代碼中的代碼片段,如下
  1. printf("初始的紅包情況:<個數:%d 金額:%d.%02d>\n",item.number, item.total/100, item.total%100);
  2. pthread_cond_broadcast(&temp.cond);//紅包包好後喚醒所有線程搶紅包
  3. pthread_mutex_unlock(&temp.mutex);//解鎖
  4. sleep(1);
沒錯你可能已經注意到了,pthread_cond_broadcast()在資源準備好以後,或者你再編寫程序的時候設置的某個事件滿足時它會喚醒隊列上的所有線程去處理這個事件,但是隻有一個線程會真正的獲得事件的“控制權”。
解決方法之一就是加鎖。下面我們來看一看解決或者避免驚羣都有哪些方法?

4.我們怎麼解決“驚羣”呢?你有什麼高見?

這裏通常代碼加鎖的處理機制我就不詳述了,來看一下常見軟件的處理機制和linux最新的避免和解決的辦法

(1)、Nginx的解決:

如上所述,如果採用epoll,則仍然存在該問題,nginx就是這種場景的一個典型,我們接下來看看其具體的處理方法。
nginx的每個worker進程都會在函數ngx_process_events_and_timers()中處理不同的事件,然後通過ngx_process_events()封裝了不同的事件處理機制,在Linux上默認採用epoll_wait()。
在主要ngx_process_events_and_timers()函數中解決驚羣現象。
  1. void ngx_process_events_and_timers(ngx_cycle_t *cycle)
  2. {
  3. ... ...
  4. // 是否通過對accept加鎖來解決驚羣問題,需要工作線程數>1且配置文件打開accetp_mutex
  5. if (ngx_use_accept_mutex) {
  6. // 超過配置文件中最大連接數的7/8時,該值大於0,此時滿負荷不會再處理新連接,簡單負載均衡
  7. if (ngx_accept_disabled > 0) {
  8. ngx_accept_disabled--;
  9. } else {
  10. // 多個worker僅有一個可以得到這把鎖。獲取鎖不會阻塞過程,而是立刻返回,獲取成功的話
  11. // ngx_accept_mutex_held被置爲1。拿到鎖意味着監聽句柄被放到本進程的epoll中了,如果
  12. // 沒有拿到鎖,則監聽句柄會被從epoll中取出。
  13. if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
  14. return;
  15. }
  16. if (ngx_accept_mutex_held) {
  17. // 此時意味着ngx_process_events()函數中,任何事件都將延後處理,會把accept事件放到
  18. // ngx_posted_accept_events鏈表中,epollin|epollout事件都放到ngx_posted_events鏈表中
  19. flags |= NGX_POST_EVENTS;
  20. } else {
  21. // 拿不到鎖,也就不會處理監聽的句柄,這個timer實際是傳給epoll_wait的超時時間,修改
  22. // 爲最大ngx_accept_mutex_delay意味着epoll_wait更短的超時返回,以免新連接長時間沒有得到處理
  23. if (timer == NGX_TIMER_INFINITE || timer > ngx_accept_mutex_delay) {
  24. timer = ngx_accept_mutex_delay;
  25. }
  26. }
  27. }
  28. }
  29. ... ...
  30. (void) ngx_process_events(cycle, timer, flags); // 實際調用ngx_epoll_process_events函數開始處理
  31. ... ...
  32. if (ngx_posted_accept_events) { //如果ngx_posted_accept_events鏈表有數據,就開始accept建立新連接
  33. ngx_event_process_posted(cycle, &ngx_posted_accept_events);
  34. }
  35. if (ngx_accept_mutex_held) { //釋放鎖後再處理下面的EPOLLIN EPOLLOUT請求
  36. ngx_shmtx_unlock(&ngx_accept_mutex);
  37. }
  38. if (delta) {
  39. ngx_event_expire_timers();
  40. }
  41. ngx_log_debug1(NGX_LOG_DEBUG_EVENT, cycle->log, 0, "posted events %p", ngx_posted_events);
  42. // 然後再處理正常的數據讀寫請求。因爲這些請求耗時久,所以在ngx_process_events裏NGX_POST_EVENTS標
  43. // 志將事件都放入ngx_posted_events鏈表中,延遲到鎖釋放了再處理。
  44. }}
具體的解釋參考:nginx處理驚羣詳解

(2)、SO_REUSEPORT

Linux內核的3.9版本帶來了SO_REUSEPORT特性,該特性支持多個進程或者線程綁定到同一端口,提高服務器程序的性能,允許多個套接字bind()以及listen()同一個TCP或UDP端口,並且在內核層面實現負載均衡。

在未開啓SO_REUSEPORT的時候,由一個監聽socket將新接收的連接請求交給各個工作者處理,看圖示:


在使用SO_REUSEPORT後,多個進程可以同時監聽同一個IP:端口,然後由內核決定將新鏈接發送給哪個進程,顯然會降低每個工人接收新鏈接時鎖競爭

下面讓我們好好比較一下多進程(線程)服務器編程傳統方法和使用SO_REUSEPORT的區別

運行在Linux系統上的網絡應用程序,爲了利用多核的優勢,一般使用以下典型的多進程(多線程)服務器模型:

1.單線程listener/accept,多個工作線程接受任務分發,雖然CPU工作負載不再成爲問題,但是仍然存在問題:

       (1)、單線程listener(圖一),在處理高速率海量連接的時候,一樣會成爲瓶頸

        (2)、cpu緩存行丟失套接字結構現象嚴重。

2.所有工作線程都accept()在同一個服務器套接字上呢?一樣存在問題:

        (1)、多線程訪問server socket鎖競爭嚴重。

        (2)、高負載情況下,線程之間的處理不均衡,有時高達3:1。

        (3)、導致cpu緩存行跳躍(cache line bouncing)。

        (4)、在繁忙cpu上存在較大延遲。

上面兩種方法共同點就是很難做到cpu之間的負載均衡,隨着核數的提升,性能並沒有提升。甚至服務器的吞吐量CPS(Connection Per Second)會隨着核數的增加呈下降趨勢。

下面我們就來看看SO_REUSEPORT解決了什麼問題:

        (1)、允許多個套接字bind()/listen()同一個tcp/udp端口。每一個線程擁有自己的服務器套接字,在服務器套接字上沒有鎖的競爭。

        (2)、內核層面實現負載均衡

        (3)、安全層面,監聽同一個端口的套接字只能位於同一個用戶下面。

        (4)、處理新建連接時,查找listener的時候,能夠支持在監聽相同IP和端口的多個sock之間均衡選擇。

當一個連接到來的時候,系統到底是怎麼決定那個套接字來處理它?

對於不同內核,存在兩種模式,這兩種模式並不共存,一種叫做熱備份模式,另一種叫做負載均衡模式,3.9內核以後,全部改爲負載均衡模式。

熱備份模式:一般而言,會將所有的reuseport同一個IP地址/端口的套接字掛在一個鏈表上,取第一個即可,工作的只有一個,其他的作爲備份存在,如果該套接字掛了,它會被從鏈表刪除,然後第二個便會成爲第一個。
負載均衡模式:和熱備份模式一樣,所有reuseport同一個IP地址/端口的套接字會掛在一個鏈表上,你也可以認爲是一個數組,這樣會更加方便,當有連接到來時,用數據包的源IP/源端口作爲一個HASH函數的輸入,將結果對reuseport套接字數量取模,得到一個索引,該索引指示的數組位置對應的套接字便是工作套接字。這樣就可以達到負載均衡的目的,從而降低某個服務的壓力。


編程關於SO_REUSEPORT的詳細介紹請參考:


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章