我相信這是從事技術之路上的必經之路,特别是搞網絡程式設計的。
那麼epoll 作為 Linux IO 下高性能網絡伺服器的必備技術至關重要,Nginx、Redis、Netty、Tomcat、Appache伺服器都使用到這一多路複用技術。
咱們先來探究一下這個關鍵名稱Linux 的網絡 IO 模型,那麼進過百度得知網絡IO模型分為五種模型,分别是阻塞式IO,非阻塞IO, IO多路複用,信号驅動IO,以及異步IO模型。
- 阻塞式IO image.png
- 非阻塞式IO image.png
- IO多路複用 image.png
- 信号驅動式IO image.png
- 異步IO模型 image.png
二、為什麼IO多路複用應用最廣泛
理論上來說異步IO模型性能更好,但是目前階段在linux平台下,作業系統底層并沒有真正實作完全異步IO,當然有可能在未來版本中會支援
而對于信号驅動IO,因為信号沒有附加資訊,如果一個信号源有多種産生信号的原因,信号接受者就無法區分,但是TCP協定裡的事件類型有多種(read, write, accept),另外一個原因是如果基于java語言開發的話,似乎還不支援信号驅動處理。
然後是非阻塞IO, 這個雖然避免了IO阻塞,但是需要不斷的主動輪詢,浪費CPU資源,效率不高
阻塞式IO的場景一般對每個連接配接配置設定一個線程,但是當連接配接數太大的情況下(比如c10k,c100k),系統不可能建立這麼多的線程。(或許協程在某種程度能改善這個問題)
是以最終一比較,至少在linux平台下,目前主流的方案大多基于IO多路複用技術。
linux平台提供的主要的IO多路複用技術有select, poll, epoll,主要目的是為了能讓一個或少量的select線程(或reactor線程),來管理多個連接配接,本質上是基于一個真實生産環境中的特性,比如雖然存在幾萬個連接配接,但是在某一時間範圍内,有資料可讀或者可寫的socket并不會很多,既active的連接配接不會很多。當然如果在一個極端環境下面,比如是一個高速的區域網路,并且每個client連接配接都會一直不斷的發送資料,既每個連接配接都可以看成active的連接配接,那麼基于IO多路複用技術未必是最佳方案。
三、select
linux系統提供select函數來實作多路複用輸入/輸出模型,select系統調用是用來讓我們的應用程式監視多個檔案句柄(包括socket檔案句柄)的狀态變化,程式會在select這裡等待,直到被監視的檔案句柄有一個或多個發生了狀态改變如可讀或可寫
API原型為:
int select(int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
參數說明:
n 一般為最大檔案描述符 + 1, 既STDIN_FILENO + 1 該值會有limit限制,一般為1024,是以生産環境基本用epoll,kqueue等代替
readfds 監視是否有可讀的檔案描述符集合
writefds 監視是否有可寫的檔案描述符集合
exceptfds 監視是否有異常情況發生的檔案描述符集合
timeout 逾時時間,如果在某一段時間内依然沒有相應事件觸發,則會阻塞直到timeout時間過期 timeout.tv_sec機關為秒, timeout.tv_usec機關為微秒
四、poll
poll算select的加強版,但基本原理跟select類似,暫不贅述,後續在補充
五、epoll
由于select和poll系統調用存在以下幾個問題,Linux核心2.6環境新增Event Poll的方式。
- select/poll每次檢查的時候是通過周遊所有的檔案描述符(fd), 尤其是對于網絡scoket而言,大部分存在這麼一個特性,既某一個時間點裡,隻有很少一部分的socket是“活躍”狀态,如果每次都是周遊所有的網絡檔案描述符的話,當檔案描述符變大之後,性能就會随着連接配接數變大之後線型下降
- select存在最大檔案描述符的限制,具體取決于常量FD_SETSIZE,預設大小為1024, 不能滿足大量的用戶端連接配接.
反觀epoll,則改進了以上不足的地方
- 在核心實作中epoll是根據每個fd上面的callback函數實作的。可以做到隻有“活躍”的socket才會主動的去調用 callback函數,其他idle狀态socket則不會
- epoll沒有對fd描述符有限制,理論上取決于系統記憶體大小, 可以通過指令 cat /proc/sys/fs/file-max檢視,大概1G記憶體可以建立10w個連接配接
- epoll的具體實作使用mmap加速核心與使用者空間的消息傳遞,進一步提高性能
epoll實際包含3個系統調用組成,分别為epoll_create(), epoll_ctl(), epoll_wait()
- epoll_create用于建立epoll的執行個體,其中參數size隻要大于0即可,核心會動态擷取大小,函數傳回epoll本身的描述符
int epoll_create(int size);
-
epoll_ctl用于添加,修改,删除要監聽的event事件
參數op為EPOLL_CTL_ADD代表添加,EPOLL_CTL_MOD代表修改,EPOLL_CTL_DEL代表删除
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- epoll_wait用于監視等待是否有IO事件發生,直到timeout過期
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll中存在兩種工作模式 LT 和 ET
二者的差異在于 level-trigger (LT) 模式下隻要某個 socket 處于 readable/writable 狀态,無論什麼時候進行 epoll_wait 都會傳回該 socket;而 edge-trigger (ET) 模式下隻有某個 socket 從 unreadable 變為 readable 或從unwritable 變為 writable 時,epoll_wait 才會傳回該 socket。
如下兩個示意圖:
- 從socket讀資料: image.png
- 從socket寫資料: image.png
經過以上圖解後,我們首先對Linux IO多路複用之select、poll、epoll這道菜進行詳解
select,poll,epoll都是IO多路複用的機制。I/O多路複用就是通過一種機制,一個程序可以監視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒),能夠通知程式進行相應的讀寫操作。但select,poll,epoll本質上都是同步I/O,因為他們都需要在讀寫事件就緒後自己負責進行讀寫,也就是說這個讀寫過程是阻塞的,而異步I/O則無需自己負責進行讀寫,異步I/O的實作會負責把資料從核心拷貝到使用者空間。
1、select
select函數監視檔案描述符,調用後select函數會阻塞,直到有描述符就緒,或者逾時,函數傳回,當select函數傳回後,就可以周遊描述符,找到就緒的描述符。
select的一個缺點在于單個程序能夠監視的檔案描述符的數量也存在最大限制,在Linux上一般為1024,可以通過修改宏定義甚至重新編譯核心的方式提升這一限制。但是這樣也會造成效率的降低。
2、poll
沒有最大限制(但是數量過大後性能也是會下降)。和select函數一樣,poll傳回後,需要輪詢來擷取就緒的描述符。
select和poll都需要在傳回後,通過周遊檔案描述符來擷取已經就緒的socket。事實上,同時連接配接的大量用戶端在同一時刻可能隻有很少的就緒狀态,是以随着監視的描述符數量的增長,其效率也會線性下降。
3、epoll
相對于select和poll來說,epoll更加靈活,沒有描述符限制。epoll使用一個檔案描述符管理多個描述符。
既然epoll 很重要,但是 epoll 與 select 的差別是什麼呢?epoll 高效的原因是什麼?
網上雖然也有不少講解 epoll 的文章,但要麼是過于淺顯,或者陷入源碼解析,很少能有通俗易懂的。筆者于是決定編寫此文,讓缺乏專業背景知識的讀者也能夠明白 epoll 的原理。
文章核心思想是:要讓讀者清晰明白 epoll 為什麼性能好。
本文會從網卡接收資料的流程講起,串聯起 CPU 中斷、作業系統程序排程等知識;再一步步分析阻塞接收資料、select 到 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 時,它會一直等待,直到接收到資料才往下執行。
那麼阻塞的原理是什麼?
工作隊列
作業系統為了支援多任務,實作了程序排程的功能,會把程序分為“運作”和“等待”等幾種狀态。運作狀态是程序獲得 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?
- 其二,如何同時監視多個 socket 的資料?
第一個問題:因為一個 socket 對應着一個端口号,而網絡資料包中包含了 ip 和端口的資訊,核心可以通過端口号找到對應的 socket。當然,為了提高處理速度,作業系統會維護端口号到 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 的實作思路很直接,假如程式同時監視如下圖的 sock1、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 的設計思路
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 對象
如下圖所示,當某個程序調用 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)。
注:因為作業系統要兼顧多種功能,以及由更多需要儲存的資料,rdlist 并非直接引用 socket,而是通過 epitem 間接引用,紅黑樹的節點也是 epitem 對象。同樣,檔案系統也并非直接引用着 socket。為友善了解,本文中省略了一些間接結構。
九、小結
epoll 在 select 和 poll 的基礎上引入了 eventpoll 作為中間層,使用了先進的資料結構,是一種高效的多路複用技術。這裡也以表格形式簡單對比一下 select、poll 與 epoll,結束此文。希望讀者能有所收獲。
你真的懂了嗎,試試就知道了 http://www.ttshixi.com