本文做者:oschina_2020nginx
從事服務端開發,少不了要接觸網絡編程。epoll 做爲 Linux 下高性能網絡服務器的必備技術相當重要,nginx、Redis、Skynet 和大部分遊戲服務器都使用到這一多路複用技術。程序員
epoll 很重要,可是 epoll 與 select 的區別是什麼呢?epoll 高效的緣由是什麼?編程
網上雖然也有很多講解 epoll 的文章,但要麼是過於淺顯,或者陷入源碼解析,不多能有通俗易懂的。筆者因而決定編寫此文,讓缺少專業背景知識的讀者也可以明白 epoll 的原理。數組
文章核心思想是:要讓讀者清晰明白 epoll 爲何性能好。服務器
本文會從網卡接收數據的流程講起,串聯起 CPU 中斷、操做系統進程調度等知識;再一步步分析阻塞接收數據、select 到 epoll 的進化過程;最後探究 epoll 的實現細節。網絡
下邊是一個典型的計算機結構圖,計算機由 CPU、存儲器(內存)與網絡接口等部件組成,瞭解 epoll 本質的第一步,要從硬件的角度看計算機怎樣接收網絡數據。數據結構
計算機結構圖(圖片來源:Linux內核徹底註釋之微型計算機組成結構)架構
下圖展現了網卡接收數據的過程。socket
這個過程涉及到 DMA 傳輸、IO 通路選擇等硬件有關的知識,但咱們只需知道:網卡會把接收到的數據寫入內存。函數
網卡接收數據的過程
經過硬件傳輸,網卡接收的數據存放到內存中,操做系統就能夠去讀取它們。
瞭解 epoll 本質的第二步,要從 CPU 的角度來看數據接收。理解這個問題,要先了解一個概念——中斷。
計算機執行程序時,會有優先級的需求。好比,當計算機收到斷電信號時,它應當即去保存數據,保存數據的程序具備較高的優先級(電容能夠保存少量電量,供 CPU 運行很短的一小段時間)。
通常而言,由硬件產生的信號須要 CPU 立馬作出迴應,否則數據可能就丟失了,因此它的優先級很高。CPU 理應中斷掉正在執行的程序,去作出響應;當 CPU 完成對硬件的響應後,再從新執行用戶程序。中斷的過程以下圖,它和函數調用差很少,只不過函數調用是事先定好位置,而中斷的位置由「信號」決定。
中斷程序調用
以鍵盤爲例,當用戶按下鍵盤某個按鍵時,鍵盤會給 CPU 的中斷引腳發出一個高電平,CPU 可以捕獲這個信號,而後執行鍵盤中斷程序。下圖展現了各類硬件經過中斷與 CPU 交互的過程。
CPU 中斷(圖片來源:net.pku.edu.cn)
如今能夠回答「如何知道接收了數據?」這個問題了:當網卡把數據寫入到內存後,網卡向 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 時,它會一直等待,直到接收到數據才往下執行。
那麼阻塞的原理是什麼?
工做隊列
操做系統爲了支持多任務,實現了進程調度的功能,會把進程分爲「運行」和「等待」等幾種狀態。運行狀態是進程得到 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 的等待隊列
注:操做系統添加等待隊列只是添加了對這個「等待中」進程的引用,以便在接收到數據時獲取進程對象、將其喚醒,而非直接將進程管理歸入本身之下。上圖爲了方便說明,直接將進程掛到等待隊列之下。
喚醒進程
當 socket 接收到數據後,操做系統將該 socket 等待隊列上的進程從新放回到工做隊列,該進程變成運行狀態,繼續執行代碼。同時因爲 socket 的接收緩衝區已經有了數據,recv 能夠返回接收到的數據。
這一步,貫穿網卡、中斷與進程調度的知識,敘述阻塞 recv 下,內核接收數據的全過程。
以下圖所示,進程在 recv 阻塞期間,計算機收到了對端傳送的數據(步驟①),數據經由網卡傳送到內存(步驟②),而後網卡經過中斷信號通知 CPU 有數據到達,CPU 執行中斷程序(步驟③)。
此處的中斷程序主要有兩項功能,先將網絡數據寫入到對應 socket 的接收緩衝區裏面(步驟④),再喚醒進程 A(步驟⑤),從新將進程 A 放入工做隊列中。
內核接收數據全過程
喚醒進程的過程以下圖所示:
喚醒進程
以上是內核接收數據全過程,這裏咱們可能會思考兩個問題:
第一個問題:由於一個 socket 對應着一個端口號,而網絡數據包中包含了 ip 和端口的信息,內核能夠經過端口號找到對應的 socket。固然,爲了提升處理速度,操做系統會維護端口號到 socket 的索引結構,以快速讀取。
第二個問題是多路複用的重中之重,也正是本文後半部分的重點。
服務端須要管理多個客戶端鏈接,而 recv 只能監視單個 socket,這種矛盾下,人們開始尋找監視多個 socket 的方法。epoll 的要義就是高效地監視多個 socket。
從歷史發展角度看,必然先出現一種不過高效的方法,人們再加以改進,正如 select 之於 epoll。
先理解不過高效的 select,纔可以更好地理解 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 的實現思路很直接,假如程序同時監視以下圖的 sock一、sock2 和 sock3 三個 socket,那麼在調用 select 以後,操做系統把進程 A 分別加入這三個 socket 的等待隊列中。
操做系統把進程 A 分別加入這三個 socket 的等待隊列中
當任何一個 socket 收到數據後,中斷程序將喚起進程。下圖展現了 sock2 接收到了數據的處理流程:
注: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 是在 select 出現 N 多年後才被髮明的,是 select 和 poll(poll 和 select 基本同樣,有少許改進)的加強版本。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_create 方法時,內核會建立一個 eventpoll 對象(也就是程序中 epfd 所表明的對象)。eventpoll 對象也是文件系統中的一員,和 socket 同樣,它也會有等待隊列。
內核建立 eventpoll 對象
建立一個表明該 epoll 的 eventpoll 對象是必須的,由於內核要維護「就緒列表」等數據,「就緒列表」能夠做爲 eventpoll 的成員。
維護監視列表
建立 epoll 對象後,能夠用 epoll_ctl 添加或刪除所要監聽的 socket。以添加 socket 爲例,以下圖,若是經過 epoll_ctl 添加 sock一、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 的本質已經有必定的瞭解。但咱們還須要知道 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)。
注:由於操做系統要兼顧多種功能,以及由更多須要保存的數據,rdlist 並不是直接引用 socket,而是經過 epitem 間接引用,紅黑樹的節點也是 epitem 對象。一樣,文件系統也並不是直接引用着 socket。爲方便理解,本文中省略了一些間接結構。
epoll 在 select 和 poll 的基礎上引入了 eventpoll 做爲中間層,使用了先進的數據結構,是一種高效的多路複用技術。這裏也以表格形式簡單對比一下 select、poll 與 epoll,結束此文。但願讀者能有所收穫。
羅培羽,正在創做好玩遊戲的程序員。
做爲遊戲行業從業人員,曾參與《卡布西遊》、《卡布仙蹤》、《卡布魔鏡》、《坦克射擊》與《海陸大戰》等多個項目研發工做;做爲獨立遊戲開發者,主導《仙劍5前傳之心願》與《蝕夢》等項目研發,擁有豐富的實戰經驗。
自 2009 年發佈第一部視頻教程《教你用VB製做RPG遊戲》以來,前後出版專業書籍《手把手教你用C#製做RPG遊戲》與《Unity3D網絡遊戲實戰》。
目前關注手機遊戲與 AI 技術等領域,並以第三方視角記錄普通開發者的心路歷程。