一次壓力測試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模式實踐編程較少,沒有深刻理解和深入應用。

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM