I/O多路轉接——select、poll 和 epoll
一、select
1. select() 函式
- select系統呼叫是用來讓我們的程式監視多個檔案描述符的狀態變化的;
- 程式會停在select這裡等待,直到被監視的檔案描述符有一個或多個發生了狀態改變。
select函式原型:
#include <sys/select.h>
int select( int nfds,
fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
struct timeval *timeout);
引數nfds:指定被監聽的檔案描述符的總數,通常被設定為select監聽的所有檔案描述符中最大的那個+1。
readfds, writefds 和 exceptfds 引數分別指向可讀、可寫和異常等事件對應的檔案描述符。程式調 select 是通過這三個引數傳入自己感興趣的檔案描述符,select 呼叫返回時,核心會修改他們,以便通知程式哪些檔案描述符已經就緒。這是典型的輸入輸出型引數,他們都是 fd_set 結構體型別。
引數 timeout 為結構體 timeval,用來設定 select() 的等待時間
(1)引數timeout
- NULL:則表示 select() 沒有 timeout,select 將一直被阻塞,直到某個檔案描述符上發生了事件;
- 0:僅檢測描述符集合的狀態,然後立即返回,並不等待外部事件的發生。
- 特定的時間值:如果在指定的時間段裡沒有事件發⽣生,select() 將超時返回。
timeval結構體定義如下:
struct timeval
{
long tv_sec; // 秒
long tv_usec; // 微妙
}
(2)fd_set結構體
第二個引數、第三個引數、第四個引數都是指向fd_set型別的指標,fd_set結構體內實質上就是一個位圖。點陣圖中每個元素的下標代表一個檔案描述符。每個元素的取值只有0和1。
fd_set 的大小由 FD_SETSIZE 指定,這就限制了select 能同時處理的檔案描述符的總數。
由於對點陣圖的操作比較繁瑣,所以系統已經封裝好了一套函式供我們使用。
#include <sys/select.h>
FD_CLR(int fd, fd_set* fdset); //清除fdset的位fd
FD_SET(int fd, fd_set* fdset); //設定fdset的位fd
FD_ZERO(fd_set* fdset; //清除fdset所有的位
FD_ISSET(int fd, fd_set* fdset) //測試fdset的位fd是否被設定
(3)函式返回值
select 函式成功時返回就緒(可讀、可寫和異常)檔案描述符的總數。如果在超時時間內沒有任何檔案描述符就緒,select 返回0.select失敗時返回-1並設定error。
如果在select等待期間,程式收到訊號,則select立即返回-1,並設定error為EINTR。
2. socket就緒條件
(1)讀就緒
socket核心中, 接收緩衝區中的位元組數, 大於等於低水位標記SO_RCVLOWAT. 此時可以無阻塞的讀該檔案描述符, 並且返回值大於0;
socket TCP通訊中, 對端關閉連線(收到了FIN的TCP連線), 此時對該socket讀, 將不會阻塞,而是直接返回0(也就是EOF);
監聽的socket上有新的連線請求;該套接字是一個listen的監聽套接字,並且目前已經完成的連線數不為0。對這樣的套接字進行accept操作通常不會阻塞。
socket上有未處理的錯誤;
(2)寫就緒
socket核心中, 傳送緩衝區中的可用位元組數(傳送緩衝區的空閒位置大小), 大於等於低水位標記SO_SNDLOWAT, 此時可以無阻塞的寫, 並且返回值大於0;
socket的寫操作被關閉(close或者shutdown或主動傳送了FIN的TCP連線).對一個寫操作被關閉的socket進行寫操作, 會觸發SIGPIPE訊號;
socket使用非阻塞connect連線成功或失敗之後;
socket上有未讀取的錯誤。
(3)異常就緒
- socket上收到帶外資料. 關於帶外資料, 和TCP緊急模式相關(TCP協議頭中, 有一個緊急指標的欄位)。
3. select的特點
可監控的檔案描述符個數取決與sizeof(fdset)的值。如果sizeof(fdset)=512,那麼支援的最大檔案描述符是 512*8=4096.
將fd加入select監控集的同時,還要再使用一個數據結構array儲存放到select監控集中的fd,
- 一是用於再select返回後,array作為源資料和fdset進行FDISSET判斷。
- 二是select返回後會把以前加入的但並無事件發生的fd清空,則每次開始select前都要重新從array取得fd逐一加入(FD_ZERO最先),掃描array的同時取得fd最大值maxfd,用於select的第一個引數。
4. select的缺點
每次呼叫select, 都需要手動設定fd集合,從介面使用角度來說非常不便.
每次呼叫select,都需要把fd集合從使用者態拷貝到核心態,這個開銷在fd很多時會很大
同時每次呼叫select都需要在核心遍歷傳遞進來的所有fd,這個開銷在fd很多時也很大
select支援的檔案描述符數量太小.
二、poll
1. poll()函式
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// pollfd結構
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
(1)引數
fds是一個poll函式監聽的結構列表. 每一個元素中, 包含了三部分內容:檔案描述符, 監聽的事件集合, 返回的事件集合.
nfds表示fds陣列的長度.
timeout表示poll函式的超時時間, 單位是毫秒(ms).
(2)events和revents
(3)返回值
- 返回值小於0,表示出錯;
- 返回值等於0,表示poll函式等待超時;
- 返回值大於0,表示poll由於監聽的檔案描述符就緒而返回。
2. poll的缺點
poll中監聽的檔案描述符數目增多時:
- 和select函式一樣,poll返回後,需要輪詢pollfd來獲取就緒的描述符.
- 每次呼叫poll都需要把大量的pollfd結構從使用者態拷貝到核心中.
- 同時連線的大量客戶端在一時刻可能只有很少的處於就緒狀態, 因此隨著監視的描述符數量的增長,其效率也會線性下降.
三、epoll
1. epoll_create()函式
#include <sys/epoll.h>
int epoll_create(int size);
/*
* 功能:在核心中建立一個事件表
* 引數:告訴核心事件表需要多大
* 返回值:建立的事件表的檔案描述符
*/
2. epoll_ctl()函式
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/*
* 功能:操作epoll的核心事件表
* 引數:
* 1. epfd:epoll_create()函式的返回值,epoll核心事件表的檔案描述符
* 2. op:要對事件表進行的操作,有以下三個選項:
①EPOLL_CTL_ADD 給fd檔案描述符新增event事件
②EPOLL_CTL_MOD 將fd檔案描述符上的事件修改成event事件
③EPOLL_CTL_DEL 刪除fd檔案描述符上的事件,event引數寫NULL
* 3. fd:要操作的檔案描述符
* 4. event:告訴核心需要監聽什麼事件,epoll_event結構體如下:
struct epoll_event {
uint32_t events; //可以取值EPOLLIN、EPOLLOUT、EPOLLET等
epoll_data_t data;
};
而其中的epoll_data_t 是一個聯合體,其結構如下:
typedef union epoll_data {
void *ptr; // 指定與fd相關的使用者資料(因為聯合體,每次只能用其中一個)
int fd; // 事件所屬的檔案描述符
uint32_t u32;
uint64_t u64;
} epoll_data_t;
* 返回值:成功返回0,失敗返回-1
*/
3. epoll_wait()
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
/*
* 功能:在超時時間內等待一組檔案描述符上的事件
* 引數:
* 1. epfd:epoll_create()函式的返回值,epoll核心事件表的檔案描述符
* 2. events:分配好的epoll_event結構體陣列,用於儲存事件表中所有已發生的事件,即輸出型引數,它使得epoll大大提高了效率
* 3. maxevents:最多監聽多少個事件
* 4. timeout:和select的timeout一樣。
*
* 返回值:和select返回值一樣
*/
關於struct epoll_event結構體:
- 第一個成員 events:代表使用者關心的事件,值可以設成EPOLLIN、EPOLLOUT等
- 第二個成員 data:是一個union epoll_data 結構體,所以裡面的 ptr 和 fd 不能同時使用。
4. epoll高效背後的祕密
在呼叫 epoll_create 時,Linux核心會建立一個 eventpoll 結構體,這個結構體中有兩個成員就是epoll高效的祕密。
struct eventpoll{
...
/*紅黑樹的根節點,這顆樹中儲存著所有新增到epoll中的需要監控的事件*/
struct rb_root rbr;
/*雙鏈表中則存放著將要通過epoll_wait返回給使用者的滿足條件的事件*/
struct list_head rdlist;
...
};
每一個epoll物件都有一個獨立的eventpoll結構體,用於存放通過epoll_ctl方法向epoll物件中新增進來的事件。這些事件都會掛載在紅黑樹中,如此,重複新增的事件就可以通過紅黑樹而高效的識別出來(紅黑樹的插入時間效率是lgn,其中n為樹的高度)。
而所有新增到epoll中的事件都會與裝置(網絡卡)驅動程式建立回撥關係,也就是說,當相應的事件發生時會呼叫這個回撥方法。這個回撥方法在核心中叫ep_poll_callback
,它會將發生的事件新增到rdlist
雙鏈表中。
在epoll中,對於每一個事件,都會建立一個epitem結構體,如下所示:
struct epitem{
struct rb_node rbn;//紅黑樹節點
struct list_head rdllink;//雙向連結串列節點
struct epoll_filefd ffd; //事件控制代碼資訊
struct eventpoll *ep; //指向其所屬的eventpoll物件
struct epoll_event event; //期待發生的事件型別
}
當呼叫epoll_wait檢查是否有事件發生時,只需要檢查eventpoll物件中的rdlist雙鏈表中是否有epitem元素即可。如果rdlist不為空,則把發生的事件複製到使用者態,同時將事件數量返回給使用者。
當呼叫
epoll_wait
時就相當於以往呼叫select/poll
,但是這時卻不用傳遞socket
控制代碼給核心,因為核心已經在epoll_ctl
中拿到了要監控的控制代碼列表。在核心裡,一切皆檔案。所以,epoll 向核心註冊了一個檔案系統,用於儲存上述的被監控socket。當你呼叫epoll_create時,就會在這個虛擬的epoll檔案系統裡建立一個file結點。當然這個file不是普通檔案,它只服務於epoll。
epoll在被核心初始化時(作業系統啟動),同時會開闢出epoll自己的核心高速cache區,用於安置每一個我們想監控的socket,這些socket會以紅黑樹的形式儲存在核心cache裡,以支援快速的查詢、插入、刪除。這個核心高速cache區,就是建立連續的實體記憶體頁,然後在之上建立slab層,簡單的說,就是物理上分配好你想要的size的記憶體物件,每次使用時都是使用空閒的已分配好的物件。
極其高效的原因:
我們在呼叫epoll_create
時,核心除了幫我們在epoll檔案系統裡建了個file結點,在核心cache裡建了個紅黑樹用於儲存以後epoll_ctl
傳來的socket外,還會再建立一個list連結串列,用於儲存準備就緒的事件,當epoll_wait
呼叫時,僅僅觀察這個list
連結串列裡有沒有資料即可。有資料就返回,沒有資料就sleep,等到timeout時間到後即使連結串列沒資料也返回。所以,epoll_wait
非常高效。
這個準備就緒list連結串列是怎麼維護的呢?當我們執行epoll_ctl時,除了把socket放到epoll檔案系統裡file物件對應的紅黑樹上之外,還會給核心中斷處理程式註冊一個回撥函式,告訴核心,如果這個控制代碼的中斷到了,就把它放到準備就緒list連結串列裡。所以,當一個socket上有資料到了,核心在把網絡卡上的資料copy到核心中,然後就把socket插入到準備就緒連結串列裡了。
如此,一顆紅黑樹,一張準備就緒控制代碼連結串列,少量的核心cache,就幫我們解決了大併發下的socket處理問題。執行epoll_create
時,建立了紅黑樹和就緒連結串列,執行epoll_ctl
時,如果增加socket控制代碼,則檢查在紅黑樹中是否存在,存在立即返回,不存在則新增到樹幹上,然後向核心註冊回撥函式,用於當中斷事件來臨時向準備就緒連結串列中插入資料。執行epoll_wait
時立刻返回準備就緒連結串列裡的資料即可。
5. LT 和 ET
EPOLL事件有兩種模型 LT(Level Triggered 水平觸發事件) 和 ET(Edge Triggered 邊沿觸發事件)
LT(level triggered,水平觸發模式)是預設的工作方式,並且同時支援 block 和 non-block socket。在這種做法中,核心告訴你一個檔案描述符是否就緒了,然後你可以對這個就緒的fd進行IO操作。如果你不作任何操作,核心還是會繼續通知你的,所以,這種模式程式設計出錯誤可能性要小一點。
ET(edge-triggered,邊緣觸發模式)是高速工作方式,只支援no-block socket。在這種模式下,當描述符從未就緒變為就緒時,核心通過epoll告訴你。然後它會假設你知道檔案描述符已經就緒,並且不會再為那個檔案描述符傳送更多的就緒通知,等到下次有新的資料進來的時候才會再次出發就緒事件。
從作業系統角度看,當一個socket控制代碼上有事件時,核心會把該控制代碼插入上面所說的準備就緒list連結串列,這時我們呼叫epoll_wait
,會把準備就緒的 socket 拷貝到使用者態記憶體,然後清空準備就緒list連結串列,最後,epoll_wait
幹了件事,就是檢查這些socket,如果是LT模式,並且這些socket上確實有未處理的事件時,又把該控制代碼放回到剛剛清空的準備就緒連結串列了。所以,LT的控制代碼,只要它上面還有事件,epoll_wait每次都會返回這個控制代碼。
四、select、poll 和 epoll 的優缺點對比
- select優點:
- 一次可以等待多個檔案描述符,減少了平均等待時間
- 客戶越來越多時,減輕了程序排程的壓力(相較於多程序多執行緒伺服器)
select缺點:
- 能監聽的檔案描述符有上限,這個上限是由fd_set決定的。
- 它返回的只是就緒事件的個數,要判斷是那個事件滿足,需要遍歷檔案描述符。
- select監聽的集合是輸入輸出引數,每次監聽都需要重新初始化。
- 每次呼叫select,都需要把fd集合從使用者態拷貝到核心態,這個開銷在fd很多時會很大
- 核心採用輪詢(遍歷fd集合)的方式來檢測就緒事件,這個開銷在fd很多時也很大
- select和poll都只能工作在低效的LT(水平觸發)模式
poll優點:
- poll監聽的檔案描述符沒有最大數量的限制(65535)
- poll對於select來說包含了一個pollfd結構,pollfd結構包含了要監視的event和發生的revent,而不像select那樣使用輸入輸出的傳遞方式。所以不需要每次監聽都初始化
poll缺點:
- 數量過大以後其效率也會線性下降。
- poll和select一樣也是返回就緒事件的個數,需要遍歷檔案描述符來判斷是那個事件已經就緒,當數量很大時,開銷也就很大。
- select和poll都只能工作在低效的LT(水平觸發)模式
- 每次呼叫poll,都需要把pollfd陣列從使用者態拷貝到核心態,這個開銷在fd很多時會很大
- 核心採用輪詢(遍歷pollfd陣列)的方式來檢測就緒事件,這個開銷在fd很多時也很大
epoll 的優點
- 檔案描述符數目無上限: 通過epoll_ctl()來註冊一個檔案描述符, 核心中使用紅黑樹的資料結構來管理所有需要監控的檔案描述符.
- 基於事件的就緒通知方式: 一旦被監聽的某個檔案描述符就緒, 核心會採用類似於callback的回撥機制, 迅速啟用這個檔案描述符. 這樣隨著檔案描述符數量的增加, 也不會影響判定就緒的效能;
- 維護就緒佇列: 當檔案描述符就緒, 就會被放到核心中的一個就緒佇列中. 這樣呼叫epoll_wait獲取就緒檔案描述符時,只需要去佇列中的元素即可,操作時間複雜度是