一次壓力測試Bug排查-epoll使用避坑指南
阿新 • • 發佈:2020-02-24
本文始發於個人公眾號:兩猿社,原創不易,求個關注
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模式實踐程式設計較少,沒有深刻理解和深入應用。
如果覺得有所收穫,請順手點個關注吧,你們的舉手之勞對我來說很重要。