如果這篇文章說不清epoll的本質,那就過來掐死我吧

從事服務端開發,少不了要接觸網絡編程。epoll作爲linux下高性能網絡服務器的必備技術至關重要,nginx、redis、skynet和大部分遊戲服務器都使用到這一多路複用技術。

/羅培羽

因爲epoll的重要性,不少遊戲公司(如某某九九)在招聘服務端同學時,可能會問及epoll相關的問題。比如epoll和select的區別是什麼?epoll高效率的原因是什麼?如果只靠背誦,顯然不能算上深刻的理解。

網上雖然也有不少講解epoll的文章,但要不是過於淺顯,就是陷入源碼解析,很少能有通俗易懂的。於是決定編寫此文,讓缺乏專業背景知識的讀者也能夠明白epoll的原理。文章核心思想是:

要讓讀者清晰明白EPOLL爲什麼性能好。

本文會從網卡接收數據的流程講起,串聯起CPU中斷、操作系統進程調度等知識;再一步步分析阻塞接收數據、select到epoll的進化過程;最後探究epoll的實現細節。目錄:

一、從網卡接收數據說起
二、如何知道接收了數據?
三、進程阻塞爲什麼不佔用cpu資源?
四、內核接收網絡數據全過程
五、同時監視多個socket的簡單方法
六、epoll的設計思路
七、epoll的原理和流程
八、epoll的實現細節
九、結論

 

一、從網卡接收數據說起

下圖是一個典型的計算機結構圖,計算機由CPU、存儲器(內存)、網絡接口等部件組成。瞭解epoll本質的第一步,要從硬件的角度看計算機怎樣接收網絡數據。

計算機結構圖(圖片來源:linux內核完全註釋之微型計算機組成結構)

下圖展示了網卡接收數據的過程。在①階段,網卡收到網線傳來的數據;經過②階段的硬件電路的傳輸;最終將數據寫入到內存中的某個地址上(③階段)。這個過程涉及到DMA傳輸、IO通路選擇等硬件有關的知識,但我們只需知道:網卡會把接收到的數據寫入內存。

網卡接收數據的過程

 

通過硬件傳輸,網卡接收的數據存放到內存中。操作系統就可以去讀取它們。

二、如何知道接收了數據?

瞭解epoll本質的第二步,要從CPU的角度來看數據接收。要理解這個問題,要先了解一個概念——中斷。

計算機執行程序時,會有優先級的需求。比如,當計算機收到斷電信號時(電容可以保存少許電量,供CPU運行很短的一小段時間),它應立即去保存數據,保存數據的程序具有較高的優先級。

一般而言,由硬件產生的信號需要cpu立馬做出迴應(不然數據可能就丟失),所以它的優先級很高。cpu理應中斷掉正在執行的程序,去做出響應;當cpu完成對硬件的響應後,再重新執行用戶程序。中斷的過程如下圖,和函數調用差不多。只不過函數調用是事先定好位置,而中斷的位置由“信號”決定。

中斷程序調用

以鍵盤爲例,當用戶按下鍵盤某個按鍵時,鍵盤會給cpu的中斷引腳發出一個高電平。cpu能夠捕獲這個信號,然後執行鍵盤中斷程序。下圖展示了各種硬件通過中斷與cpu交互。

cpu中斷(圖片來源:net.pku.edu.cn)

現在可以回答本節提出的問題了:當網卡把數據寫入到內存後,網卡向cpu發出一箇中斷信號,操作系統便能得知有新數據到來,再通過網卡中斷程序去處理數據。

三、進程阻塞爲什麼不佔用cpu資源?

瞭解epoll本質的第三步,要從操作系統進程調度的角度來看數據接收。阻塞是進程調度的關鍵一環,指的是進程在等待某事件(如接收到網絡數據)發生之前的等待狀態,recv、select和epoll都是阻塞方法。瞭解“進程阻塞爲什麼不佔用cpu資源?”,也就能夠了解這一步

爲簡單起見,我們從普通的recv接收開始分析,先看看下面代碼:

//創建socket
int s = socket(AF_INET, SOCK_STREAM, 0);   
//綁定
bind(s, ...)
//監聽
listen(s, ...)
//接受客戶端連接
int c = accept(s, ...)
//接收客戶端數據
recv(c, ...);
//將數據打印出來
printf(...)

這是一段最基礎的網絡編程代碼,先新建socket對象,依次調用bind、listen、accept,最後調用recv接收數據。recv是個阻塞方法,當程序運行到recv時,它會一直等待,直到接收到數據才往下執行。

插入:如果您還不太熟悉網絡編程,歡迎閱讀我編寫的《Unity3D網絡遊戲實戰(第2版)》,會有詳細的介紹。

那麼阻塞的原理是什麼?

工作隊列

操作系統爲了支持多任務,實現了進程調度的功能,會把進程分爲“運行”和“等待”等幾種狀態。運行狀態是進程獲得cpu使用權,正在執行代碼的狀態;等待狀態是阻塞狀態,比如上述程序運行到recv時,程序會從運行狀態變爲等待狀態,接收到數據後又變回運行狀態。操作系統會分時執行各個運行狀態的進程,由於速度很快,看上去就像是同時執行多個任務。

下圖中的計算機中運行着A、B、C三個進程,其中進程A執行着上述基礎網絡程序,一開始,這3個進程都被操作系統的工作隊列所引用,處於運行狀態,會分時執行。

工作隊列中有A、B和C三個進程

等待隊列

當進程A執行到創建socket的語句時,操作系統會創建一個由文件系統管理的socket對象(如下圖)。這個socket對象包含了發送緩衝區、接收緩衝區、等待隊列等成員。等待隊列是個非常重要的結構,它指向所有需要等待該socket事件的進程。

創建socket

當程序執行到recv時,操作系統會將進程A從工作隊列移動到該socket的等待隊列中(如下圖)。由於工作隊列只剩下了進程B和C,依據進程調度,cpu會輪流執行這兩個進程的程序,不會執行進程A的程序。所以進程A被阻塞,不會往下執行代碼,也不會佔用cpu資源

socket的等待隊列

ps:操作系統添加等待隊列只是添加了對這個“等待中”進程的引用,以便在接收到數據時獲取進程對象、將其喚醒,而非直接將進程管理納入自己之下。上圖爲了方便說明,直接將進程掛到等待隊列之下。

喚醒進程

當socket接收到數據後,操作系統將該socket等待隊列上的進程重新放回到工作隊列,該進程變成運行狀態,繼續執行代碼。也由於socket的接收緩衝區已經有了數據,recv可以返回接收到的數據。

四、內核接收網絡數據全過程

這一步,貫穿網卡、中斷、進程調度的知識,敘述阻塞recv下,內核接收數據全過程。

如下圖所示,進程在recv阻塞期間,計算機收到了對端傳送的數據(步驟①)。數據經由網卡傳送到內存(步驟②),然後網卡通過中斷信號通知cpu有數據到達,cpu執行中斷程序(步驟③)。此處的中斷程序主要有兩項功能,先將網絡數據寫入到對應socket的接收緩衝區裏面(步驟④),再喚醒進程A(步驟⑤),重新將進程A放入工作隊列中。

內核接收數據全過程

喚醒進程的過程如下圖所示。

喚醒進程

以上是內核接收數據全過程

這裏留有兩個思考題,大家先想一想。

其一,操作系統如何知道網絡數據對應於哪個socket?

其二,如何同時監視多個socket的數據?

 

 

 

(——我是分割線,想好了才能往下看哦~)

 

 

公佈答案的時刻到了。

第一個問題:因爲一個socket對應着一個端口號,而網絡數據包中包含了ip和端口的信息,內核可以通過端口號找到對應的socket。當然,爲了提高處理速度,操作系統會維護端口號到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的等待隊列中。

操作系統把進程A分別加入這三個socket的等待隊列中

當任何一個socket收到數據後,中斷程序將喚起進程。下圖展示了sock2接收到了數據的處理流程。

ps:recv和select的中斷回調可以設置成不同的內容。

sock2接收到了數據,中斷程序喚起進程A

所謂喚起進程,就是將進程從所有的等待隊列中移除,加入到工作隊列裏面。如下圖所示。

將進程A從所有等待隊列中移除,再加入到工作隊列裏面

經由這些步驟,當進程A被喚醒後,它知道至少有一個socket接收了數據。程序只需遍歷一遍socket列表,就可以得到就緒的socket。

這種簡單方式行之有效,在幾乎所有操作系統都有對應的實現。

 

但是簡單的方法往往有缺點,主要是:

其一,每次調用select都需要將進程加入到所有監視socket的等待隊列,每次喚醒都需要從每個隊列中移除。這裏涉及了兩次遍歷,而且每次都要將整個fds列表傳遞給內核,有一定的開銷。正是因爲遍歷操作開銷大,出於效率的考量,纔會規定select的最大監視數量,默認只能監視1024個socket。

其二,進程被喚醒後,程序並不知道哪些socket收到數據,還需要遍歷一次。

那麼,有沒有減少遍歷的方法?有沒有保存就緒socket的方法?這兩個問題便是epoll技術要解決的。

 

補充說明: 本節只解釋了select的一種情形。當程序調用select時,內核會先遍歷一遍socket,如果有一個以上的socket接收緩衝區有數據,那麼select直接返回,不會阻塞。這也是爲什麼select的返回值有可能大於1的原因之一。如果沒有socket有數據,進程纔會阻塞。

六、epoll的設計思路

epoll是在select出現N多年後才被髮明的,是select和poll的增強版本。epoll通過以下一些措施來改進效率。

措施一:功能分離

select低效的原因之一是將“維護等待隊列”和“阻塞進程”兩個步驟合二爲一。如下圖所示,每次調用select都需要這兩步操作,然而大多數應用場景中,需要監視的socket相對固定,並不需要每次都修改。epoll將這兩個操作分開,先用epoll_ctl維護等待隊列,再調用epoll_wait阻塞進程。顯而易見的,效率就能得到提升。

相比select,epoll拆分了功能

爲方便理解後續的內容,我們先複習下epoll的用法。如下的代碼中,先用epoll_create創建一個epoll對象epfd,再通過epoll_ctl將需要監視的socket添加到epfd中,最後調用epoll_wait等待數據。

int s = socket(AF_INET, SOCK_STREAM, 0);   
bind(s, ...)
listen(s, ...)

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的原理和流程。

創建epoll對象

如下圖所示,當某個進程調用epoll_create方法時,內核會創建一個eventpoll對象(也就是程序中epfd所代表的對象)。eventpoll對象也是文件系統中的一員,和socket一樣,它也會有等待隊列。

內核創建eventpoll對象

創建一個代表該epoll的eventpoll對象是必須的,因爲內核要維護“就緒列表”等數據,“就緒列表”可以作爲eventpoll的成員。

維護監視列表

創建epoll對象後,可以用epoll_ctl添加或刪除所要監聽的socket。以添加socket爲例,如下圖,如果通過epoll_ctl添加sock1、sock2和sock3的監視,內核會將eventpoll添加到這三個socket的等待隊列中。

添加所要監聽的socket

當socket收到數據後,中斷程序會操作eventpoll對象,而不是直接操作進程。

接收數據

當socket收到數據後,中斷程序會給eventpoll的“就緒列表”添加socket引用。如下圖展示的是sock2和sock3收到數據後,中斷程序讓rdlist引用這兩個socket。

給就緒列表添加引用

eventpoll對象相當於是socket和進程之間的中介,socket的數據接收並不直接影響進程,而是通過改變eventpoll的就緒列表來改變進程狀態。

當程序執行到epoll_wait時,如果rdlist已經引用了socket,那麼epoll_wait直接返回,如果rdlist爲空,阻塞進程。

阻塞和喚醒進程

假設計算機中正在運行進程A和進程B,在某時刻進程A運行到了epoll_wait語句。如下圖所示,內核會將進程A放入eventpoll的等待隊列中,阻塞進程。

epoll_wait阻塞進程

當socket接收到數據,中斷程序一方面修改rdlist,另一方面喚醒eventpoll等待隊列中的進程,進程A再次進入運行狀態(如下圖)。也因爲rdlist的存在,進程A可以知道哪些socket發生了變化。

epoll喚醒進程

八、epoll的實現細節

至此,相信讀者對epoll的本質已經有一定的瞭解。但我們還留有一個問題,eventpoll的數據結構是什麼樣子?

再留兩個問題,就緒隊列應該應使用什麼數據結構?eventpoll應使用什麼數據結構來管理通過epoll_ctl添加或刪除的socket?

 

(——我是分割線,想好了才能往下看哦~)

 

如下圖所示,eventpoll包含了lock、mtx、wq(等待隊列)、rdlist等成員。rdlist和rbr是我們所關心的。

epoll原理示意圖,圖片來源:《深入理解Nginx:模塊開發與架構解析(第二版)》,陶輝

 

就緒列表的數據結構

就緒列表引用着就緒的socket,所以它應能夠快速的插入數據。

程序可能隨時調用epoll_ctl添加監視socket,也可能隨時刪除。當刪除時,若該socket已經存放在就緒列表中,它也應該被移除。

所以就緒列表應是一種能夠快速插入和刪除的數據結構。雙向鏈表就是這樣一種數據結構,epoll使用雙向鏈表來實現就緒隊列(對應上圖的rdllist)。

索引結構

既然epoll將“維護監視隊列”和“進程阻塞”分離,也意味着需要有個數據結構來保存監視的socket。至少要方便的添加和移除,還要便於搜索,以避免重複添加。紅黑樹是一種自平衡二叉查找樹,搜索、插入和刪除時間複雜度都是O(log(N)),效率較好。epoll使用了紅黑樹作爲索引結構(對應上圖的rbr)。

 

ps:因爲操作系統要兼顧多種功能,以及由更多需要保存的數據,rdlist並非直接引用socket,而是通過epitem間接引用,紅黑樹的節點也是epitem對象。同樣,文件系統也並非直接引用着socket。爲方便理解,本文中省略了一些間接結構。

九、結論

epoll在select和poll(poll和select基本一樣,有少量改進)的基礎引入了eventpoll作爲中間層,使用了先進的數據結構,是一種高效的多路複用技術。

再留一點作業

下表是個很常見的表,描述了select、poll和epoll的區別。讀完本文,讀者能否解釋select和epoll的時間複雜度爲什麼是O(n)和O(1)?

select、poll和epoll的區別。圖片來源《Linux高性能服務器編程》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章