select和epoll模型
利用select或者epoll實現I/O多路複用。
select的缺陷:
高併發的核心解決方案是一個執行緒處理所有連線的“等待訊息準備好”,當有數十萬併發連線存在時,可能每一毫秒只有數百個連線是活躍的。其餘的在這一毫秒都是非活躍的。select使用的方法是:
返回活躍的連線 = select(全部監控的連線)。
什麼時候呼叫select方法?當需要找出有報文到達的活躍連線時,就應該呼叫。
首先監控數十萬的連線但是返回的只有數百個活躍連線,這本身就是無效率的表現。
其次在Linux核心中,select所用到的FD_SET是有限的(即監控的連線數目是有限的)
__FD_SETSIZE定義了FD_SET的控制代碼個數。
並且核心中實現select是用輪詢的方法,既每次檢測都會遍歷所有FD_SET中的控制代碼
顯然當select函式監控的連線數越多那麼每次檢測都要遍歷的控制代碼數就會越多時間就越浪費
相比於select機制,poll只是取消了最大監控檔案描述符的限制,其它的並和select並沒有區別
epoll高效的奧祕:
epoll精巧的使用了3個方法來實現select方法要做的事:
1.新建epoll描述符(epoll_create())
2.epoll_ctl(新增、刪除或者修改所有待監控的連線)
3.返回活躍連線(epoll_wait())
與select相比,epoll分清了頻繁呼叫和不頻繁滴啊用的操作。如:epoll_ctl是不頻繁呼叫的
而epoll_wait是非常頻繁呼叫的,而epoll_wait卻幾乎沒有入參,所以相比select效率高,
並且也不會隨著併發連線的增加使得入參越來越多,導致核心執行效率下降。
epoll的三大關鍵要素:mmap、紅黑樹、連結串列。
epoll是通過核心與使用者空間mmap同一塊記憶體(實體記憶體)實現的。mmap將使用者空間的一塊地址和核心空間的一塊地址同時對映到同一塊實體記憶體地址(不管是使用者空間還是核心空間都是虛擬地址,最終要通過地址對映對映到實體地址),使得這塊實體記憶體對核心和使用者均可見,減少使用者態和核心態之間的資料交換。核心可以直接看到epoll監聽的控制代碼,效率高。
紅黑樹將儲存epoll所監聽的套接字。上面mmap出來的記憶體如何儲存epoll所監聽的套接字,必然也得有一套資料結構,epoll在實現上採用紅黑樹去儲存所有套接字,當新增或者刪除一個套接字時(epoll_ctl),都在紅黑樹上去處理,紅黑樹本身插入和刪除效能比較好,時間複雜度o(logN).
管理紅黑樹和就緒連結串列的結構
struct eventpoll
{
spin_lock_t lock; //對本資料結構的訪問
struct mutex mtx; //防止使用時被刪除
wait_queue_head_t wq; //sys_epoll_wait() 使用的等待佇列
wait_queue_head_t poll_wait; //file->poll()使用的等待佇列
struct list_head rdllist; //事件滿足條件的連結串列
struct rb_root rbr; //用於管理所有fd的紅黑樹
struct epitem *ovflist; //將事件到達的fd進行連結起來發送至使用者空間
}
紅黑樹節點的結構
struct epitem
{
struct rb_node rbn; //用於主結構管理的紅黑樹
struct list_head rdllink; //事件就緒佇列
struct epitem *next; //用於主結構體中的連結串列
struct epoll_filefd ffd; //每個fd生成的一個結構
int nwait;
struct list_head pwqlist; //poll等待佇列
struct eventpoll *ep; //該項屬於哪個主結構體
struct list_head fllink; //連結fd對應的file連結串列
struct epoll_event event; //註冊的感興趣的事件,也就是使用者空間的epoll_event
}
新增以及返回事件
通過epoll_ctl函式新增進來的事件都會被放在紅黑樹的某個節點內,所以,重複新增是沒用的。
當把事件新增進來的時候該時間都會與相應的裝置(網絡卡)驅動程式建立回撥關係,當相應的事件發生後,就會呼叫這個回撥函式,該回調函式在核心中被稱為:ep_poll_callback,這個回撥函式其實就把這個事件新增到rdllist這個雙向連結串列中。一旦有事件發生,epoll就會將該時間新增到雙向連結串列中,當我們呼叫epoll_wait時,epoll_wait只需要檢查rdlist雙向連結串列中是否存在註冊的事件,效率很高。
epoll_wait的工作流程:
1.epoll_wait呼叫ep_poll,當rdlist為空(無就緒fd)時掛起當前程序,直到rdlist不空時程序才被喚醒。
2.檔案fd狀態改變(buffer由不可讀變為可讀或由不可寫變為可寫),導致相應fd上的回撥函式ep_poll_callback()被呼叫。
3.ep_poll_callback將相應fd對應epitem加入rdlist,導致rdlist不空,程序被喚醒,epoll_wait得以繼續執行
4.ep_events_transfer函式將rdlist中的epitem拷貝到txlist中,並將rdlist清空
5.ep_send_events函式,掃描txlist中的每個epitem,呼叫其關聯fd對應的poll方法。此時對poll的呼叫僅僅是取得fd上較新的events之後將取得的events和相應的fd傳送到使用者空間(封裝在strcut epoll_event,從epoll_wait返回)
需要注意的是:epoll並不是在所有的應用場景都會比select和poll高很多。尤其是當活動連線比較多的時候,回撥函式被觸發得過於頻繁的時候,epoll的效率也會受到顯著影響!所以,epoll特別適用於連線數量多,但活動連線較少的情況。
EPOLL的使用
檔案描述符的建立
#include <sys/epoll.h>
int epoll_create(int size);
在epoll早期的實現中,對於監控檔案描述符的組織並不是使用紅黑樹,而是hash表。這裡的size實際上已經沒有意義。
註冊監控事件
#include <sys/epoll.h>
int epoll_ctl( int epfd, int op, int fd, struct epoll_event *event );
函式說明:
efd:要操作的檔案描述符
op:指定操作型別
操作型別:
EPOLL_CTL_ADD:往事件表中註冊fd上的事件
EPOLL_CTL_MOD:修改fd上的註冊事件
EPOLL_CTL_DEL:刪除fd上的註冊事件
event:指定事件,它是epoll_event結構指標型別
epoll_event定義:
1 struct epoll_event
2 {
3 __unit32_t events; // epoll事件
4 epoll_data_t data; // 使用者資料
5 };
結構體說明:
events:描述事件型別,和poll支援的事件型別基本相同(兩個額外的事件:EPOLLET和EPOLLONESHOT,高效運作的關鍵)
data成員:儲存使用者資料
typedef union epoll_data
{
void* ptr; //指定與fd相關的使用者資料
int fd; //指定事件所從屬的目標檔案描述符
uint32_t u32;
uint64_t u64;
} epoll_data_t;
epoll_wait函式
#include <sys/epoll.h>
int epoll_wait ( int epfd, struct epoll_event* events, int maxevents, int timeout );
函式說明:
返回:成功時返回就緒的檔案描述符的個數,失敗時返回-1並設定errno
timeout:指定epoll的超時時間,單位是毫秒。當timeout為-1是,epoll_wait呼叫將永遠阻塞,直到某個時間發生。當timeout為0時,epoll_wait呼叫將立即返回。
maxevents:指定最多監聽多少個事件
events:檢測到事件,將所有就緒的事件從核心事件表中複製到它的第二個引數events指向的陣列中。
EPOLLONESHOT事件
使用場合:
一個執行緒在讀取完某個socket上的資料後開始處理這些資料,而資料的處理過程中該socket又有新資料可讀,此時另外一個執行緒被喚醒來讀取這些新的資料。
於是,就出現了兩個執行緒同時操作一個socket的局面。可以使用epoll的EPOLLONESHOT事件實現一個socket連線在任一時刻都被一個執行緒處理。
作用:
對於註冊了EPOLLONESHOT事件的檔案描述符,作業系統最多出發其上註冊的一個可讀,可寫或異常事件,且只能觸發一次。
使用:
註冊了EPOLLONESHOT事件的socket一旦被某個執行緒處理完畢,該執行緒就應該立即重置這個socket上的EPOLLONESHOT事件,以確保這個socket下一次可讀時,其EPOLLIN事件能被觸發,進而讓其他工作執行緒有機會繼續處理這個sockt。
效果:
儘管一個socket在不同事件可能被不同的執行緒處理,但同一時刻肯定只有一個執行緒在為它服務,這就保證了連線的完整性,從而避免了很多可能的競態條件。
LT與ET模式
在這裡,筆者強烈推薦《徹底學會使用epoll》系列博文,這是筆者看過的,對epoll的ET和LT模式講解最為詳盡和易懂的博文。下面的例項均來自該系列博文。限於篇幅原因,很多關鍵的細節,不能完全摘錄。
#include <stdio.h>
#include <unistd.h>
#include <sys/epoll.h>
int main(void)
{
int epfd,nfds;
struct epoll_event ev,events[5]; //ev用於註冊事件,陣列用於返回要處理的事件
epfd = epoll_create(1); //只需要監聽一個描述符——標準輸入
ev.data.fd = STDIN_FILENO;
ev.events = EPOLLIN|EPOLLET; //監聽讀狀態同時設定ET模式
epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev); //註冊epoll事件
for(;;)
{
nfds = epoll_wait(epfd, events, 5, -1);
for(int i = 0; i < nfds; i++)
{
if(events[i].data.fd==STDIN_FILENO)
printf("welcome to epoll's word!\n");
}
}
}
- 當用戶輸入一組字元,這組字元被送入buffer,字元停留在buffer中,又因為buffer由空變為不空,所以ET返回讀就緒,輸出”welcome to epoll's world!”。
- 之後程式再次執行epoll_wait,此時雖然buffer中有內容可讀,但是根據我們上節的分析,ET並不返回就緒,導致epoll_wait阻塞。(底層原因是ET下就緒fd的epitem只被放入rdlist一次)。
- 使用者再次輸入一組字元,導致buffer中的內容增多,根據我們上節的分析這將導致fd狀態的改變,是對應的epitem再次加入rdlist,從而使epoll_wait返回讀就緒,再次輸出“Welcome to epoll's world!”。
將註冊的事件改為ev.events=EPOLLIN; //預設使用LT模式
程式陷入死迴圈,因為使用者輸入任意資料後,資料被送入buffer且沒有被讀出,所以LT模式下每次epoll_wait都認為buffer可讀返回讀就緒。導致每次都會輸出”welcome to epoll's world!”。
#include <stdio.h>
#include <unistd.h>
#include <sys/epoll.h>
int main(void)
{
int epfd,nfds;
struct epoll_event ev,events[5]; //ev用於註冊事件,陣列用於返回要處理的事件
epfd = epoll_create(1); //只需要監聽一個描述符——標準輸入
ev.data.fd = STDIN_FILENO;
ev.events = EPOLLIN; //監聽讀狀態同時設定LT模式
epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev); //註冊epoll事件
for(;;)
{
nfds = epoll_wait(epfd, events, 5, -1);
for(int i = 0; i < nfds; i++)
{
if(events[i].data.fd==STDIN_FILENO)
{
char buf[1024] = {0};
read(STDIN_FILENO, buf, sizeof(buf));
printf("welcome to epoll's word!\n");
}
}
}
}
本程式依然使用LT模式,但是每次epoll_wait返回讀就緒的時候我們都將buffer(緩衝)中的內容read出來,所以導致buffer再次清空,下次呼叫epoll_wait就會阻塞。所以能夠實現我們所想要的功能——當用戶從控制檯有任何輸入操作時,輸出”welcome to epoll's world!”
#include <stdio.h>
#include <unistd.h>
#include <sys/epoll.h>
int main(void)
{
int epfd,nfds;
struct epoll_event ev,events[5]; //ev用於註冊事件,陣列用於返回要處理的事件
epfd = epoll_create(1); //只需要監聽一個描述符——標準輸入
ev.data.fd = STDOUT_FILENO;
ev.events = EPOLLOUT|EPOLLET; //監聽讀狀態同時設定ET模式
epoll_ctl(epfd, EPOLL_CTL_ADD, STDOUT_FILENO, &ev); //註冊epoll事件
for(;;)
{
nfds = epoll_wait(epfd, events, 5, -1);
for(int i = 0; i < nfds; i++)
{
if(events[i].data.fd==STDOUT_FILENO)
{
printf("welcome to epoll's word!\n");
}
}
}
}
這個程式的功能是隻要標準輸出寫就緒,就輸出“welcome to epoll's world”。我們發現這將是一個死迴圈。下面具體分析一下這個程式的執行過程:
- 首先初始buffer為空,buffer中有空間可寫,這時無論是ET還是LT都會將對應的epitem加入rdlist,導致epoll_wait就返回寫就緒。
- 程式想標準輸出輸出”welcome to epoll's world”和換行符,因為標準輸出為控制檯的時候緩衝是“行緩衝”,所以換行符導致buffer中的內容清空,這就對應第二節中ET模式下寫就緒的第二種情況——當有舊資料被髮送走時,即buffer中待寫的內容變少得時候會觸發fd狀態的改變。所以下次epoll_wait會返回寫就緒。如此迴圈往復。
#include <stdio.h>
#include <unistd.h>
#include <sys/epoll.h>
int main(void)
{
int epfd,nfds;
struct epoll_event ev,events[5]; //ev用於註冊事件,陣列用於返回要處理的事件
epfd = epoll_create(1); //只需要監聽一個描述符——標準輸入
ev.data.fd = STDOUT_FILENO;
ev.events = EPOLLOUT|EPOLLET; //監聽讀狀態同時設定ET模式
epoll_ctl(epfd, EPOLL_CTL_ADD, STDOUT_FILENO, &ev); //註冊epoll事件
for(;;)
{
nfds = epoll_wait(epfd, events, 5, -1);
for(int i = 0; i < nfds; i++)
{
if(events[i].data.fd==STDOUT_FILENO)
{
printf("welcome to epoll's word!");
}
}
}
}
與程式四相比,程式五隻是將輸出語句的printf的換行符移除。我們看到程式成掛起狀態。因為第一次epoll_wait返回寫就緒後,程式向標準輸出的buffer中寫入“welcome to epoll's world!”,但是因為沒有輸出換行,所以buffer中的內容一直存在,下次epoll_wait的時候,雖然有寫空間但是ET模式下不再返回寫就緒。回憶第一節關於ET的實現,這種情況原因就是第一次buffer為空,導致epitem加入rdlist,返回一次就緒後移除此epitem,之後雖然buffer仍然可寫,但是由於對應epitem已經不再rdlist中,就不會對其就緒fd的events的在檢測了。
#include <stdio.h>
#include <unistd.h>
#include <sys/epoll.h>
int main(void)
{
int epfd,nfds;
struct epoll_event ev,events[5]; //ev用於註冊事件,陣列用於返回要處理的事件
epfd = epoll_create(1); //只需要監聽一個描述符——標準輸入
ev.data.fd = STDOUT_FILENO;
ev.events = EPOLLOUT; //監聽讀狀態同時設定LT模式
epoll_ctl(epfd, EPOLL_CTL_ADD, STDOUT_FILENO, &ev); //註冊epoll事件
for(;;)
{
nfds = epoll_wait(epfd, events, 5, -1);
for(int i = 0; i < nfds; i++)
{
if(events[i].data.fd==STDOUT_FILENO)
{
printf("welcome to epoll's word!");
}
}
}
}
程式六相對程式五僅僅是修改ET模式為預設的LT模式,我們發現程式再次死迴圈。這時候原因已經很清楚了,因為當向buffer寫入”welcome to epoll's world!”後,雖然buffer沒有輸出清空,但是LT模式下只有buffer有寫空間就返回寫就緒,所以會一直輸出”welcome to epoll's world!”,當buffer滿的時候,buffer會自動刷清輸出,同樣會造成epoll_wait返回寫就緒。
經過前面的案例分析,我們已經瞭解到,當epoll工作在ET模式下時,對於讀操作,如果read一次沒有讀盡buffer中的資料,那麼下次將得不到讀就緒的通知,造成buffer中已有的資料無機會讀出,除非有新的資料再次到達。對於寫操作,主要是因為ET模式下fd通常為非阻塞造成的一個問題——如何保證將使用者要求寫的資料寫完。
要解決上述兩個ET模式下的讀寫問題,我們必須實現:
- 對於讀,只要buffer中還有資料就一直讀;
- 對於寫,只要buffer還有空間且使用者請求寫的資料還未寫完,就一直寫。
ET模式下的accept問題
請思考以下一種場景:在某一時刻,有多個連線同時到達,伺服器的 TCP 就緒佇列瞬間積累多個就緒連線,由於是邊緣觸發模式,epoll 只會通知一次,accept 只處理一個連線,導致 TCP 就緒佇列中剩下的連線都得不到處理。在這種情形下,我們應該如何有效的處理呢?
解決的方法是:解決辦法是用 while 迴圈抱住 accept 呼叫,處理完 TCP 就緒佇列中的所有連線後再退出迴圈。如何知道是否處理完就緒佇列中的所有連線呢? accept 返回 -1 並且 errno 設定為 EAGAIN 就表示所有連線都處理完。
ET模式為什麼要設定在非阻塞模式下工作
因為ET模式下的讀寫需要一直讀或寫直到出錯(對於讀,當讀到的實際位元組數小於請求位元組數時就可以停止),而如果你的檔案描述符如果不是非阻塞的,那這個一直讀或一直寫勢必會在最後一次阻塞。這樣就不能在阻塞在epoll_wait上了,造成其他檔案描述符的任務飢餓。
小結
LT:水平觸發,效率會低於ET觸發,尤其在大併發,大流量的情況下。但是LT對程式碼編寫要求比較低,不容易出現問題。LT模式服務編寫上的表現是:只要有資料沒有被獲取,核心就不斷通知你,因此不用擔心事件丟失的情況。
ET:邊緣觸發,效率非常高,在併發,大流量的情況下,會比LT少很多epoll的系統呼叫,因此效率高。但是對程式設計要求高,需要細緻的處理每個請求,否則容易發生丟失事件的情況。
從本質上講:與LT相比,ET模型是通過減少系統呼叫來達到提高並行效率的。
1.2 兩種加入rdlist途徑的不同
下面我們來分析一下圖中兩種將epitem加入rdlist方式(也就是紅線和藍線)的區別。
l 紅線:fd狀態改變是才會觸發。那麼什麼情況會導致fd狀態的改變呢?
對於讀取操作:
(1) 當buffer由不可讀狀態變為可讀的時候,即由空變為不空的時候。
(2) 當有新資料到達時,即buffer中的待讀內容變多的時候。
對於寫操作:
(1) 當buffer由不可寫變為可寫的時候,即由滿狀態變為不滿狀態的時候。
(2) 當有舊資料被髮送走時,即buffer中待寫的內容變少得時候。
l 藍線:fd的events中有相應的時間(位置1)即會觸發。那麼什麼情況下會改變events的相應位呢?
對於讀操作:
(1) buffer中有資料可讀的時候,即buffer不空的時候fd的events的可讀為就置1。
對於寫操作:
(1) buffer中有空間可寫的時候,即buffer不滿的時候fd的events的可寫位就置1。
說明:紅線是時間驅動被動觸發,藍線是函式查詢主動觸發。