天天看點

Linux驚群效應詳解(最詳細的了吧)

linux驚群效應

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

1、驚群效應是什麼?

       驚群效應也有人叫做雷鳴群體效應,不過叫什麼,簡言之,驚群現象就是多程序(多線程)在同時阻塞等待同一個事件的時候(休眠狀态),如果等待的這個事件發生,那麼他就會喚醒等待的所有程序(或者線程),但是最終卻隻可能有一個程序(線程)獲得這個時間的“控制權”,對該事件進行處理,而其他程序(線程)擷取“控制權”失敗,隻能重新進入休眠狀态,這種現象和性能浪費就叫做驚群。

        為了更好的了解何為驚群,舉一個很簡單的例子,當你往一群鴿子中間扔一粒谷子,所有的各自都被驚動前來搶奪這粒食物,但是最終注定隻可能有一個鴿子滿意的搶到食物,沒有搶到的鴿子隻好回去繼續睡覺,等待下一粒谷子的到來。這裡鴿子表示程序(線程),那粒谷子就是等待處理的事件。

看一下:WIKI的雷鳴群體效應的解釋

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

     我想你應該也會有跟我一樣的問題,那就是驚群效應到底消耗了什麼?

     (1)、系統對使用者程序/線程頻繁地做無效的排程,上下文切換系統性能大打折扣。

     (2)、為了確定隻有一個線程得到資源,使用者必須對資源操作進行加鎖保護,進一步加大了系統開銷。

     是不是還是覺得不夠深入,概念化?看下面:

         *1、上下文切換(context  switch)過高會導緻cpu像個搬運工,頻繁地在寄存器和運作隊列之間奔波,更多的時間花在了程序(線程)切換,而不是在真正工作的程序(線程)上面。直接的消耗包括cpu寄存器要儲存和加載(例如程式計數器)、系統排程器的代碼需要執行。間接的消耗在于多核cache之間的共享資料。

看一下:wiki上下文切換

         *2、通過鎖機制解決驚群效應是一種方法,在任意時刻隻讓一個程序(線程)處理等待的事件。但是鎖機制也會造成cpu等資源的消耗和性能損耗。目前一些常見的伺服器軟體有的是通過鎖機制解決的,比如nginx(它的鎖機制是預設開啟的,可以關閉);還有些認為驚群對系統性能影響不大,沒有去處理,比如lighttpd。

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

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

*1)accept()驚群:

       首先讓我們先來考慮一個場景:

        主程序建立了socket、bind、listen之後,fork()出來多個程序,每個子程序都開始循環處理(accept)這個listen_fd。每個程序都阻塞在accept上,當一個新的連接配接到來時候,所有的程序都會被喚醒,但是其中隻有一個程序會接受成功,其餘皆失敗,重新休眠。

       那麼這個問題真的存在嗎?

       曆史上,Linux的accpet确實存在驚群問題,但現在的核心都解決該問題了。即,當多個程序/線程都阻塞在對同一個socket的接受調用上時,當有一個新的連接配接到來,核心隻會喚醒一個程序,其他程序保持休眠,壓根就不會被喚醒。

       不妨寫個程式測試一下,眼見為實:

fork_thunder_herd.c:

#include<stdio.h>

#include<stdlib.h>

#include<sys/types.h>

#include<sys/socket.h>

#include<sys/wait.h>

#include<string.h>

#include<netinet/in.h>

#include<unistd.h>

#define PROCESS_NUM 10

int main()

{

    int fd = socket(PF_INET, SOCK_STREAM, 0);

    int connfd;

    int pid;

    char sendbuff[1024];

    struct sockaddr_in serveraddr;

    serveraddr.sin_family = AF_INET;

    serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);

    serveraddr.sin_port = htons(1234);

    bind(fd, (struct sockaddr *)&serveraddr, sizeof(serveraddr));

    listen(fd, 1024);

    int i;

    for(i = 0; i < PROCESS_NUM; ++i){

        pid = fork();

        if(pid == 0){

            while(1){

                connfd = accept(fd, (struct sockaddr *)NULL, NULL);

                snprintf(sendbuff, sizeof(sendbuff), "接收到accept事件的程序PID = %d\n", getpid());

                send(connfd, sendbuff, strlen(sendbuff)+1, 0);

                printf("process %d accept success\n", getpid());

                close(connfd);

            }

        }

    }

    //int status;

    wait(0);

    return 0;

}

這個程式模拟上面的場景,當我們用telnet連接配接該伺服器程式時,會看到隻傳回一個程序pid,即隻有一個程序被喚醒。

我們用strace -f來追蹤fork子程序的執行:

編譯:cc fork_thunder_herd.c -o server

           一個終端執行strace -f  ./server  你會看到如下結果(隻截取部分可以說明問題的截圖,減小篇幅):

這裡我們首先看到系統建立了十個程序。下面這張圖你會看出十個程序阻塞在accept這個系統調用上面:

接下來在另一個終端執行telnet 127.0.0.1 1234:

很明顯當telnet連接配接的時候隻有一個程序accept成功,你會不會和我有同樣的疑問,就是會不會核心中喚醒了所有的程序隻是沒有擷取到資源失敗了,就好像驚群被“隐藏”?

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

                connfd = accept(fd, (struct sockaddr *)NULL, NULL);

                if(connfd == 0){

                    snprintf(sendbuff, sizeof(sendbuff), "接收到accept事件的程序PID = %d\n", getpid());

                    send(connfd, sendbuff, strlen(sendbuff)+1, 0);

                    printf("process %d accept success\n", getpid());

                    close(connfd);

                }else{

                    printf("process %d accept a connection failed: %s\n", getpid(), strerror(errno));

                    close(connfd);

                }

沒錯,就是增加了一個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和“驚群”

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

代碼執行個體:epoll_thunder_herd.c:

#include<stdio.h>

#include<sys/types.h>

#include<sys/socket.h>

#include<unistd.h>

#include<sys/epoll.h>

#include<netdb.h>

#include<stdlib.h>

#include<fcntl.h>

#include<sys/wait.h>

#include<errno.h>

#define PROCESS_NUM 10

#define MAXEVENTS 64

//socket建立和綁定

int sock_creat_bind(char * port){

    int sock_fd = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in serveraddr;

    serveraddr.sin_family = AF_INET;

    serveraddr.sin_port = htons(atoi(port));

    serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);

    bind(sock_fd, (struct sockaddr *)&serveraddr, sizeof(serveraddr));

    return sock_fd;

}

//利用fcntl設定檔案或者函數調用的狀态标志

int make_nonblocking(int fd){

    int val = fcntl(fd, F_GETFL);

    val |= O_NONBLOCK;

    if(fcntl(fd, F_SETFL, val) < 0){

        perror("fcntl set");

        return -1;

    }

    return 0;

}

int main(int argc, char *argv[])

{

    int sock_fd, epoll_fd;

    struct epoll_event event;

    struct epoll_event *events;

    if(argc < 2){

        printf("usage: [port] %s", argv[1]);

        exit(1);

    }

     if((sock_fd = sock_creat_bind(argv[1])) < 0){

        perror("socket and bind");

        exit(1);

    }

    if(make_nonblocking(sock_fd) < 0){

        perror("make non blocking");

        exit(1);

    }

    if(listen(sock_fd, SOMAXCONN) < 0){

        perror("listen");

        exit(1);

    }

    if((epoll_fd = epoll_create(MAXEVENTS))< 0){

        perror("epoll_create");

        exit(1);

    }

    event.data.fd = sock_fd;

    event.events = EPOLLIN;

    if(epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sock_fd, &event) < 0){

        perror("epoll_ctl");

        exit(1);

    }

    events = calloc(MAXEVENTS, sizeof(event));

    int i;

    for(i = 0; i < PROCESS_NUM; ++i){

        int pid = fork();

        if(pid == 0){

            while(1){

                int num, j;

                num = epoll_wait(epoll_fd, events, MAXEVENTS, -1);

                printf("process %d returnt from epoll_wait\n", getpid());

                sleep(2);

                for(i = 0; i < num; ++i){

                    if((events[i].events & EPOLLERR) || (events[i].events & EPOLLHUP) || (!(events[i].events & EPOLLIN))){

                        fprintf(stderr, "epoll error\n");

                        close(events[i].data.fd);

                        continue;

                    }else if(sock_fd == events[i].data.fd){

                        //收到關于監聽套接字的通知,意味着一盒或者多個傳入連接配接

                        struct sockaddr in_addr;

                        socklen_t in_len = sizeof(in_addr);

                        if(accept(sock_fd, &in_addr, &in_len) < 0){

                            printf("process %d accept failed!\n", getpid());

                        }else{

                            printf("process %d accept successful!\n", getpid());

                        }

                    }

                }

            }

        }

    }

    wait(0);

    free(events);

    close(sock_fd);

    return 0;

}

上面的代碼編譯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);這樣所有的程序都會被喚起。看下面改正後的代碼結果更加清晰:

代碼修改:

                num = epoll_wait(epoll_fd, events, MAXEVENTS, -1);

                printf("process %d returnt from epoll_wait\n", getpid());

                sleep(2);

運作結果:

如圖所示:所有的程序都被喚醒了。是以epoll_wait的驚群确實存在。

為什麼核心處理了accept的驚群,卻不處理epoll_wait的驚群呢?

我想,應該是這樣的:

accept确實應該隻能被一個程序調用成功,核心很清楚這一點。但epoll不一樣,他監聽的檔案描述符,除了可能後續被accept調用外,還有可能是其他網絡IO事件的,而其他IO事件是否隻能由一個程序處理,是不一定的,核心不能保證這一點,這是一個由使用者決定的事情,例如可能一個檔案會由多個程序來讀寫。是以,對epoll的驚群,核心則不予處理。

*3)線程驚群:

    程序的驚群已經介紹的很詳細了,這裡我就舉一個線程驚群的簡單例子,我就截取上次紅包代碼中的代碼片段,如下

        printf("初始的紅包情況:<個數:%d  金額:%d.%02d>\n",item.number, item.total/100, item.total%100);

        pthread_cond_broadcast(&temp.cond);//紅包包好後喚醒所有線程搶紅包

        pthread_mutex_unlock(&temp.mutex);//解鎖

        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()函數中解決驚群現象。

void ngx_process_events_and_timers(ngx_cycle_t *cycle)

{

    ... ...

    // 是否通過對accept加鎖來解決驚群問題,需要工作線程數>1且配置檔案打開accetp_mutex

    if (ngx_use_accept_mutex) {

        // 超過配置檔案中最大連接配接數的7/8時,該值大于0,此時滿負荷不會再處理新連接配接,簡單負載均衡

        if (ngx_accept_disabled > 0) {

            ngx_accept_disabled--;

        } else {

            // 多個worker僅有一個可以得到這把鎖。擷取鎖不會阻塞過程,而是立刻傳回,擷取成功的話

            // ngx_accept_mutex_held被置為1。拿到鎖意味着監聽句柄被放到本程序的epoll中了,如果

            // 沒有拿到鎖,則監聽句柄會被從epoll中取出。

            if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {

                return;

            }

            if (ngx_accept_mutex_held) {

                // 此時意味着ngx_process_events()函數中,任何事件都将延後處理,會把accept事件放到

                // ngx_posted_accept_events連結清單中,epollin|epollout事件都放到ngx_posted_events連結清單中

                flags |= NGX_POST_EVENTS;

            } else {

                // 拿不到鎖,也就不會處理監聽的句柄,這個timer實際是傳給epoll_wait的逾時時間,修改

                // 為最大ngx_accept_mutex_delay意味着epoll_wait更短的逾時傳回,以免新連接配接長時間沒有得到處理

                if (timer == NGX_TIMER_INFINITE || timer > ngx_accept_mutex_delay) {

                    timer = ngx_accept_mutex_delay;

                }

            }

        }

    }

    ... ...

    (void) ngx_process_events(cycle, timer, flags);   // 實際調用ngx_epoll_process_events函數開始處理

    ... ...

    if (ngx_posted_accept_events) { //如果ngx_posted_accept_events連結清單有資料,就開始accept建立新連接配接

        ngx_event_process_posted(cycle, &ngx_posted_accept_events);

    }

    if (ngx_accept_mutex_held) { //釋放鎖後再處理下面的EPOLLIN EPOLLOUT請求

        ngx_shmtx_unlock(&ngx_accept_mutex);

    }

    if (delta) {

        ngx_event_expire_timers();

    }

    ngx_log_debug1(NGX_LOG_DEBUG_EVENT, cycle->log, 0, "posted events %p", ngx_posted_events);

    // 然後再處理正常的資料讀寫請求。因為這些請求耗時久,是以在ngx_process_events裡NGX_POST_EVENTS标

    // 志将事件都放入ngx_posted_events連結清單中,延遲到鎖釋放了再處理。

}}

具體的解釋參考: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的詳細介紹請參考:

SO_REUSEPORT 

參考資料:

https://pureage.info/2015/12/22/thundering-herd.html

http://www.tuicool.com/articles/2aumqe

http://blog.163.com/[email protected]/blog/static/16223010220122611523786/

http://baike.baidu.com/link?url=6x0zTazmBxTYE9ngPt_boKjS8ivdQnRlfhHj-STCnqG9tjKwfCluPsKlq-ASUkdQTPW3XrD8FtyilBaI75GJCK

http://m.blog.csdn.net/tuantuanls/article/details/41205739

tcp對so_reuseport的優化 

參考文獻:

https://blog.csdn.net/lyztyycode/article/details/78648798