0x01 前言
在服務器編程中,經常會遇到 Too many open files 這個報錯,而且這個報錯如果處理不好,很有可能會導致服務器死循環。
0x02 示例代碼
以上是我用rust寫的一個非常簡單的tcp服務器,它的主要邏輯是,先創建一個listener,然后再在循環里不斷調用listener.accept接收tcp連接,如果接收成功,就調用handle_client處理這個連接,如果接收失敗,就打印一行錯誤日志。
handle_client里的邏輯也非常簡單,就是等待客戶端關閉連接,或等待其發送任意數據,當這兩種情況發生時,handle_client就會直接關閉這個連接。
當然,如果在等待期間報錯了,handle_client也會打印一行錯誤日志。
下面我們就會使用這段程序,來演示服務器死循環的情況,這段程序不必非要用rust編寫,用其他語言也都可以。
測試代碼我已經放到github了,如果想要自己動手測試的,可以clone下來自己試下。
代碼地址:https://github.com/ytcoode/too-many-open-files
0x03 動手演示
先啟動該服務器:
由上圖可見,該服務器的進程id是312004,監聽地址是0.0.0.0:9999。
再查看下該服務器已打開的文件數:
一共是10個,主要包括標準輸入輸出、epoll、及一些socket。
再查看下該服務器進程最多可打開的文件數:
看選中行,Soft Limit那一列,其表示該進程最多可用的文件描述符數量為1024個,即最多可同時打開的文件數為1024個。
我們把它改小一點,方便后續測試:
上圖中,先使用prlimit命令將該服務器進程的Max open files數改成12,然后再用cat命令確認下該改動已生效。
至此,我們已經設置好該服務器進程最多可用的文件描述符數量為12,其當前已用的文件描述符數量為10,所以該服務器最多還可以再接收2個tcp連接。
我們用 `ncat localhost 9999` 命令建立連接試一下,當然你也可以用telnet, nc等其他命令,只要能建立tcp連接就行:
由上圖服務器日志可見,該tcp連接已建立成功。
再看下當前服務器已使用的文件描述符數量:
由上圖可見,新建socket使用的文件描述符為10,當前服務器進程已使用11個文件描述符,到目前為止一切正常。
用同樣的命令再建立一個tcp連接,這次應該也能連接成功,不過會有一些有意思的事情發生:
首先看上圖中最后一行info日志,它表示第二次tcp連接也建立成功了,如果此時去看文件描述符數量,也正好是12。
不過此次連接建立也導致不斷的error日志輸出,該服務器死循環了。
但此時,如果我們關閉第二次ncat命令建立的tcp連接,服務器又不會一直輸出error日志了,它又會恢復到正常狀態:
看上圖中的最后一條info日志,它表示第二個tcp連接正常關閉了,且當前已建立的連接數量是1。
此時,如果我們去看文件描述符數量,其也變成了11,這里就不再截圖了,有興趣的可以自己動手試下。
0x04 為什么會出現死循環?
首先,在linux的世界里,一切皆文件,這里就包括socket。
其次,linux為保證系統的整體性安全,為每個進程限制了其最大可使用的文件描述符數量,即最大可打開的文件數,這個數量就是上面我們用 `cat /proc/$(pidof too-many-open-files)/limits` 命令輸出的Max open files行,Soft Limit列對應的值,該值是可以通過各種方式修改的,在我的系統上,該值默認為1024。
接著,我們啟動了服務器,然后通過 `l /proc/$(pidof too-many-open-files)/fd/` 命令查看該服務器已使用的文件描述符數量,其為10。
之后,我們用prlimit命令將該服務器進程最大可使用的文件描述符數量改成了12,這樣該服務器就還只剩兩個文件描述符可用。
再之后,我們用ncat命令建立了兩個tcp連接,在服務器端的循環里,accept接收到這兩個連接并進行處理,此時該服務器進程消耗完了最后兩個可用的文件描述符。
接下來,服務器代碼進入下一次循環,繼續調用accept嘗試接收新的連接,問題的關鍵點也就出現在了這里。
accept是個系統調用,我們看下其對應的內核實現:
這個是accept系統調用的入口函數,沿著函數調用,可找到以下代碼:
由上圖可見,在真正的do_accept之前,會先調用get_unused_fd_flags找一個還未被使用的文件描述符,如果尋找時報錯了,即newfd < 0,則直接返回該錯誤碼給用戶層,如果找到了一個可用的文件描述符,則開始執行真正的accept操作。
繼續看get_unused_fd_flags函數:
它在調用其他函數之前,會通過 rlimit(RLIMIT_NOFILE) 獲取當前進程最大可使用的文件描述符數量,即我們上面通過prlitmit命令設置的12。
繼續往下看,我們會找到以下代碼:
該函數的目的是分配一個文件描述符,即fd,圖中選中行之前是找到一個還未被使用的fd,然后判斷該 fd 是否 >= end,如果是,則goto到out,進而return error,而這個error就是EMFILE。
那end值是什么呢?它就是上面用 rlimit(RLIMIT_NOFILE) 獲取的當前進程最大可用的文件描述符數。
結合上面的例子我們知道,當服務器接收完兩個tcp連接后,其最大可使用的12個文件描述符已全部被用完,當其循環到下一次accept系統調用后,會最終進入到上圖這個函數,這次新分配的fd值一定是12(因為fd值從0開始的,所以fd值為12表示第13個文件描述符),而我們又限制了該進程最大可用12個文件描述符,即我們限制了end值為12,所以在上圖選中行進行判斷時,fd 一定是 >= end 的,所以,該函數一定會返回EMFILE這個錯誤碼。
而EMFILE是什么呢?
它就是我們在運行測試程序時看到的 Too many open files 這個錯誤。
示例程序調用accept收到這個錯誤碼后,會打印一行error日志,然后繼續循環調用accept,然后繼續報錯,就這樣,服務器就在accept這里發生了死循環。
0x05 這個問題如何處理?
因為 too many open files 是個臨時性錯誤,當進程中的其他地方關閉了一些文件,或者管理人員調高了該進程的 max open files值,accept就不會再報 EMFILE 錯誤,也就不會再死循環了。
所以其處理方法也很簡單,就是在accept發生錯誤時,sleep一段時間,這樣既防止了cpu 100%的發生,也給進程時間來調整已用及最大的文件描述符數。
0x06 用epoll也會有這個問題嗎?
會有,epoll只是個通知機制,當epoll檢測到有連接可被接收時,還是會通過accept來接收這個連接。
不過這里分成兩種情況。
當使用epoll的edge-triggered模式時,正確寫法是要一直循環調用accept接收連接,直到其返回 EAGAIN 或 EWOULDBLOCK 錯誤碼,表示已經沒有連接可接收了,這時才能退出accept循環,但如果在這之前accept返回了 too many open files 這個錯誤,就會發生死循環了。
當使用epoll的level-triggered模式時,可以不必一直循環調用accept直到其返回EAGAIN 或 EWOULDBLOCK,可以提前退出,但如果操作系統里還有建立好的連接等待被接收,epoll還是會一直通知應用層,告知其要調用accept接收這些連接,如果此時文件描述符沒有了,accept還是會一直報 too many open files 錯誤,最終還是進入到了死循環。
0x07 Go是如何處理的?
下面我們看下go內置的http服務器,是如何處理這個問題的:
當accept返回err后,其會通過ne.Temporary()來檢查該err是否是臨時性錯誤,如果是,則會根據一定的規則,sleep一段時間。
這里,臨時性錯誤就包括 EMFILE,即too many open files錯誤:
我們也可以寫個簡單的例子測試下:
按照之前的方式,讓其觸發 too many open files 這個錯誤:
由圖可見,和我們上面分析的一樣,其也陷入了死循環,但是它用sleep的方式,防止cpu使用率100%。
0x08 Redis是如何處理的?
下面我們看下redis是如何處理這個問題的:
當anetTcpAccept返回 too many open files 錯誤時,它只打印了一行錯誤日志,就直接return了。
不過因為redis使用的是level-triggered模式的epoll,所以雖然這里直接return了,但因為底層的連接沒接收出來,epoll一直會調用這個函數,然后一直報錯,進而死循環。
實驗下:
可以看到,其一直在輸出這個錯誤。
0x09 結語
希望通過這篇文章,能給大家的技術水平帶來一點提高。
-
服務器
+關注
關注
12文章
9237瀏覽量
85666 -
程序
+關注
關注
117文章
3792瀏覽量
81171 -
代碼
+關注
關注
30文章
4803瀏覽量
68752
發布評論請先 登錄
相關推薦
評論