推薦閱讀:
本節,我們介紹IO複用,通過簡單的例子演示IO複用的使用,以及實現原理,這個技術是目前構建目前的高性能服務器必備技術,在後面我們介紹到各種網絡編程模型的時候,會用到IO複用。
看完本文,您將瞭解到:
IO複用的執行流程;
select函數的使用和優缺點,以及實現原理;
poll函數的使用和優缺點,以及實現原理;
epoll函數的使用和優缺點,以及實現原理;
epoll的條件觸發和邊緣觸發,以及實現原理。
1、I/O複用模型介紹
I/O複用(I/O multiplexing),指的是通過一個支持同時感知多個描述符的函數系統調用,阻塞在這個系統調用上,等待某一個或者幾個描述符準備就緒,就返回可讀條件。常見的如select,poll,epoll系統調用可以實現此類功能功能。這種模型不用阻塞在真正的I/O系統調用上。
工作原理如下圖所示:
如上圖,這種模型與非阻塞式I/O相比,把輪訓判斷數據是否準備好的處理方式替換爲了通過select()系統調用的方式來實現。
常用的實現IO複用的相關函數有select,poll和epoll,接下倆我們介紹下這三個函數。
2、select函數
select是實現I/O多路複用的經典系統調用函數。select()可以同時等待多個套接字的變爲可讀,只要有任意一個套接字可讀,那麼就會立刻返回,處理已經準備好的套接字了。
2.1、select函數定義
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
int pselect(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, const struct timespec *timeout,
const sigset_t *sigmask);
select函數參數:
int nfds:指定待測試的描述符的個數,它的值是待測試的最大描述符加1;
fd_set readfds:指定要讓內核測試讀的描述符;
fd_set writefds:指定要讓內核測試寫的描述符;
fd_set exceptfds:指定要讓內核測試異常的描述符;
timeval timeout:告知內核等待所制定描述符中的任何一個準備就緒的超時時間。
其中有一個重要的結構體:fd_set,用於存儲描述符集,底層使用bitmap記錄描述符的。
與之相關的4個宏:
FD_CLR:清除fdset中的所有bit位;
FD_SET:開啓fdset中fd描述符對應的bit位;
FD_ZERO:關閉fdset中fd描述符對應的bit位;
FD_ISSET:判斷fd描述符對應的bit位是否開啓;
2.2、select函數例子
下面通過一個例子演示select是如何使用的,並且分析其執行原理。
這個例子開啓了一個監聽套接字,然後獲取5個客戶端連接,通過select函數判斷是否有數據到達服務器端,如果有則讀取,我把詳細的註釋都加上了,下面重點介紹標註了序號的代碼:
1、SOCKET
調用socket
創建一個監聽套接字,並拿到監聽套接字描述符;
2、BIND
調用bind
把本地協議地址賦予套接字;
3、LISTEN
調用listen
轉換爲被動套接字,開始接受指向該套接字的連接請求;
4、得到MAXFD
在獲取5個已連接套接字的過程中,判斷獲取到最大的套接字文件描述符;
5、初始化FD_SET
在循環裏面,每次重新調用select
之前,都需要重新設置rset
,在第7步我們解釋爲什麼要這樣做;
fd_set
是一個bitmap,由內核固定設置的大小,最大長度爲1024,這也限制了我們最多隻能同時監聽1024個描述符。假如我們這裏得到的五個描述符是:1 2 5 6 8
,那麼這個位圖會是這樣的:
6、SELECT
函數傳入的待測試描述符+1
這裏爲什麼要加1呢?
根據第五步,可以知道,fd_set
中的bitmap是從0開始的,所以rset實際有效的bitmap長度是待測試描述符+1。
7、往SELECT
中傳入要讓內核測試讀的描述符,然後阻塞等待內核返回
這一步的流程是這樣的:
應用進程調用了select之後,會把fd_set從用戶空間拷貝到內核空間,隨後應用進程進入阻塞;
內核根據fd_set得到需要處理的描述符,根據描述符是否準備好數據,給fd_set進行置位;
進程被喚醒,拿到內核處理過後的fd_set,就可以通過FD_ISSET判斷到套接字數據是否已經準備好了。
8、判斷描述符是否可讀
這裏會把已準備好的數據的套接字描述符對應的fd_set中的標識進行標記,通過FD_ISSET
即可判斷到標記結果。
2.3、select函數優缺點
2.3.1、優點
非阻塞IO直接輪訓查詢數據是否準備好,每次查詢都要切換內核態,輪訓消耗CPU。而select函數則直接把查詢多個描述符的動作交給了內核,這樣避免了CPU消耗和減少了內核態的切換。
2.3.2、缺點
根據上面的過程描述,我們可以知道select有如下缺點:
fd_set中的bitmap是固定1024位的,也就是說最多隻能監聽1024個套接字。當然也可以改內核源碼,不過代價比較大;
fd_set每次傳入內核之後,都會被改寫,導致不可重用,每次調用select都需要重新初始化fd_set;
每次調用select都需要拷貝新的fd_set到內核空間,這裏會做一個用戶態到內核態的切換;
拿到fd_set的結果後,應用進程需要遍歷整個fd_set,才知道哪些文件描述符有數據可以處理。
3、poll
基於epoll的缺點,於是出現了第二個系統調用,poll,poll與內核交互的數據有所不同,並且突破了文件描述符數量的限制。
3.1、poll函數定義
下面是poll函數的定義:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// 返回:若有就緒描述符則爲其數目,若超時則爲0,若出錯則爲1
poll函數參數:
- pollfd * fds:指向一個結構數組第一個元素的指針,每個元素,都是一個pollfd結構,用於指定測試某個給定描述符fd的條件,結構體格式如下:
struct pollfd {
int fd; /* 待檢測的文件描述符 */
short events; /* 描述符上待檢測的事件類型 */
short revents; /* 返回描述符對應事件的狀態 */
};
int fd:爲待檢測的文件描述符;
short events:爲描述符上待檢測的事件類型,這裏用了short類型,具體的實現用二進制掩碼位操作來完成,常用的事件類型如下:
short revents:返回描述符對應事件的狀態,在pollfd由系統調用返回之後,會響應具體的事件狀態;
nfds_t nfds:nfds指定fds數組的大小;
int timeout:指定poll函數返回前需要等待多長時間。
接下來我們還是看具體的例子。
3.2、poll函數例子
下面通過一個例子演示poll是如何使用的,並且分析其執行原理。
與select的例子很類似,開啓了一個監聽套接字,然後獲取5個客戶端連接,通過poll函數判斷是否有數據到達服務器端,如果有則讀取,我把詳細的註釋都加上了,下面重點介紹標註了序號的代碼:
1、設置POLLFD描述符
這裏通過accept阻塞獲取已連接描述符,賦值給pollfd結構的fd中。
2、設置POLLFD事件
然後給pollfd的events設置POLLIN,指定需要檢測POLLIN,即數據讀入。
3、POLL
調用poll函數,傳入剛剛初始化好的pollfds,數量爲5,超時時間爲10秒。這裏進入阻塞等待,直到從內核返回。
與select類似,這一步的執行流程是這樣的:
應用進程調用了poll之後,會把poll_fd從用戶空間拷貝到內核空間,隨後應用進程進入阻塞;
內核根據poll_fd的fd得到需要處理的描述符,根據描述符是否準備好數據,給poll_fd的revents進行置位;
進程被喚醒,拿到內核處理過後的poll_fd,就可以通過與操作判斷到對應的事件是否被置位,從而知道套接字數據是否已經準備好了。
4、判斷事件是否準備好
從內核返回之後,我們循環判斷pollfds中每個元素的revents,通過與操作,看看POLLIN是否被置位了,如果置位了就說明數據已經準備好了。
5、重置事件
這裏對revents進行了重置,下次就可以複用這個pollfds,繼續執行poll函數了。
3.3、poll函數優缺點
3.3.1、優點
與select類似,非阻塞IO直接輪訓查詢數據是否準備好,每次查詢都要切換內核態,輪訓消耗CPU,而poll則是把查詢多個描述符的動作交給了內核,避免了CPU消耗和減少了內核態的切換。
與select相比,這裏不是用的bitmap,而是直接用poll_fd數組,沒有1024個描述符的限制;
這裏引入了poll_fd結構體,內核只是修改poll_fd結構體中的revents,這樣每次讀取數據的時候,重置revents,就可以複用poll_fd了,不用像select那樣反覆初始化一個新的rset。
3.3.2、缺點
每次調用poll都需要拷貝新的poll_fd到內核空間,這裏會做一個用戶態到內核態的切換;
拿到poll_fd的結果後,應用進程需要遍歷整個poll_fd,才知道哪些文件描述符有數據可以處理。
4、epoll
與poll不同,epoll本身不是系統調用,而是一種內核數據結構
,它允許進程在多個文件描述符上多路複用I / O。
可以通過三個系統調用來創建,修改和刪除此數據結構。
4.1、epoll的相關函數
4.1.1、EPOLL_CREATE
epoll實例是通過epoll_create系統調用創建的,該系統調用將文件描述符返回到epoll實例,函數定義如下:
#include <sys/epoll.h>
int epoll_create(int size);
size參數向內核指示進程要監視的文件描述符的數量,這有助於內核確定epoll實例的大小。從Linux 2.6.8開始,此參數將被忽略,因爲epoll數據結構會隨着文件描述符的添加或刪除而動態調整大小。
epoll_create系統調用將返回新創建的epoll內核數據結構的文件描述符。然後,調用過程中可以使用此文件描述符來添加,刪除或修改其要監視的epoll實例的I/O的其他文件描述符。
如下圖,用戶進程最終拿到了epoll實例的描述符 EPFD,以支持對epoll實例的訪問:
還有另一個系統調用epoll_create1,其定義如下:
int epoll_create1(int flags);
flags參數可以爲0或EPOLL_CLOEXEC。
設置爲0時,epoll_create1的行爲與epoll_create相同;
設置EPOLL_CLOEXEC標誌後,當前進程派生的任何子進程將在執行前關閉epoll描述符,因此該子進程將無法再訪問epoll實例;
4.1.2、EPOLL_CTL
進程可以通過調用epoll_ctl將想要監視的文件描述符添加到epoll實例。
向epoll實例註冊的所有文件描述符統稱爲epoll的興趣列表[1],會包裝成epitem結構體,放到一顆紅黑樹rbr中:
在上圖中,用戶進程向epoll實例註冊了文件描述符fd1,fd2,fd3,fd4,這是該epoll實例的興趣列表集。
當任何已註冊的文件描述符準備好進行I/O時,它們就被放入事件就緒隊列。事件就緒隊列是興趣列表的一個子集。內核在接收到I/O準備好的事件回調的時候,把rbr中的epitem移到事件就緒隊列。
epoll_ctl系統調用定義如下:
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epfd:epoll_create返回的文件描述符,用於標識內核中的epoll實例;
int op:指要在文件描述符fd上執行的操作。通常,支持三種操作:
EPOLL_CTL_ADD:向epoll實例註冊文件描述符對應的事件;
EPOLL_CTL_DEL:從epoll實例註銷fd。這意味着該進程將不再獲取有關該文件描述符上事件的任何通知。如果已將文件描述符添加到多個epoll實例,則關閉該文件描述符會將其從添加了該文件的所有epoll興趣列表中刪除
EPOLL_CTL_MOD:修改文件描述符對應的事件。
int fd:我們要添加到epoll興趣列表的文件描述符;
struct epoll_event *event:指向名爲epoll_event的結構的指針,該結構存儲我們實際上要監視fd的事件。
typedef union epoll_data {
void *ptr;
int fd; /* 需要監視的文件描述符 */
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* 需要監視的Epoll事件,與poll一樣,基於mask的事件類型 */
epoll_data_t data; /* User data variable */
};
4.1.3、EPOLL_WAIT
可以通過調用epoll_wait系統調用來等到內核通知進程epoll實例的興趣列表上發生的事件,該事件將阻塞直到被監視的任何描述符準備好進行I/O操作爲止。
函數定義如下:
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
int epfd:epoll實例描述符;
struct epoll_event *events:返回給用戶空間需要處理的IO事件數組;
int maxevents:指定epoll_wait可以返回的最大事件值;
int timeout:指定阻塞調用超時時間。-1表示不超時,0表示立即返回。
4.2、epoll例子
注意:epoll機制是在Linux 2.6之後引入的,所以Mac OS不支持。Mac OS下使用kqueue機制代替epoll實現IO複用。
1、定義EPOLL_EVENT
定義一個epoll_event,存儲實際要監視的fd相關信息,以及待接收epll_wait返回的就緒事件列表。
2、調用EPOLL_CREATE
在內核中創建epoll實例,併發揮epfd文件描述符。
3、設置EVENT.DATA.FD
event結構設置實際要監視的描述符。
4、設置EVENT.EVENTS
event和值實際要監視的事件。
5、調用EPOLL_CTL
將要監視的event添加到epoll實例。
6、調用EPOLL_WAIT
獲取內核epoll實例中興趣列表上發生的事件,即事件就緒隊列的內容,epoll_wait的返回值爲就緒隊列的大小。
4.3、epoll原理解析[2]
還是看看剛纔那個圖:
這裏我們重點來看看epoll實例的以下相關結構體:
eventpoll epoll實例
rdllist:事件就緒隊列
rbr:用於快速查找fd的紅黑樹
epitem:一個fd會對應創建一個epitem
eventpoll實例
是存在內核空間的,每次用戶進程要請求epoll_wait
調用的時候,都需要通過傳遞epfd描述
符讓內核找到用戶要訪問的eventpoll實例;每次調用
epoll_ctl
爲描述符訂閱事件的時候,其實是把描述符和事件相關內容包裝成epitem結構
,然後往紅黑樹rbr
添加樹節點,用戶進程所有關心的描述符都存在這顆紅黑樹中;內核會通過
ep_ptable_queue_proc
函數給每個文件描述符設置回調ep_poll_callback
,對應的文件描述符如果有事件發生,那麼就會調用回調函數,從而觸發內核進行查找紅黑樹,把需要的套接字epitem移動到事件就緒隊列;最後在執行epoll_wait準備把事件就緒隊列的內容從內核空間拷貝到用戶空間的時候,還會再次調用每個文件描述符的poll方法,以便確定確實有事件發生,從而確保事件還是有效的。
更詳細的實現細節,可以進一步閱讀epoll的源碼[2]。
4.4、邊緣觸發與條件觸發
先講下邊緣觸發和條件觸發的區別。
4.4.1、邊緣觸發
當一個添加到epoll實例的epoll_event設置爲EPOLLET邊緣觸發(edge-triggered)
之後,如果後續有描述符的事件準備好了,調用epoll_wait就會把對應的epoll_event返回給應用進程,注意,在邊緣觸發模式下,只會返回已準備好的描述符的epoll_evnet一次,也就是說程序只有一次的處理機會。
4.4.2、條件觸發
當把要添加到epoll實例的epoll_event設置爲EPOLLLT條件觸發(level-triggered)
時,只要已準備好的描述符沒有被處理完,下一次調用epoll_wait的時候,還是會繼續返回給應用進程處理。這是系統默認處理方式。
EPOLLET邊緣觸發的效率要比EPOLLLT高效,因爲對於每個準備就緒的套接字,只會通知應用進程一次,但是這也要求程序員必須小心處理,不會留多次機會給你去補償處理套接字。
4.4.3、實現原理
針對條件觸發,返回給內核空間的描述符會再次加入到就緒隊列中,那麼下次調用epoll_wait的時候,這些epoll_item將會被重新處理:調用文件描述符的poll方法,確定事件是否還有效,如果還有效,那就繼續返回,從而實現了條件觸發。
而邊緣觸發的情況下,返回給內核空間的描述符則不會再次放會就緒隊列,所以只會返回一次。
4.5、epoll優缺點
4.5.1、優點
epoll每次調用epoll_wait的時候,不像poll調用一樣,每次都要傳遞結構體到內核空間,而是複用一個內核的epoll實例結構體,通過epfd進行引用,從而減小了系統開銷;
epoll底層是套接字一旦有事件,就調用回調立刻通知epoll實例,可以儘早的準備好事件就緒隊列,執行epoll_wait的時候相應的更快;
epoll底層基於紅黑樹維護興趣事件列表,這樣每次套接字有新事件觸發回調的時候,可以更快的找到套接字的epitem進行後續的處理;
提供了性能更佳的邊緣觸發機制。
正是因爲epoll這麼多的優點,很多技術都是基於epoll實現的,如nginx、redis,以及Linux下Java的NIO。
4.5.2、缺點
它還不是真正的異步IO,還是要應用進程調用IO函數的時候,才把數據從內核拷貝到應用進程。