徹底弄懂IO複用:IO處理殺手鐗,帶您深入瞭解select,poll,epoll 1、I/O複用模型介紹 2、select函數 3、poll 4、epoll

推薦閱讀:

本節,我們介紹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函數的時候,才把數據從內核拷貝到應用進程。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章