Linux accept()/epoll_wait()驚群問題與解決方案
問題的來源:
參考《UNP 第三版》第30章“客戶/伺服器設計正規化”中“30.6 TCP預先派生子程序伺服器程式”
// 為便於說明問題,程式碼已簡化
int main(int argc, char **argv)
{
int listenfd = Tcp_Listen();
for (int i = 0; i < nchildren; i++){
if ((pid = fork()) == 0){
child_main(i, listenfd, addrlen);
}
}
for(;;){
pause();
}
}
pid_t child_main(int i, int listenfd, int addrlen)
{
struct sockaddr clientAddr;
socklen_t clientAddrLen;
for(;;)
{
int connfd = accept(listenfd, &clientAddr, &clientAddrLen);
web_child(connfd);
close(connfd);
}
}
在如上所示的程式碼中,主程序建立了用於監聽的socket描述符listenfd,每個子程序阻塞呼叫accept(listenfd, ),等待獲取客戶端的新建連線connfd,並放入web_child(connfd)中執行。
因為多個子程序被同時阻塞在同一個監聽socket上,當有客戶端的新建連線接入時,所有被阻塞的子程序都被喚醒,但是隻有執行最快的那個子程序呼叫accept(listenfd, )能夠得到客戶連線connfd,其它子程序呼叫accept(listenfd, )只會等到EGAIN。這種多個子程序被喚醒後又無事可做的狀態被稱為“驚群”,因為每次程序排程的系統開銷相對較大,所以對於高併發伺服器,頻繁的“驚群”必然導致的伺服器效能低下。
針對30.6樣例中存在的問題,30.7和30.8給出了兩種方案分別使用“檔案鎖”和“執行緒互斥鎖”保護accept()。簡單的說,就是所有的子程序在呼叫accept()之前,先獲取一個鎖,只有得到鎖的程序才能繼續執行accept()函式,其它程序都被鎖阻塞了。並且,通過驗證,這種加鎖的方式對於解決“驚群”問題是有效的。
for(;;)
{
lock(); // 檔案鎖或互斥鎖
int client = accept(...);
unlock();
if (client < 0) continue;
...
}
另外,30.9中還給出了一個“主程序aceept客戶連線,再將客戶連線描述符傳遞給子程序處理”的方案。不過,通過實驗證明:和鎖相比,程序間傳遞描述符的效率更低。這算是題外話吧。
其實,如果只考慮Linux系統,在Linux 2.6版本以後,核心核心已經解決了accept()函式的“驚群”問題,大概的處理方式就是,當核心接收到一個客戶連線後,只會喚醒等待佇列上的第一個程序或執行緒。所以,如果伺服器採用accept阻塞呼叫方式,在最新的Linux系統上,已經沒有“驚群”的問題了。
但是,對於實際工程中常見的伺服器程式,大都使用select、poll或epoll機制,此時,伺服器不是阻塞在accept,而是阻塞在select、poll或epoll_wait,這種情況下的“驚群”仍然需要考慮。接下來以epoll為例分析:
在早期的Linux版本中,核心對於阻塞在epoll_wait的程序,也是採用全部喚醒的機制,所以存在和accept相似的“驚群”問題。新版本的的解決方案也是只會喚醒等待佇列上的第一個程序或執行緒,所以,新版本Linux 部分的解決了epoll的“驚群”問題。所謂部分的解決,意思就是:對於部分特殊場景,使用epoll機制,已經不存在“驚群”的問題了,但是對於大多數場景,epoll機制仍然存在“驚群”。
1、場景一:epoll_create()在fork子程序之前:
如果epoll_create()呼叫在fork子程序之前,那麼epoll_create()建立的epfd 會被所有子程序繼承。接下來,所有子程序阻塞呼叫epoll_wait(),等待被監控的描述符(包括用於監聽客戶連線的監聽描述符)出現新事件。如果監聽描述符發生可讀事件,核心將阻塞佇列上排在第一位的程序/執行緒喚醒,被喚醒的程序/執行緒繼續執行accept()函式,得到新建立的客戶連線描述符connfd。這種情況下,任何一個子程序被喚醒並執行accept()函式都是沒有問題的。
但是,接下來,子程序的工作如果是呼叫epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);
將新建連線的描述符connfd加入到epfd中統一監控的話,因為,當前的epfd是在fork之前建立的,此時系統中只有一個epoll監控檔案,即所有子程序共享一個epoll監控檔案。任何一個程序(父程序或子程序)向epoll監控檔案新增、修改和刪除檔案描述符時,都會影響到其它程序的epoll_wait。
後續,當connfd描述符上接收到客戶端資訊時,核心也無法保證每次都是喚醒同一個程序/執行緒,來處理這個連線描述符connfd上的讀寫資訊(其它程序可能根本就不認識connfd;或者在不同程序中,相同的描述符對應不同的客戶端連線),最終導致連線處理錯誤。(另外,不同的執行緒處理同一個連線描述符,也會導致傳送的資訊亂序)
所以,應該避免epoll_create()在fork子程序之前。關於這一點,據說libevent的文件中有專門的描述。
2、場景二:epoll_create()在fork子程序之後:
如果epoll_create()在fork子程序之後,則每個程序都有自己的epoll監控檔案(當某個程序將新建連線的描述符connfd加入到本程序的epfd中統一監控,不會影響其它程序的epoll_wait),但是為了實現併發監聽,所有的子程序都會呼叫
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);
將監聽描述符加入到監控檔案中,也就是說所有子程序都在通過epoll機制輪詢同一個監聽描述符。如果有新的客戶端請求接入,監聽描述符出現POLLIN事件(表示描述符可讀,有新連線接入),此時核心會喚醒所有的程序,所以“驚群”的問題依然存在。
對於這種情況下的“驚群”問題,Nginx的解決方案和《UNP 第三版》第30章中30.7和30.8給出的加鎖方案類似,大概就是通過互斥鎖對每個程序從epoll_wait到accept之間的處理通過互斥量保護。需要注意的是,對於這種加鎖操作,每次只有一個子程序能執行epoll_wait和accept,具體哪個程序得到執行,要看核心排程。所以,為了解決負載均衡的問題,Nginx的解決方案中,每個程序有一個當前連線計數,如果當前連線計數超過最大連線的7/8,該程序就停止接收新的連線。
lock()
epoll_wait(...);
accept(...);
unlock(...);
另外,我在想,如果不考慮多程序,而是用多執行緒實現,因為,執行緒排程的開銷比程序要小很多,那麼在多執行緒下,是否就可以不用考慮驚群的問題,當然這個結論需要具體的測試資料,後面有空準備測試一下。
3、利用SO_REUSEPORT解決epoll的驚群問題
網上這方面的內容也非常多,大家可以自行搜尋。因為這是利用核心新版本新特性的解決方案,應該算是終極解決方案吧。
利用SO_REUSEPORT解決epoll的驚群問題