1. 程式人生 > >一次壓力測試Bug排查-epoll使用避坑指南

一次壓力測試Bug排查-epoll使用避坑指南

本文始發於個人公眾號:兩猿社,原創不易,求個關注

Bug復現

使用Webbench對伺服器進行壓力測試,建立1000個客戶端,併發訪問伺服器10s,正常情況下有接近8萬個HTTP請求訪問伺服器。

結果顯示僅有7個請求被成功處理,0個請求處理失敗,伺服器也沒有返回錯誤。此時,從瀏覽器端訪問伺服器,發現該請求也不能被處理和響應,必須將伺服器重啟後,瀏覽器端才能訪問正常。


排查過程

通過查詢伺服器執行日誌,對伺服器接收HTTP請求連線,HTTP處理邏輯兩部分進行排查。

日誌中顯示,7個請求報文為:GET / HTTP/1.0的HTTP請求被正確處理和響應,排除HTTP處理邏輯錯誤。

因此,將重點放在接收HTTP請求連線部分。其中,伺服器端接收HTTP請求的連線步驟為socket -> bind -> listen -> accept;客戶端連線請求步驟為socket -> connect。

listen


#include<sys/socket.h>
int listen(int sockfd, int backlog)
  • 函式功能,把一個未連線的套接字轉換成一個被動套接字,指示核心應接受指向該套接字的連線請求。根據TCP狀態轉換圖,呼叫listen導致套接字從CLOSED狀態轉換成LISTEN狀態。
  • backlog是佇列的長度,核心為任何一個給定的監聽套介面維護兩個佇列:
    • 未完成連線佇列(incomplete connection queue),每個這樣的 SYN 分節對應其中一項:已由某個客戶發出併到達伺服器,而伺服器正在等待完成相應的 TCP 三次握手過程。這些套介面處於 SYN_RCVD 狀態。
    • 已完成連線佇列(completed connection queue),每個已完成 TCP 三次握手過程的客戶對應其中一項。這些套介面處於ESTABLISHED狀態。

connect

  • 當有客戶端主動連線(connect)伺服器,Linux 核心就自動完成TCP 三次握手,該項就從未完成連線佇列移到已完成連線佇列的隊尾,將建立好的連線自動儲存到佇列中,如此重複。

accept

  • 函式功能,從處於ESTABLISHED狀態的連線佇列頭部取出一個已經完成的連線(三次握手之後)。
  • 如果這個佇列沒有已經完成的連線,accept函式就會阻塞,直到取出佇列中已完成的使用者連線為止。
  • 如果,伺服器不能及時呼叫 accept取走佇列中已完成的連線,佇列滿掉後,TCP就緒佇列中剩下的連線都得不到處理,同時新的連線也不會到來。

從上面的分析中可以看出,accept如果沒有將佇列中的連線取完,就緒佇列中剩下的連線都得不到處理,也不能接收新請求,這個特性與壓力測試的Bug十分類似。


定位accept


//對檔案描述符設定非阻塞
int setnonblocking(int fd){
    int old_option=fcntl(fd,F_GETFL);
    int new_option=old_option | O_NONBLOCK;
    fcntl(fd,F_SETFL,new_option);
    return old_option;
}

//將核心事件表註冊讀事件,ET模式,選擇開啟EPOLLONESHOT
void addfd(int epollfd,int fd,bool one_shot)
{
    epoll_event event;
    event.data.fd=fd;
    event.events=EPOLLIN|EPOLLET|EPOLLRDHUP;
    if(one_shot)
        event.events|=EPOLLONESHOT;
    epoll_ctl(epollfd,EPOLL_CTL_ADD,fd,&event);
    setnonblocking(fd);
}

//建立核心事件表
epoll_event events[MAX_EVENT_NUMBER];
int epollfd=epoll_create(5);
assert(epollfd!=-1);

//將listenfd設定為ET邊緣觸發
addfd(epollfd,listenfd,false);

int number=epoll_wait(epollfd,events,MAX_EVENT_NUMBER,-1);

if(number<0&&errno!=EINTR)
{
    printf("epoll failure\n");
    break;
}

for(int i=0;i<number;i++)
{
    int sockfd=events[i].data.fd;

    //處理新到的客戶連線
    if(sockfd==listenfd)
    {
        struct sockaddr_in client_address;
        socklen_t client_addrlength=sizeof(client_address);
        
        //定位accept
        //從listenfd中接收資料
        int connfd=accept(listenfd,(struct sockaddr*)&client_address,&client_addrlength);
        if(connfd<0)
        {
            printf("errno is:%d\n",errno);
            continue;
        }
        //TODO,邏輯處理
    }
}

分析程式碼發現,web端和伺服器端建立連線,採用epoll的邊緣觸發模式同時監聽多個檔案描述符。

epoll的ET、LT

  • LT水平觸發模式
    • epoll_wait檢測到檔案描述符有事件發生,則將其通知給應用程式,應用程式可以不立即處理該事件。
    • 當下一次呼叫epoll_wait時,epoll_wait還會再次嚮應用程式報告此事件,直至被處理。
  • ET邊緣觸發模式
    • epoll_wait檢測到檔案描述符有事件發生,則將其通知給應用程式,應用程式必須立即處理該事件。
    • 必須要一次性將資料讀取完,使用非阻塞I/O,讀取到出現eagain。

從上面的定位分析,問題可能是錯誤使用epoll的ET模式。


程式碼分析修改

嘗試將listenfd設定為LT阻塞,或者ET非阻塞模式下while包裹accept對程式碼進行修改,這裡以ET非阻塞為例。

for(int i=0;i<number;i++)
{
    int sockfd=events[i].data.fd;

    //處理新到的客戶連線
    if(sockfd==listenfd)
    {
        struct sockaddr_in client_address;
        socklen_t client_addrlength=sizeof(client_address);
        
        //從listenfd中接收資料
        //這裡的程式碼出現使用錯誤
        while ((connfd = accept (listenfd, (struct sockaddr *) &remote, &addrlen)) > 0){
            if(connfd<0)
            {
                printf("errno is:%d\n",errno);
                continue;
            }
            //TODO,邏輯處理
        }
    }
}

將程式碼修改後,重新進行壓力測試,問題得到解決,伺服器成功完成75617個訪問請求,且沒有出現任何失敗的情況。壓測結果如下:


覆盤總結

  • Bug原因
    • established狀態的連線佇列backlog引數,歷史上被定義為已連線佇列和未連線佇列兩個的大小之和,大多數實現預設值為5。當連線較少時,佇列不會變滿,即使listenfd設定成ET非阻塞,不使用while一次性讀取完,也不會出現Bug。
    • 若此時1000個客戶端同時對伺服器發起連線請求,連線過多會造成established 狀態的連線佇列變滿。但accept並沒有使用while一次性讀取完,只讀取一個。因此,連線過多導致TCP就緒佇列中剩下的連線都得不到處理,同時新的連線也不會到來。
  • 解決方案
    • 將listenfd設定成LT阻塞,或者ET非阻塞模式下while包裹accept即可解決問題。

該Bug的出現,本質上對epoll的ET和LT模式實踐程式設計較少,沒有深刻理解和深入應用。

如果覺得有所收穫,請順手點個關注吧,你們的舉手之勞對我來說很重要。