如何優雅的處理 accept 出現 EMFILE 的問題

通常情況下,服務端調用 accept 函數會返回一個新的文件描述符,用於和客戶端之間的數據傳輸

在伺服器的開發中,有時會遇到這種情況:當調用 accept 函數接受客戶端連接,函數返回失敗,對應的錯誤碼是 EMFILE, 它表示當前進程打開的文件描述符已達上限,此時,伺服器不能再接受客戶端連接

當遇到上述問題,怎麼合理的處理呢,下面就來分析一下

建立連接的流程

先簡單回顧下客戶端和伺服器建立連接的流程,具體的如下圖所示:


1. 客戶端發起 SYN 請求



2. 伺服器收到客戶端的 SYN 請求後,內核把連接放入半連接隊列,同時給客戶端返回一個 SYN + ACK
 


3. 客戶端向伺服器返回一個確認的 ACK, 伺服器收到本次 ACK 之後,三次握手完成,同時,內核把連接從半連接隊列中移除,創建新完全連接,加入到全連接隊列中
 


4. 應用層調用 accept 函數從全連接隊列中取出連接

上面的第 1、第 2、第 3 步是 TCP 的三次握手,它是由內核中TCP協議完成的, 第 4 步是應用層調用 accept 介面

在 epoll 中的問題

epoll 是 Linux中IO多路復用模型,在伺服器的開發中有廣泛的應用,下面就以 epoll 為例來詳細說明

伺服器端創建偵聽文件描述符 listenfd 之後, 向 epoll 註冊讀事件

當 epoll 檢測到 listenfd 上有讀事件發生,會立即通知應用層,應用層調用 accept 接受新連接,而此時進程打開的文件描述符數量已經達到上限了,所以每次 accept 都是失敗的

這裡會出現以下幾個問題

  1. 由於 每次 accept 都失敗了,相當於 listenfd 上的可讀事件沒有處理,epoll 會不停的觸發 listenfd 上的可讀事件,應用層也就會不停的調用 accept,然後又出現 accept 調用失敗,如此這般不停的執行無效的循環,白白浪費了CPU的資源

  2. 上面提到伺服器在不停的執行無效的循環, 將會引發另一個問題,如果此時有新客戶端連接到來,建立連接的過程會很慢

前面說的 epoll 默認是使用了水平觸發模式,如果使用垂直觸發模式會出現什麼問題呢?

垂直觸發模式下,listenfd 從無讀事件狀態到有讀事件狀態時,才會通知到應用層,在應用層處理完 listenfd 上所有的讀事件之前,epoll 不會再通知應用層

也就是說,應用層收到 listenfd 上讀事件通知之後,需要把 listenfd 上所有的讀事件全部處理完,下次listenfd 上再有讀事件時,才會通知應用層

回到 accept 的問題上,在垂直觸發模式下,當 epoll 通知應用層 listenfd 上有可讀事件時,應用層調用 accept, 由於此時進程打開的文件描述符數量已經達到上限了,所以 accept 調用失敗

也即 listenfd 上的可讀事件還沒有處理,在應用層處理完 listenfd 上可讀事件之前,epoll 不會再通知應用層 listenfd 上有可讀事件

如果在應用層處理完 listenfd 上可讀事件之前,有新的客戶端連接到來,這個時候 epoll 是不會通知應用層 listenfd 上有可讀事件,這會導致一個嚴重的問題:accept 只要出現了 EMFILE的錯誤碼,就再也無法接受客戶端的連接了

所以,當出現 EMFILE 時,不管使用 epoll 的水平觸發模式還是垂直觸發模式都會存在問題

如何解決

EMFILE 表示進程打開的文件描述符數量達到上限了,可以把這個值調大些,但這治標不治本

本來系統設置文件描述符數量上限是為了限制進程對系統資源的過度佔用,況且,這個值調整到多大合適呢,總不能無限大吧,所以調整上限值的方式不是最合適的方式

accept 成功時會返回一個新的文件描述符,如果此時進程打開的文件描述符已經達到上限了,就會返回失敗

假如此時能關閉一個空閑的文件描述符,讓出一個名額,再調用 accept 就會創建成功,這總方式具體的處理步驟如下:


1、事先準備一個空閑的文件描述符 idlefd,相當於先佔一個"坑"位

2、調用 close 關閉 idlefd,關閉之後,進程就會獲得一個文件描述符名額

3、再次調用 accept 函數, 此時就會返回新的文件描述符 clientfd, 立刻調用 close 函數,關閉 clientfd

4、重新創建空閑文件描述符 idlefd,重新佔領 "坑" 位,再出現這種情況的時候又可以使用

測試程式碼比較長,這裡就不貼了,感興趣可以通過文末的方式獲取,下面是處理 EMFILE 的偽程式碼:

int ret = accept( listenfd, (struct sockaddr*)&addr, sizeof(addr) );

if (-1 == ret)
{
  if ( errno == EMFILE )
  {
	 //關閉空閑文件描述符,釋放 "坑"位
     close(idlefd);
	 
	 //接受 clientfd
	 clientfd = accept( listenfd, nullptr, nullptr);
	 //關閉 clientfd,防止一直觸發 listenfd 上的可讀事件
	 close(clientfd);
	 
	 //重新佔領 "坑"位
	 idlefd = ::open("/dev/null", O_RDONLY | O_CLOEXEC);
  }
}

Tags: