從事服務端開發,少不了要接觸網路編程。epoll作為linux下高性能網路伺服器的必備技術至關重要,大部分遊戲伺服器都使用到這一多路復用技術。文章核心思想是:要讓讀者清晰明白EPOLL為什麼性能好。
文/羅培羽
上篇回顧
一、從網卡接收數據說起
二、如何知道接收了數據?
三、進程阻塞為什麼不佔用cpu資源?
這一步,貫穿網卡、中斷、進程調度的知識,敘述阻塞recv下,內核接收數據全過程。
如下圖所示,進程在recv阻塞期間,計算機收到了對端傳送的數據(步驟①)。數據經由網卡傳送到內存(步驟②),然後網卡通過中斷信號通知cpu有數據到達,cpu執行中斷程序(步驟③)。此處的中斷程序主要有兩項功能,先將網路數據寫入到對應socket的接收緩衝區裡面(步驟④),再喚醒進程A(步驟⑤),重新將進程A放入工作隊列中。
喚醒進程的過程如下圖所示。
以上是內核接收數據全過程
這裡留有兩個思考題,大家先想一想。
其一,操作系統如何知道網路數據對應於哪個socket?
其二,如何同時監視多個socket的數據?
(——我是分割線,想好了才能往下看哦~)
公布答案的時刻到了。
第一個問題:因為一個socket對應著一個埠號,而網路數據包中包含了ip和埠的信息,內核可以通過埠號找到對應的socket。當然,為了提高處理速度,操作系統會維護埠號到socket的索引結構,以快速讀取。
第二個問題是多路復用的重中之重,是本文後半部分的重點!
服務端需要管理多個客戶端連接,而recv只能監視單個socket,這種矛盾下,人們開始尋找監視多個socket的方法。epoll的要義是高效的監視多個socket。從歷史發展角度看,必然先出現一種不太高效的方法,人們再加以改進。只有先理解了不太高效的方法,才能夠理解epoll的本質。
假如能夠預先傳入一個socket列表,如果列表中的socket都沒有數據,掛起進程,直到有一個socket收到數據,喚醒進程。這種方法很直接,也是select的設計思想。
為方便理解,我們先複習select的用法。在如下的代碼中,先準備一個數組(下面代碼中的fds),讓fds存放著所有需要監視的socket。然後調用select,如果fds中的所有socket都沒有數據,select會阻塞,直到有一個socket接收到數據,select返回,喚醒進程。用戶可以遍歷fds,通過FD_ISSET判斷具體哪個socket收到數據,然後做出處理。
int s = socket(AF_INET, SOCK_STREAM, 0); bind(s, ...) listen(s, ...)
int fds[] = 存放需要監聽的socket
while(1){ int n = select(..., fds, ...) for(int i=0; i < fds.count; i++){ if(FD_ISSET(fds[i], ...)){ //fds[i]的數據處理 } } }
select的流程
select的實現思路很直接。假如程序同時監視如下圖的sock1、sock2和sock3三個socket,那麼在調用select之後,操作系統把進程A分別加入這三個socket的等待隊列中。
當任何一個socket收到數據後,中斷程序將喚起進程。下圖展示了sock2接收到了數據的處理流程。
ps:recv和select的中斷回調可以設置成不同的內容。
所謂喚起進程,就是將進程從所有的等待隊列中移除,加入到工作隊列裡面。如下圖所示。
經由這些步驟,當進程A被喚醒後,它知道至少有一個socket接收了數據。程序只需遍歷一遍socket列表,就可以得到就緒的socket。
這種簡單方式行之有效,在幾乎所有操作系統都有對應的實現。
但是簡單的方法往往有缺點,主要是:
其一,每次調用select都需要將進程加入到所有監視socket的等待隊列,每次喚醒都需要從每個隊列中移除。這裡涉及了兩次遍歷,而且每次都要將整個fds列表傳遞給內核,有一定的開銷。正是因為遍歷操作開銷大,出於效率的考量,才會規定select的最大監視數量,默認只能監視1024個socket。
其二,進程被喚醒後,程序並不知道哪些socket收到數據,還需要遍歷一次。
那麼,有沒有減少遍歷的方法?有沒有保存就緒socket的方法?這兩個問題便是epoll技術要解決的。
補充說明: 本節只解釋了select的一種情形。當程序調用select時,內核會先遍歷一遍socket,如果有一個以上的socket接收緩衝區有數據,那麼select直接返回,不會阻塞。這也是為什麼select的返回值有可能大於1的原因之一。如果沒有socket有數據,進程才會阻塞。
epoll是在select出現N多年後才被發明的,是select和poll的增強版本。epoll通過以下一些措施來改進效率。
措施一:功能分離
select低效的原因之一是將「維護等待隊列」和「阻塞進程」兩個步驟合二為一。如下圖所示,每次調用select都需要這兩步操作,然而大多數應用場景中,需要監視的socket相對固定,並不需要每次都修改。epoll將這兩個操作分開,先用epoll_ctl維護等待隊列,再調用epoll_wait阻塞進程。顯而易見的,效率就能得到提升。
為方便理解後續的內容,我們先複習下epoll的用法。如下的代碼中,先用epoll_create創建一個epoll對象epfd,再通過epoll_ctl將需要監視的socket添加到epfd中,最後調用epoll_wait等待數據。
int epfd = epoll_create(...); epoll_ctl(epfd, ...); //將所有需要監聽的socket添加到epfd中
while(1){ int n = epoll_wait(...) for(接收到數據的socket){ //處理 } }
功能分離,使得epoll有了優化的可能。
措施二:就緒列表
select低效的另一個原因在於程序不知道哪些socket收到數據,只能一個個遍歷。如果內核維護一個「就緒列表」,引用收到數據的socket,就能避免遍歷。如下圖所示,計算機共有三個socket,收到數據的sock2和sock3被rdlist(就緒列表)所引用。當進程被喚醒後,只要獲取rdlist的內容,就能夠知道哪些socket收到數據。
以下內容待續
七、epoll的原理和流程
八、epoll的實現細節
九、結論
除了網路編程,「同步」也是網路遊戲開發的核心課題。多人遊戲中,玩家在世界中的位置旋轉以及各種屬性都會對遊戲表現產生影響,需要同步給其他玩家。然而由於網路通信存在延遲和抖動,很難做到完美的同步效果。如果沒能處理好,遊戲將不同步和卡頓,這是玩家所不能容忍的。筆者即將開展一場知乎live《網路遊戲同步演算法》,分析同步技術,歡迎收聽。
致謝:本文力圖詳細說明epoll的原理,特別感謝 @陸俊壕 @AllenKong12 雄爺、堂叔 等同事審閱了文章並給予修改意見。
推薦閱讀:
TAG:Linux內核 | epoll | IO多路復用 |