本節,咱們介紹IO複用,經過簡單的例子演示IO複用的使用,以及實現原理,這個技術是目前構建目前的高性能服務器必備技術,在後面咱們介紹到各類網絡編程模型的時候,會用到IO複用。nginx
看完本文,您將瞭解到:程序員
IO複用的執行流程;redis
select函數的使用和優缺點,以及實現原理;編程
poll函數的使用和優缺點,以及實現原理;數組
epoll函數的使用和優缺點,以及實現原理;服務器
epoll的條件觸發和邊緣觸發,以及實現原理。網絡
I/O複用(I/O multiplexing),指的是經過一個支持同時感知多個描述符的函數系統調用,阻塞在這個系統調用上,等待某一個或者幾個描述符準備就緒,就返回可讀條件。常見的如select,poll,epoll系統調用能夠實現此類功能功能。這種模型不用阻塞在真正的I/O系統調用上。數據結構
工做原理以下圖所示:架構
如上圖,這種模型與非阻塞式I/O相比,把輪訓判斷數據是否準備好的處理方式替換爲了經過select()系統調用的方式來實現。併發
經常使用的實現IO複用的相關函數有select,poll和epoll,接下倆咱們介紹下這三個函數。
**select是實現I/O多路複用的經典系統調用函數。**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位是否開啓;
下面經過一個例子演示select是如何使用的,而且分析其執行原理。
這個例子開啓了一個監聽套接字,而後獲取5個客戶端鏈接,經過select函數判斷是否有數據到達服務器端,若是有則讀取,我把詳細的註釋都加上了,下面重點介紹標註了序號的代碼:
調用socket建立一個監聽套接字,並拿到監聽套接字描述符;
調用bind把本地協議地址賦予套接字;
調用listen轉換爲被動套接字,開始接受指向該套接字的鏈接請求;
在獲取5個已鏈接套接字的過程當中,判斷獲取到最大的套接字文件描述符;
在循環裏面,每次從新調用select以前,都須要從新設置rset,在第7步咱們解釋爲何要這樣作;
fd_set是一個bitmap,由內核固定設置的大小,最大長度爲1024,這也限制了咱們最多隻能同時監聽1024個描述符。假如咱們這裏獲得的五個描述符是:1 2 5 6 8,那麼這個位圖會是這樣的:
這裏爲何要加1呢?
根據第五步,能夠知道,fd_set中的bitmap是從0開始的,因此rset實際有效的bitmap長度是待測試描述符+1。
這一步的流程是這樣的:
應用進程調用了select以後,會把fd_set從用戶空間拷貝到內核空間,隨後應用進程進入阻塞;
內核根據fd_set獲得須要處理的描述符,根據描述符是否準備好數據,給fd_set進行置位;
進程被喚醒,拿到內核處理事後的fd_set,就能夠經過FD_ISSET判斷到套接字數據是否已經準備好了。
這裏會把已準備好的數據的套接字描述符對應的fd_set中的標識進行標記,經過FD_ISSET便可判斷到標記結果。
非阻塞IO直接輪訓查詢數據是否準備好,每次查詢都要切換內核態,輪訓消耗CPU。而select函數則直接把查詢多個描述符的動做交給了內核,這樣避免了CPU消耗和減小了內核態的切換。
根據上面的過程描述,咱們能夠知道select有以下缺點:
fd_set中的bitmap是固定1024位的,也就是說最多隻能監聽1024個套接字。固然也能夠改內核源碼,不過代價比較大;
fd_set每次傳入內核以後,都會被改寫,致使不可重用,每次調用select都須要從新初始化fd_set;
每次調用select都須要拷貝新的fd_set到內核空間,這裏會作一個用戶態到內核態的切換;
拿到fd_set的結果後,應用進程須要遍歷整個fd_set,才知道哪些文件描述符有數據能夠處理。
基於epoll的缺點,因而出現了第二個系統調用,poll,poll與內核交互的數據有所不一樣,而且突破了文件描述符數量的限制。
下面是poll函數的定義:
#include <poll.h> int poll(struct pollfd *fds, nfds_t nfds, int timeout); // 返回:如有就緒描述符則爲其數目,若超時則爲0,若出錯則爲1複製代碼
poll函數參數:
struct pollfd { int fd; /* 待檢測的文件描述符 */ short events; /* 描述符上待檢測的事件類型 */ short revents; /* 返回描述符對應事件的狀態 */ };複製代碼
int fd:爲待檢測的文件描述符;
short events:爲描述符上待檢測的事件類型,這裏用了short類型,具體的實現用二進制掩碼位操做來完成,經常使用的事件類型以下:
short revents:返回描述符對應事件的狀態,在pollfd由系統調用返回以後,會響應具體的事件狀態;
nfds_t nfds:nfds指定fds數組的大小;
int timeout:指定poll函數返回前須要等待多長時間。
接下來咱們仍是看具體的例子。
下面經過一個例子演示poll是如何使用的,而且分析其執行原理。
與select的例子很相似,開啓了一個監聽套接字,而後獲取5個客戶端鏈接,經過poll函數判斷是否有數據到達服務器端,若是有則讀取,我把詳細的註釋都加上了,下面重點介紹標註了序號的代碼:
這裏經過accept阻塞獲取已鏈接描述符,賦值給pollfd結構的fd中。
而後給pollfd的events設置POLLIN,指定須要檢測POLLIN,即數據讀入。
調用poll函數,傳入剛剛初始化好的pollfds,數量爲5,超時時間爲10秒。這裏進入阻塞等待,直到從內核返回。
與select相似,這一步的執行流程是這樣的:
應用進程調用了poll以後,會把poll_fd從用戶空間拷貝到內核空間,隨後應用進程進入阻塞;
內核根據poll_fd的fd獲得須要處理的描述符,根據描述符是否準備好數據,給poll_fd的revents進行置位;
進程被喚醒,拿到內核處理事後的poll_fd,就能夠經過與操做判斷到對應的事件是否被置位,從而知道套接字數據是否已經準備好了。
從內核返回以後,咱們循環判斷pollfds中每一個元素的revents,經過與操做,看看POLLIN是否被置位了,若是置位了就說明數據已經準備好了。
這裏對revents進行了重置,下次就能夠複用這個pollfds,繼續執行poll函數了。
與select相似,非阻塞IO直接輪訓查詢數據是否準備好,每次查詢都要切換內核態,輪訓消耗CPU,而poll則是把查詢多個描述符的動做交給了內核,避免了CPU消耗和減小了內核態的切換。
與select相比,這裏不是用的bitmap,而是直接用poll_fd數組,沒有1024個描述符的限制;
這裏引入了poll_fd結構體,內核只是修改poll_fd結構體中的revents,這樣每次讀取數據的時候,重置revents,就能夠複用poll_fd了,不用像select那樣反覆初始化一個新的rset。
每次調用poll都須要拷貝新的poll_fd到內核空間,這裏會作一個用戶態到內核態的切換;
拿到poll_fd的結果後,應用進程須要遍歷整個poll_fd,才知道哪些文件描述符有數據能夠處理。
與poll不一樣,epoll自己不是系統調用,而是一種內核數據結構,它容許進程在多個文件描述符上多路複用I / O。
能夠經過三個系統調用來建立,修改和刪除此數據結構。
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實例;
進程能夠經過調用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 */ };複製代碼
能夠經過調用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表示當即返回。
注意:epoll機制是在Linux 2.6以後引入的,因此Mac OS不支持。Mac OS下使用kqueue機制代替epoll實現IO複用。
定義一個epoll_event,存儲實際要監視的fd相關信息,以及待接收epll_wait返回的就緒事件列表。
在內核中建立epoll實例,併發揮epfd文件描述符。
event結構設置實際要監視的描述符。
event和值實際要監視的事件。
將要監視的event添加到epoll實例。
獲取內核epoll實例中興趣列表上發生的事件,即事件就緒隊列的內容,epoll_wait的返回值爲就緒隊列的大小。
仍是看看剛纔那個圖:
這裏咱們重點來看看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]。
先講下邊緣觸發和條件觸發的區別。
當一個添加到epoll實例的epoll_event設置爲EPOLLET邊緣觸發(edge-triggered)以後,若是後續有描述符的事件準備好了,調用epoll_wait就會把對應的epoll_event返回給應用進程,注意,在邊緣觸發模式下,只會返回已準備好的描述符的epoll_evnet一次,也就是說程序只有一次的處理機會。
當把要添加到epoll實例的epoll_event設置爲EPOLLLT條件觸發(level-triggered)時,只要已準備好的描述符沒有被處理完,下一次調用epoll_wait的時候,仍是會繼續返回給應用進程處理。這是系統默認處理方式。
EPOLLET邊緣觸發的效率要比EPOLLLT高效,由於對於每一個準備就緒的套接字,只會通知應用進程一次,可是這也要求程序員必須當心處理,不會留屢次機會給你去補償處理套接字。
針對條件觸發,返回給內核空間的描述符會再次加入到就緒隊列中,那麼下次調用epoll_wait的時候,這些epoll_item將會被從新處理:調用文件描述符的poll方法,肯定事件是否還有效,若是還有效,那就繼續返回,從而實現了條件觸發。
而邊緣觸發的狀況下,返回給內核空間的描述符則不會再次放會就緒隊列,因此只會返回一次。
epoll每次調用epoll_wait的時候,不像poll調用同樣,每次都要傳遞結構體到內核空間,而是複用一個內核的epoll實例結構體,經過epfd進行引用,從而減少了系統開銷;
epoll底層是套接字一旦有事件,就調用回調馬上通知epoll實例,能夠儘早的準備好事件就緒隊列,執行epoll_wait的時候相應的更快;
epoll底層基於紅黑樹維護興趣事件列表,這樣每次套接字有新事件觸發回調的時候,能夠更快的找到套接字的epitem進行後續的處理;
提供了性能更佳的邊緣觸發機制。
正是由於epoll這麼多的優勢,不少技術都是基於epoll實現的,如nginx、redis,以及Linux下Java的NIO。
它還不是真正的異步IO,仍是要應用進程調用IO函數的時候,才把數據從內核拷貝到應用進程。最後點擊這裏便可領取Java架構大禮包哦!!!