I/O多路復用(multiplexing)的本質是通過一種機制(系統內核緩沖I/O數據),讓單個進程可以監視多個文件描述符,一旦某個描述符就緒(一般是讀就緒或寫就緒),能夠通知程序進行相應的讀寫操作
select、poll 和 epoll 都是 Linux API 提供的 IO 復用方式。
相信大家都了解了Unix五種IO模型,不了解的可以 => 查看這里
[1] blocking IO - 阻塞IO
[2] nonblocking IO - 非阻塞IO
[3] IO multiplexing - IO多路復用
[4] signal driven IO - 信號驅動IO
[5] asynchronous IO - 異步IO
其中前面4種IO都可以歸類為synchronous IO - 同步IO,而select、poll、epoll本質上也都是同步I/O,因為他們都需要在讀寫事件就緒后自己負責進行讀寫,也就是說這個讀寫過程是阻塞的。
與多進程和多線程技術相比,I/O多路復用技術的最大優勢是系統開銷小,系統不必創建進程/線程,也不必維護這些進程/線程,從而大大減小了系統的開銷。
在介紹select、poll、epoll之前,首先介紹一下Linux操作系統中基礎的概念:
-
用戶空間 / 內核空間
現在操作系統都是采用虛擬存儲器,那么對32位操作系統而言,它的尋址空間(虛擬存儲空間)為4G(2的32次方)。
操作系統的核心是內核,獨立于普通的應用程序,可以訪問受保護的內存空間,也有訪問底層硬件設備的所有權限。為了保證用戶進程不能直接操作內核(kernel),保證內核的安全,操作系統將虛擬空間劃分為兩部分,一部分為內核空間,一部分為用戶空間。 -
進程切換
為了控制進程的執行,內核必須有能力掛起正在CPU上運行的進程,并恢復以前掛起的某個進程的執行。這種行為被稱為進程切換。因此可以說,任何進程都是在操作系統內核的支持下運行的,是與內核緊密相關的,并且進程切換是非常耗費資源的。 -
進程阻塞
正在執行的進程,由于期待的某些事件未發生,如請求系統資源失敗、等待某種操作的完成、新數據尚未到達或無新工作做等,則由系統自動執行阻塞原語(Block),使自己由運行狀態變為阻塞狀態。可見,進程的阻塞是進程自身的一種主動行為,也因此只有處于運行態的進程(獲得了CPU資源),才可能將其轉為阻塞狀態。當進程進入阻塞狀態,是不占用CPU資源的。 -
文件描述符
文件描述符(File descriptor)是計算機科學中的一個術語,是一個用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一個非負整數。實際上,它是一個索引值,指向內核為每一個進程所維護的該進程打開文件的記錄表。當程序打開一個現有文件或者創建一個新文件時,內核向進程返回一個文件描述符。在程序設計中,一些涉及底層的程序編寫往往會圍繞著文件描述符展開。但是文件描述符這一概念往往只適用于UNIX、Linux這樣的操作系統。 -
緩存I/O
緩存I/O又稱為標準I/O,大多數文件系統的默認I/O操作都是緩存I/O。在Linux的緩存I/O機制中,操作系統會將I/O的數據緩存在文件系統的頁緩存中,即數據會先被拷貝到操作系統內核的緩沖區中,然后才會從操作系統內核的緩沖區拷貝到應用程序的地址空間。
Select
我們先分析一下select函數
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout);
【參數說明】
int maxfdp1 指定待測試的文件描述字個數,它的值是待測試的最大描述字加1。
fd_set *readset , fd_set *writeset , fd_set *exceptset
fd_set
可以理解為一個集合,這個集合中存放的是文件描述符(file descriptor),即文件句柄。中間的三個參數指定我們要讓內核測試讀、寫和異常條件的文件描述符集合。如果對某一個的條件不感興趣,就可以把它設為空指針。
const struct timeval *timeout timeout
告知內核等待所指定文件描述符集合中的任何一個就緒可花多少時間。其timeval結構用于指定這段時間的秒數和微秒數。
【返回值】
int 若有就緒描述符返回其數目,若超時則為0,若出錯則為-1
select運行機制
select()的機制中提供一種fd_set
的數據結構,實際上是一個long類型的數組,每一個數組元素都能與一打開的文件句柄(不管是Socket句柄,還是其他文件或命名管道或設備句柄)建立聯系,建立聯系的工作由程序員完成,當調用select()時,由內核根據IO狀態修改fd_set的內容,由此來通知執行了select()的進程哪一Socket或文件可讀。
從流程上來看,使用select函數進行IO請求和同步阻塞模型沒有太大的區別,甚至還多了添加監視socket,以及調用select函數的額外操作,效率更差。但是,使用select以后最大的優勢是用戶可以在一個線程內同時處理多個socket的IO請求。用戶可以注冊多個socket,然后不斷地調用select讀取被激活的socket,即可達到在同一個線程內同時處理多個IO請求的目的。而在同步阻塞模型中,必須通過多線程的方式才能達到這個目的。
select機制的問題
- 每次調用select,都需要把
fd_set
集合從用戶態拷貝到內核態,如果fd_set
集合很大時,那這個開銷也很大 - 同時每次調用select都需要在內核遍歷傳遞進來的所有
fd_set
,如果fd_set
集合很大時,那這個開銷也很大 - 為了減少數據拷貝帶來的性能損壞,內核對被監控的
fd_set
集合大小做了限制,并且這個是通過宏控制的,大小不可改變(限制為1024)
Poll
poll的機制與select類似,與select在本質上沒有多大差別,管理多個描述符也是進行輪詢,根據描述符的狀態進行處理,但是poll沒有最大文件描述符數量的限制。也就是說,poll只解決了上面的問題3,并沒有解決問題1,2的性能開銷問題。
下面是pll的函數原型:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
typedef struct pollfd {
int fd; // 需要被檢測或選擇的文件描述符
short events; // 對文件描述符fd上感興趣的事件
short revents; // 文件描述符fd上當前實際發生的事件
} pollfd_t;
poll改變了文件描述符集合的描述方式,使用了pollfd
結構而不是select的fd_set
結構,使得poll支持的文件描述符集合限制遠大于select的1024
【參數說明】
struct pollfd *fds fds
是一個struct pollfd
類型的數組,用于存放需要檢測其狀態的socket描述符,并且調用poll函數之后fds
數組不會被清空;一個pollfd
結構體表示一個被監視的文件描述符,通過傳遞fds
指示 poll() 監視多個文件描述符。其中,結構體的events
域是監視該文件描述符的事件掩碼,由用戶來設置這個域,結構體的revents
域是文件描述符的操作結果事件掩碼,內核在調用返回時設置這個域
nfds_t nfds 記錄數組fds
中描述符的總數量
【返回值】
int 函數返回fds集合中就緒的讀、寫,或出錯的描述符數量,返回0表示超時,返回-1表示出錯;
Epoll
epoll在Linux2.6內核正式提出,是基于事件驅動的I/O方式,相對于select來說,epoll沒有描述符個數限制,使用一個文件描述符管理多個描述符,將用戶關心的文件描述符的事件存放到內核的一個事件表中,這樣在用戶空間和內核空間的copy只需一次。
Linux中提供的epoll相關函數如下:
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
1. epoll_create 函數創建一個epoll句柄,參數size
表明內核要監聽的描述符數量。調用成功時返回一個epoll句柄描述符,失敗時返回-1。
2. epoll_ctl 函數注冊要監聽的事件類型。四個參數解釋如下:
-
epfd
表示epoll句柄 -
op
表示fd操作類型,有如下3種- EPOLL_CTL_ADD 注冊新的fd到epfd中
- EPOLL_CTL_MOD 修改已注冊的fd的監聽事件
- EPOLL_CTL_DEL 從epfd中刪除一個fd
-
fd
是要監聽的描述符 -
event
表示要監聽的事件
epoll_event 結構體定義如下:
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
typedef union epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;
3. epoll_wait 函數等待事件的就緒,成功時返回就緒的事件數目,調用失敗時返回 -1,等待超時返回 0。
-
epfd
是epoll句柄 -
events
表示從內核得到的就緒事件集合 -
maxevents
告訴內核events的大小 -
timeout
表示等待的超時事件
epoll是Linux內核為處理大批量文件描述符而作了改進的poll,是Linux下多路復用IO接口select/poll的增強版本,它能顯著提高程序在大量并發連接中只有少量活躍的情況下的系統CPU利用率。原因就是獲取事件的時候,它無須遍歷整個被偵聽的描述符集,只要遍歷那些被內核IO事件異步喚醒而加入Ready隊列的描述符集合就行了。
epoll除了提供select/poll那種IO事件的水平觸發(Level Triggered)外,還提供了邊緣觸發(Edge Triggered),這就使得用戶空間程序有可能緩存IO狀態,減少epoll_wait/epoll_pwait的調用,提高應用程序效率。
- 水平觸發(LT):默認工作模式,即當epoll_wait檢測到某描述符事件就緒并通知應用程序時,應用程序可以不立即處理該事件;下次調用epoll_wait時,會再次通知此事件
- 邊緣觸發(ET): 當epoll_wait檢測到某描述符事件就緒并通知應用程序時,應用程序必須立即處理該事件。如果不處理,下次調用epoll_wait時,不會再次通知此事件。(直到你做了某些操作導致該描述符變成未就緒狀態了,也就是說邊緣觸發只在狀態由未就緒變為就緒時只通知一次)。
LT和ET原本應該是用于脈沖信號的,可能用它來解釋更加形象。Level和Edge指的就是觸發點,Level為只要處于水平,那么就一直觸發,而Edge則為上升沿和下降沿的時候觸發。比如:0->1 就是Edge,1->1 就是Level。
ET模式很大程度上減少了epoll事件的觸發次數,因此效率比LT模式下高。
總結
一張圖總結一下select,poll,epoll的區別:
select | poll | epoll | |
---|---|---|---|
操作方式 | 遍歷 | 遍歷 | 回調 |
底層實現 | 數組 | 鏈表 | 紅黑樹 |
IO效率 | 每次調用都進行線性遍歷,時間復雜度為O(n) | 每次調用都進行線性遍歷,時間復雜度為O(n) | 事件通知方式,每當fd就緒,系統注冊的回調函數就會被調用,將就緒fd放到readyList里面,時間復雜度O(1) |
最大連接數 | 1024(x86)或2048(x64) | 無上限 | 無上限 |
fd拷貝 | 每次調用select,都需要把fd集合從用戶態拷貝到內核態 | 每次調用poll,都需要把fd集合從用戶態拷貝到內核態 | 調用epoll_ctl時拷貝進內核并保存,之后每次epoll_wait不拷貝 |
epoll是Linux目前大規模網絡并發程序開發的首選模型。在絕大多數情況下性能遠超select和poll。目前流行的高性能web服務器Nginx正式依賴于epoll提供的高效網絡套接字輪詢服務。但是,在并發連接不高的情況下,多線程+阻塞I/O方式可能性能更好。
既然select,poll,epoll都是I/O多路復用的具體的實現,之所以現在同時存在,其實他們也是不同歷史時期的產物
- select出現是1984年在BSD里面實現的
- 14年之后也就是1997年才實現了poll,其實拖那么久也不是效率問題, 而是那個時代的硬件實在太弱,一臺服務器處理1千多個鏈接簡直就是神一樣的存在了,select很長段時間已經滿足需求
- 2002, 大神 Davide Libenzi 實現了epoll