此文若說不清Epoll原理,那就過來掐死我!
從事服務端開發(fā),少不了要接觸網絡編程。Epoll 作為 Linux 下高性能網絡服務器的必備技術至關重要,Nginx、Redis、Skynet 和大部分游戲服務器都使用到這一多路復用技術。
Epoll 很重要,但是 Epoll 與 Select 的區(qū)別是什么呢?Epoll 高效的原因是什么?
網上雖然也有不少講解 Epoll 的文章,但要么是過于淺顯,或者陷入源碼解析,很少能有通俗易懂的。
筆者于是決定編寫此文,讓缺乏專業(yè)背景知識的讀者也能夠明白 Epoll 的原理。本文核心思想是:要讓讀者清晰明白 Epoll 為什么性能好。
文章會從網卡接收數據的流程講起,串聯起 CPU 中斷、操作系統(tǒng)進程調度等知識;再一步步分析阻塞接收數據、Select 到 Epoll 的進化過程;最后探究 Epoll 的實現細節(jié)。
從網卡接收數據說起
下邊是一個典型的計算機結構圖,計算機由 CPU、存儲器(內存)與網絡接口等部件組成,了解 Epoll 本質的第一步,要從硬件的角度看計算機怎樣接收網絡數據。
計算機結構圖(圖片來源:Linux 內核完全注釋之微型計算機組成結構)
下圖展示了網卡接收數據的過程:
- 在 1 階段,網卡收到網線傳來的數據。
- 經過 2 階段的硬件電路的傳輸。
- 最終 3 階段將數據寫入到內存中的某個地址上。
這個過程涉及到 DMA 傳輸、IO 通路選擇等硬件有關的知識,但我們只需知道:網卡會把接收到的數據寫入內存。
網卡接收數據的過程
通過硬件傳輸,網卡接收的數據存放到內存中,操作系統(tǒng)就可以去讀取它們。
如何知道接收了數據?
了解 Epoll 本質的第二步,要從 CPU 的角度來看數據接收。理解這個問題,要先了解一個概念:中斷。
計算機執(zhí)行程序時,會有優(yōu)先級的需求。比如,當計算機收到斷電信號時,它應立即去保存數據,保存數據的程序具有較高的優(yōu)先級(電容可以保存少許電量,供 CPU 運行很短的一小段時間)。
一般而言,由硬件產生的信號需要 CPU 立馬做出回應,不然數據可能就丟失了,所以它的優(yōu)先級很高。
CPU 理應中斷掉正在執(zhí)行的程序,去做出響應;當 CPU 完成對硬件的響應后,再重新執(zhí)行用戶程序。
中斷的過程如下圖,它和函數調用差不多,只不過函數調用是事先定好位置,而中斷的位置由“信號”決定。
中斷程序調用
以鍵盤為例,當用戶按下鍵盤某個按鍵時,鍵盤會給 CPU 的中斷引腳發(fā)出一個高電平,CPU 能夠捕獲這個信號,然后執(zhí)行鍵盤中斷程序。
下圖展示了各種硬件通過中斷與 CPU 交互的過程:
CPU 中斷(圖片來源:net.pku.edu.cn)
現在可以回答“如何知道接收了數據?”這個問題了:當網卡把數據寫入到內存后,網卡向 CPU 發(fā)出一個中斷信號,操作系統(tǒng)便能得知有新數據到來,再通過網卡中斷程序去處理數據。
進程阻塞為什么不占用 CPU 資源?
了解 Epoll 本質的第三步,要從操作系統(tǒng)進程調度的角度來看數據接收。阻塞是進程調度的關鍵一環(huán),指的是進程在等待某事件(如接收到網絡數據)發(fā)生之前的等待狀態(tài),Recv、Select 和 Epoll 都是阻塞方法。
下邊分析一下進程阻塞為什么不占用 CPU 資源?為簡單起見,我們從普通的 Recv 接收開始分析,先看看下面代碼:
- //創(chuàng)建socket
- int s = socket(AF_INET, SOCK_STREAM, 0);
- //綁定
- bind(s, ...)
- //監(jiān)聽
- listen(s, ...)
- //接受客戶端連接
- int c = accept(s, ...)
- //接收客戶端數據
- recv(c, ...);
- //將數據打印出來
- printf(...)
這是一段最基礎的網絡編程代碼,先新建 Socket 對象,依次調用 Bind、Listen 與 Accept,最后調用 Recv 接收數據。
Recv 是個阻塞方法,當程序運行到 Recv 時,它會一直等待,直到接收到數據才往下執(zhí)行。那么阻塞的原理是什么?
工作隊列
操作系統(tǒng)為了支持多任務,實現了進程調度的功能,會把進程分為“運行”和“等待”等幾種狀態(tài)。
運行狀態(tài)是進程獲得 CPU 使用權,正在執(zhí)行代碼的狀態(tài);等待狀態(tài)是阻塞狀態(tài),比如上述程序運行到 Recv 時,程序會從運行狀態(tài)變?yōu)榈却隣顟B(tài),接收到數據后又變回運行狀態(tài)。
操作系統(tǒng)會分時執(zhí)行各個運行狀態(tài)的進程,由于速度很快,看上去就像是同時執(zhí)行多個任務。
下圖的計算機中運行著 A、B 與 C 三個進程,其中進程 A 執(zhí)行著上述基礎網絡程序,一開始,這 3 個進程都被操作系統(tǒng)的工作隊列所引用,處于運行狀態(tài),會分時執(zhí)行。
工作隊列中有 A、B 和 C 三個進程
等待隊列
當進程 A 執(zhí)行到創(chuàng)建 Socket 的語句時,操作系統(tǒng)會創(chuàng)建一個由文件系統(tǒng)管理的 Socket 對象(如下圖)。
創(chuàng)建 Socket
這個 Socket 對象包含了發(fā)送緩沖區(qū)、接收緩沖區(qū)與等待隊列等成員。等待隊列是個非常重要的結構,它指向所有需要等待該 Socket 事件的進程。
當程序執(zhí)行到 Recv 時,操作系統(tǒng)會將進程 A 從工作隊列移動到該 Socket 的等待隊列中(如下圖)。
Socket 的等待隊列
由于工作隊列只剩下了進程 B 和 C,依據進程調度,CPU 會輪流執(zhí)行這兩個進程的程序,不會執(zhí)行進程 A 的程序。所以進程 A 被阻塞,不會往下執(zhí)行代碼,也不會占用 CPU 資源。
注:操作系統(tǒng)添加等待隊列只是添加了對這個“等待中”進程的引用,以便在接收到數據時獲取進程對象、將其喚醒,而非直接將進程管理納入自己之下。上圖為了方便說明,直接將進程掛到等待隊列之下。
喚醒進程
當 Socket 接收到數據后,操作系統(tǒng)將該 Socket 等待隊列上的進程重新放回到工作隊列,該進程變成運行狀態(tài),繼續(xù)執(zhí)行代碼。
同時由于 Socket 的接收緩沖區(qū)已經有了數據,Recv 可以返回接收到的數據。
內核接收網絡數據全過程
這一步,貫穿網卡、中斷與進程調度的知識,敘述阻塞 Recv 下,內核接收數據的全過程。
內核接收數據全過程
如上圖所示,進程在 Recv 阻塞期間:
- 計算機收到了對端傳送的數據(步驟 ①)
- 數據經由網卡傳送到內存(步驟 ②)
- 然后網卡通過中斷信號通知 CPU 有數據到達,CPU 執(zhí)行中斷程序(步驟 ③)
此處的中斷程序主要有兩項功能,先將網絡數據寫入到對應 Socket 的接收緩沖區(qū)里面(步驟 ④),再喚醒進程 A(步驟 ⑤),重新將進程 A 放入工作隊列中。
喚醒進程的過程如下圖所示:
喚醒進程
以上是內核接收數據全過程,這里我們可能會思考兩個問題:
- 操作系統(tǒng)如何知道網絡數據對應于哪個 Socket?
- 如何同時監(jiān)視多個 Socket 的數據?
第一個問題:因為一個 Socket 對應著一個端口號,而網絡數據包中包含了 IP 和端口的信息,內核可以通過端口號找到對應的 Socket。
當然,為了提高處理速度,操作系統(tǒng)會維護端口號到 Socket 的索引結構,以快速讀取。
第二個問題是多路復用的重中之重,也正是本文后半部分的重點。
同時監(jiān)視多個 Socket 的簡單方法
服務端需要管理多個客戶端連接,而 Recv 只能監(jiān)視單個 Socket,這種矛盾下,人們開始尋找監(jiān)視多個 Socket 的方法。Epoll 的要義就是高效地監(jiān)視多個 Socket。
從歷史發(fā)展角度看,必然先出現一種不太高效的方法,人們再加以改進,正如 Select 之于 Epoll。先理解不太高效的 Select,才能夠更好地理解 Epoll 的本質。
假如能夠預先傳入一個 Socket 列表,如果列表中的 Socket 都沒有數據,掛起進程,直到有一個 Socket 收到數據,喚醒進程。這種方法很直接,也是 Select 的設計思想。
為方便理解,我們先復習 Select 的用法。在下邊的代碼中,先準備一個數組 FDS,讓 FDS 存放著所有需要監(jiān)視的 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[] = 存放需要監(jiān)聽的socket
- while(1){
- int n = select(..., fds, ...)
- for(int i=0; i < fds.count; i++){
- if(FD_ISSET(fds[i], ...)){
- //fds[i]的數據處理
- }
- }
- }
Select 的流程
Select 的實現思路很直接,假如程序同時監(jiān)視如下圖的 Sock1、Sock2 和 Sock3 三個 Socket,那么在調用 Select 之后,操作系統(tǒng)把進程 A 分別加入這三個 Socket 的等待隊列中。
操作系統(tǒng)把進程 A 分別加入這三個 Socket 的等待隊列中
當任何一個 Socket 收到數據后,中斷程序將喚起進程。下圖展示了 Sock2 接收到了數據的處理流程:
Sock2 接收到了數據,中斷程序喚起進程 A
注:Recv 和 Select 的中斷回調可以設置成不同的內容。
所謂喚起進程,就是將進程從所有的等待隊列中移除,加入到工作隊列里面,如下圖所示:
將進程 A 從所有等待隊列中移除,再加入到工作隊列里面
經由這些步驟,當進程 A 被喚醒后,它知道至少有一個 Socket 接收了數據。程序只需遍歷一遍 Socket 列表,就可以得到就緒的 Socket。
這種簡單方式行之有效,在幾乎所有操作系統(tǒng)都有對應的實現。但是簡單的方法往往有缺點,主要是:
- 每次調用 Select 都需要將進程加入到所有監(jiān)視 Socket 的等待隊列,每次喚醒都需要從每個隊列中移除。這里涉及了兩次遍歷,而且每次都要將整個 FDS 列表傳遞給內核,有一定的開銷。
正是因為遍歷操作開銷大,出于效率的考量,才會規(guī)定 Select 的最大監(jiān)視數量,默認只能監(jiān)視 1024 個 Socket。
- 進程被喚醒后,程序并不知道哪些 Socket 收到數據,還需要遍歷一次。
那么,有沒有減少遍歷的方法?有沒有保存就緒 Socket 的方法?這兩個問題便是 Epoll 技術要解決的。
補充說明:本節(jié)只解釋了 Select 的一種情形。當程序調用 Select 時,內核會先遍歷一遍 Socket,如果有一個以上的 Socket 接收緩沖區(qū)有數據,那么 Select 直接返回,不會阻塞。
這也是為什么 Select 的返回值有可能大于 1 的原因之一。如果沒有 Socket 有數據,進程才會阻塞。
Epoll 的設計思路
Epoll 是在 Select 出現 N 多年后才被發(fā)明的,是 Select 和 Poll(Poll 和 Select 基本一樣,有少量改進)的增強版本。Epoll 通過以下一些措施來改進效率:
措施一:功能分離
Select 低效的原因之一是將“維護等待隊列”和“阻塞進程”兩個步驟合二為一。
相比 Select,Epoll 拆分了功能
如上圖所示,每次調用 Select 都需要這兩步操作,然而大多數應用場景中,需要監(jiān)視的 Socket 相對固定,并不需要每次都修改。
Epoll 將這兩個操作分開,先用 epoll_ctl 維護等待隊列,再調用 epoll_wait 阻塞進程。顯而易見地,效率就能得到提升。
為方便理解后續(xù)的內容,我們先了解一下 Epoll 的用法。如下的代碼中,先用 epoll_create 創(chuàng)建一個 Epoll 對象 Epfd,再通過 epoll_ctl 將需要監(jiān)視的 Socket 添加到 Epfd 中,最后調用 epoll_wait 等待數據:
- int s = socket(AF_INET, SOCK_STREAM, 0);
- bind(s, ...)
- listen(s, ...)
- int epfd = epoll_create(...);
- epoll_ctl(epfd, ...); //將所有需要監(jiān)聽的socket添加到epfd中
- while(1){
- int n = epoll_wait(...)
- for(接收到數據的socket){
- //處理
- }
- }
功能分離,使得 Epoll 有了優(yōu)化的可能。
措施二:就緒列表
Select 低效的另一個原因在于程序不知道哪些 Socket 收到數據,只能一個個遍歷。如果內核維護一個“就緒列表”,引用收到數據的 Socket,就能避免遍歷。
就緒列表示意圖
如上圖所示,計算機共有三個 Socket,收到數據的 Sock2 和 Sock3 被就緒列表 Rdlist 所引用。
當進程被喚醒后,只要獲取 Rdlist 的內容,就能夠知道哪些 Socket 收到數據。
Epoll 的原理與工作流程
本節(jié)會以示例和圖表來講解 Epoll 的原理和工作流程。
創(chuàng)建 Epoll 對象
如下圖所示,當某個進程調用 epoll_create 方法時,內核會創(chuàng)建一個 eventpoll 對象(也就是程序中 Epfd 所代表的對象)。
內核創(chuàng)建 eventpoll 對象
eventpoll 對象也是文件系統(tǒng)中的一員,和 Socket 一樣,它也會有等待隊列。
創(chuàng)建一個代表該 Epoll 的 eventpoll 對象是必須的,因為內核要維護“就緒列表”等數據,“就緒列表”可以作為 eventpoll 的成員。
維護監(jiān)視列表
創(chuàng)建 Epoll 對象后,可以用 epoll_ctl 添加或刪除所要監(jiān)聽的 Socket。以添加 Socket 為例。
添加所要監(jiān)聽的 Socket
如上圖,如果通過 epoll_ctl 添加 Sock1、Sock2 和 Sock3 的監(jiān)視,內核會將 eventpoll 添加到這三個 Socket 的等待隊列中。
當 Socket 收到數據后,中斷程序會操作 eventpoll 對象,而不是直接操作進程。
接收數據
當 Socket 收到數據后,中斷程序會給 eventpoll 的“就緒列表”添加 Socket 引用。
給就緒列表添加引用
如上圖展示的是 Sock2 和 Sock3 收到數據后,中斷程序讓 Rdlist 引用這兩個 Socket。
eventpoll 對象相當于 Socket 和進程之間的中介,Socket 的數據接收并不直接影響進程,而是通過改變 eventpoll 的就緒列表來改變進程狀態(tài)。
當程序執(zhí)行到 epoll_wait 時,如果 Rdlist 已經引用了 Socket,那么 epoll_wait 直接返回,如果 Rdlist 為空,阻塞進程。
阻塞和喚醒進程
假設計算機中正在運行進程 A 和進程 B,在某時刻進程 A 運行到了 epoll_wait 語句。
epoll_wait 阻塞進程
如上圖所示,內核會將進程 A 放入 eventpoll 的等待隊列中,阻塞進程。
當 Socket 接收到數據,中斷程序一方面修改 Rdlist,另一方面喚醒 eventpoll 等待隊列中的進程,進程 A 再次進入運行狀態(tài)(如下圖)。
Epoll 喚醒進程
也因為 Rdlist 的存在,進程 A 可以知道哪些 Socket 發(fā)生了變化。
Epoll 的實現細節(jié)
至此,相信讀者對 Epoll 的本質已經有一定的了解。但我們還需要知道 eventpoll 的數據結構是什么樣子?
此外,就緒隊列應該使用什么數據結構?eventpoll 應使用什么數據結構來管理通過 epoll_ctl 添加或刪除的 Socket?
Epoll 原理示意圖,圖片來源:《深入理解 Nginx:模塊開發(fā)與架構解析(第二版)》,陶輝
如上圖所示,eventpoll 包含了 Lock、MTX、WQ(等待隊列)與 Rdlist 等成員,其中 Rdlist 和 RBR 是我們所關心的。
就緒列表的數據結構
就緒列表引用著就緒的 Socket,所以它應能夠快速的插入數據。程序可能隨時調用 epoll_ctl 添加監(jiān)視 Socket,也可能隨時刪除。
當刪除時,若該 Socket 已經存放在就緒列表中,它也應該被移除。所以就緒列表應是一種能夠快速插入和刪除的數據結構。
雙向鏈表就是這樣一種數據結構,Epoll 使用雙向鏈表來實現就緒隊列(對應上圖的 Rdlist)。
索引結構
既然 Epoll 將“維護監(jiān)視隊列”和“進程阻塞”分離,也意味著需要有個數據結構來保存監(jiān)視的 Socket,至少要方便地添加和移除,還要便于搜索,以避免重復添加。
紅黑樹是一種自平衡二叉查找樹,搜索、插入和刪除時間復雜度都是 O(log(N)),效率較好,Epoll 使用了紅黑樹作為索引結構(對應上圖的 RBR)。
注:因為操作系統(tǒng)要兼顧多種功能,以及有更多需要保存的數據,Rdlist 并非直接引用 Socket,而是通過 Epitem 間接引用,紅黑樹的節(jié)點也是 Epitem 對象。
同樣,文件系統(tǒng)也并非直接引用著 Socket。為方便理解,本文中省略了一些間接結構。
總結
Epoll 在 Select 和 Poll 的基礎上引入了 eventpoll 作為中間層,使用了先進的數據結構,是一種高效的多路復用技術。
這里也以表格形式簡單對比一下 Select、Poll 與 Epoll,結束此文。希望讀者能有所收獲。
圖片來源《Linux 高性能服務器編程》
作者:羅培羽
簡介:正在創(chuàng)作好玩游戲的程序員,作為游戲行業(yè)從業(yè)人員,曾參與《卡布西游》、《卡布仙蹤》、《卡布魔鏡》、《坦克射擊》與《海陸大戰(zhàn)》等多個項目研發(fā)工作;作為獨立游戲開發(fā)者,主導《仙劍 5 前傳之心愿》與《蝕夢》等項目研發(fā),擁有豐富的實戰(zhàn)經驗。自 2009 年發(fā)布第一部視頻教程《教你用 VB 制作 RPG 游戲》以來,先后出版專業(yè)書籍《手把手教你用 C# 制作 RPG 游戲》與《Unity3D 網絡游戲實戰(zhàn)》。目前關注手機游戲與 AI 技術等領域,并以第三方視角記錄普通開發(fā)者的心路歷程。