本文始發於個人公眾號:兩猿社,原創不易,求個關注
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模式實踐編程較少,沒有深刻理解和深入應用。
如果覺得有所收獲,請順手點個關注吧,你們的舉手之勞對我來說很重要。
