前言
I/O多路復用有很多種實現。在linux上,2.4內核前主要是select和poll,自Linux 2.6內核正式引入epoll以來,epoll已經成為了目前實現高性能網絡服務器的必備技術。盡管他們的使用方法不盡相同,但是本質上卻沒有什么區別。本文將重點探討將放在EPOLL的實現與使用詳解。
為什么會是EPOLL
select的缺陷

1 /linux/posix_types.h: 2 3 #define __FD_SETSIZE 1024
圖 1.主流I/O復用機制的benchmark
epoll高效的奧秘
epoll精巧的使用了3個方法來實現select方法要做的事:
- 新建epoll描述符==epoll_create()
- epoll_ctrl(epoll描述符,添加或者刪除所有待監控的連接)
- 返回的活躍連接 ==epoll_wait( epoll描述符 )
要深刻理解epoll,首先得了解epoll的三大關鍵要素:mmap、紅黑樹、鏈表。
epoll是通過內核與用戶空間mmap同一塊內存實現的。mmap將用戶空間的一塊地址和內核空間的一塊地址同時映射到相同的一塊物理內存地址(不管是用戶空間還是內核空間都是虛擬地址,最終要通過地址映射映射到物理地址),使得這塊物理內存對內核和對用戶均可見,減少用戶態和內核態之間的數據交換。內核可以直接看到epoll監聽的句柄,效率高。
紅黑樹將存儲epoll所監聽的套接字。上面mmap出來的內存如何保存epoll所監聽的套接字,必然也得有一套數據結構,epoll在實現上采用紅黑樹去存儲所有套接字,當添加或者刪除一個套接字時(epoll_ctl),都在紅黑樹上去處理,紅黑樹本身插入和刪除性能比較好,時間復雜度O(logN)。
下面幾個關鍵數據結構的定義

1 struct epitem 2 { 3 struct rb_node rbn; //用於主結構管理的紅黑樹 4 struct list_head rdllink; //事件就緒隊列 5 struct epitem *next; //用於主結構體中的鏈表 6 struct epoll_filefd ffd; //每個fd生成的一個結構 7 int nwait; 8 struct list_head pwqlist; //poll等待隊列 9 struct eventpoll *ep; //該項屬於哪個主結構體 10 struct list_head fllink; //鏈接fd對應的file鏈表 11 struct epoll_event event; //注冊的感興趣的事件,也就是用戶空間的epoll_event 12 }

1 struct eventpoll 2 { 3 spin_lock_t lock; //對本數據結構的訪問 4 struct mutex mtx; //防止使用時被刪除 5 wait_queue_head_t wq; //sys_epoll_wait() 使用的等待隊列 6 wait_queue_head_t poll_wait; //file->poll()使用的等待隊列 7 struct list_head rdllist; //事件滿足條件的鏈表 8 struct rb_root rbr; //用於管理所有fd的紅黑樹 9 struct epitem *ovflist; //將事件到達的fd進行鏈接起來發送至用戶空間 10 }
添加以及返回事件
通過epoll_ctl函數添加進來的事件都會被放在紅黑樹的某個節點內,所以,重復添加是沒有用的。當把事件添加進來的時候時候會完成關鍵的一步,那就是該事件都會與相應的設備(網卡)驅動程序建立回調關系,當相應的事件發生后,就會調用這個回調函數,該回調函數在內核中被稱為:ep_poll_callback,這個回調函數其實就所把這個事件添加到rdllist這個雙向鏈表中。一旦有事件發生,epoll就會將該事件添加到雙向鏈表中。那么當我們調用epoll_wait時,epoll_wait只需要檢查rdlist雙向鏈表中是否有存在注冊的事件,效率非常可觀。這里也需要將發生了的事件復制到用戶態內存中即可。
epoll_wait的工作流程:
- epoll_wait調用ep_poll,當rdlist為空(無就緒fd)時掛起當前進程,直到rdlist不空時進程才被喚醒。
- 文件fd狀態改變(buffer由不可讀變為可讀或由不可寫變為可寫),導致相應fd上的回調函數ep_poll_callback()被調用。
- ep_poll_callback將相應fd對應epitem加入rdlist,導致rdlist不空,進程被喚醒,epoll_wait得以繼續執行。
- ep_events_transfer函數將rdlist中的epitem拷貝到txlist中,並將rdlist清空。
- ep_send_events函數(很關鍵),它掃描txlist中的每個epitem,調用其關聯fd對用的poll方法。此時對poll的調用僅僅是取得fd上較新的events(防止之前events被更新),之后將取得的events和相應的fd發送到用戶空間(封裝在struct epoll_event,從epoll_wait返回)。
小結
表 1. select、poll和epoll三種I/O復用模式的比較( 摘錄自《linux高性能服務器編程》)
系統調用 |
select |
poll |
epoll |
事件集合 |
用哦過戶通過3個參數分別傳入感興趣的可讀,可寫及異常等事件 內核通過對這些參數的在線修改來反饋其中的就緒事件 這使得用戶每次調用select都要重置這3個參數 |
統一處理所有事件類型,因此只需要一個事件集參數。 用戶通過pollfd.events傳入感興趣的事件,內核通過 修改pollfd.revents反饋其中就緒的事件 |
內核通過一個事件表直接管理用戶感興趣的所有事件。 因此每次調用epoll_wait時,無需反復傳入用戶感興趣 的事件。epoll_wait系統調用的參數events僅用來反饋就緒的事件 |
應用程序索引就緒文件 描述符的時間復雜度 |
O(n) |
O(n) |
O(1) |
最大支持文件描述符數 |
一般有最大值限制 |
65535 |
65535 |
工作模式 |
LT |
LT |
支持ET高效模式 |
內核實現和工作效率 | 采用輪詢方式檢測就緒事件,時間復雜度:O(n) | 采用輪詢方式檢測就緒事件,時間復雜度:O(n) |
采用回調方式檢測就緒事件,時間復雜度:O(1) |
行文至此,想必各位都應該已經明了為什么epoll會成為Linux平台下實現高性能網絡服務器的首選I/O復用調用。
需要注意的是:epoll並不是在所有的應用場景都會比select和poll高很多。尤其是當活動連接比較多的時候,回調函數被觸發得過於頻繁的時候,epoll的效率也會受到顯著影響!所以,epoll特別適用於連接數量多,但活動連接較少的情況。
接下來,筆者將介紹一下epoll使用方式的注意點。
EPOLL的使用
文件描述符的創建

1 #include <sys/epoll.h> 2 int epoll_create ( int size );
在epoll早期的實現中,對於監控文件描述符的組織並不是使用紅黑樹,而是hash表。這里的size實際上已經沒有意義。
注冊監控事件

1 #include <sys/epoll.h> 2 int epoll_ctl ( int epfd, int op, int fd, struct epoll_event *event );

1 struct epoll_event 2 { 3 __unit32_t events; // epoll事件 4 epoll_data_t data; // 用戶數據 5 };

1 typedef union epoll_data 2 { 3 void* ptr; //指定與fd相關的用戶數據 4 int fd; //指定事件所從屬的目標文件描述符 5 uint32_t u32; 6 uint64_t u64; 7 } epoll_data_t;
epoll_wait函數

1 #include <sys/epoll.h> 2 int epoll_wait ( int epfd, struct epoll_event* events, int maxevents, int timeout );
EPOLLONESHOT事件
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!”。
接下來我們將上面程序的第11行做如下修改:

1 ev.events=EPOLLIN; //默認使用LT模式
編譯並運行,結果如下:
程序陷入死循環,因為用戶輸入任意數據后,數據被送入buffer且沒有被讀出,所以LT模式下每次epoll_wait都認為buffer可讀返回讀就緒。導致每次都會輸出”welcome to epoll's world!”。
程序二:

1 #include <stdio.h> 2 #include <unistd.h> 3 #include <sys/epoll.h> 4 5 int main(void) 6 { 7 int epfd,nfds; 8 struct epoll_event ev,events[5]; //ev用於注冊事件,數組用於返回要處理的事件 9 epfd = epoll_create(1); //只需要監聽一個描述符——標准輸入 10 ev.data.fd = STDIN_FILENO; 11 ev.events = EPOLLIN; //監聽讀狀態同時設置LT模式 12 epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev); //注冊epoll事件 13 for(;;) 14 { 15 nfds = epoll_wait(epfd, events, 5, -1); 16 for(int i = 0; i < nfds; i++) 17 { 18 if(events[i].data.fd==STDIN_FILENO) 19 { 20 char buf[1024] = {0}; 21 read(STDIN_FILENO, buf, sizeof(buf)); 22 printf("welcome to epoll's word!\n"); 23 } 24 } 25 } 26 }
編譯並運行,結果如下:
本程序依然使用LT模式,但是每次epoll_wait返回讀就緒的時候我們都將buffer(緩沖)中的內容read出來,所以導致buffer再次清空,下次調用epoll_wait就會阻塞。所以能夠實現我們所想要的功能——當用戶從控制台有任何輸入操作時,輸出”welcome to epoll's world!”
程序三:

1 #include <stdio.h> 2 #include <unistd.h> 3 #include <sys/epoll.h> 4 5 int main(void) 6 { 7 int epfd,nfds; 8 struct epoll_event ev,events[5]; //ev用於注冊事件,數組用於返回要處理的事件 9 epfd = epoll_create(1); //只需要監聽一個描述符——標准輸入 10 ev.data.fd = STDIN_FILENO; 11 ev.events = EPOLLIN|EPOLLET; //監聽讀狀態同時設置ET模式 12 epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev); //注冊epoll事件 13 for(;;) 14 { 15 nfds = epoll_wait(epfd, events, 5, -1); 16 for(int i = 0; i < nfds; i++) 17 { 18 if(events[i].data.fd==STDIN_FILENO) 19 { 20 printf("welcome to epoll's word!\n"); 21 ev.data.fd = STDIN_FILENO; 22 ev.events = EPOLLIN|EPOLLET; //設置ET模式 23 epoll_ctl(epfd, EPOLL_CTL_MOD, STDIN_FILENO, &ev); //重置epoll事件(ADD無效) 24 } 25 } 26 } 27 }
編譯並運行,結果如下:
程序依然使用ET,但是每次讀就緒后都主動的再次MOD IN事件,我們發現程序再次出現死循環,也就是每次返回讀就緒。但是注意,如果我們將MOD改為ADD,將不會產生任何影響。別忘了每次ADD一個描述符都會在epitem組成的紅黑樹中添加一個項,我們之前已經ADD過一次,再次ADD將阻止添加,所以在次調用ADD IN事件不會有任何影響。
程序四:

1 #include <stdio.h> 2 #include <unistd.h> 3 #include <sys/epoll.h> 4 5 int main(void) 6 { 7 int epfd,nfds; 8 struct epoll_event ev,events[5]; //ev用於注冊事件,數組用於返回要處理的事件 9 epfd = epoll_create(1); //只需要監聽一個描述符——標准輸入 10 ev.data.fd = STDOUT_FILENO; 11 ev.events = EPOLLOUT|EPOLLET; //監聽讀狀態同時設置ET模式 12 epoll_ctl(epfd, EPOLL_CTL_ADD, STDOUT_FILENO, &ev); //注冊epoll事件 13 for(;;) 14 { 15 nfds = epoll_wait(epfd, events, 5, -1); 16 for(int i = 0; i < nfds; i++) 17 { 18 if(events[i].data.fd==STDOUT_FILENO) 19 { 20 printf("welcome to epoll's word!\n"); 21 } 22 } 23 } 24 }
編譯並運行,結果如下:
這個程序的功能是只要標准輸出寫就緒,就輸出“welcome to epoll's world”。我們發現這將是一個死循環。下面具體分析一下這個程序的執行過程:
- 首先初始buffer為空,buffer中有空間可寫,這時無論是ET還是LT都會將對應的epitem加入rdlist,導致epoll_wait就返回寫就緒。
- 程序想標准輸出輸出”welcome to epoll's world”和換行符,因為標准輸出為控制台的時候緩沖是“行緩沖”,所以換行符導致buffer中的內容清空,這就對應第二節中ET模式下寫就緒的第二種情況——當有舊數據被發送走時,即buffer中待寫的內容變少得時候會觸發fd狀態的改變。所以下次epoll_wait會返回寫就緒。如此循環往復。
程序五:

1 #include <stdio.h> 2 #include <unistd.h> 3 #include <sys/epoll.h> 4 5 int main(void) 6 { 7 int epfd,nfds; 8 struct epoll_event ev,events[5]; //ev用於注冊事件,數組用於返回要處理的事件 9 epfd = epoll_create(1); //只需要監聽一個描述符——標准輸入 10 ev.data.fd = STDOUT_FILENO; 11 ev.events = EPOLLOUT|EPOLLET; //監聽讀狀態同時設置ET模式 12 epoll_ctl(epfd, EPOLL_CTL_ADD, STDOUT_FILENO, &ev); //注冊epoll事件 13 for(;;) 14 { 15 nfds = epoll_wait(epfd, events, 5, -1); 16 for(int i = 0; i < nfds; i++) 17 { 18 if(events[i].data.fd==STDOUT_FILENO) 19 { 20 printf("welcome to epoll's word!"); 21 } 22 } 23 } 24 }
編譯並運行,結果如下:
與程序四相比,程序五只是將輸出語句的printf的換行符移除。我們看到程序成掛起狀態。因為第一次epoll_wait返回寫就緒后,程序向標准輸出的buffer中寫入“welcome to epoll's world!”,但是因為沒有輸出換行,所以buffer中的內容一直存在,下次epoll_wait的時候,雖然有寫空間但是ET模式下不再返回寫就緒。回憶第一節關於ET的實現,這種情況原因就是第一次buffer為空,導致epitem加入rdlist,返回一次就緒后移除此epitem,之后雖然buffer仍然可寫,但是由於對應epitem已經不再rdlist中,就不會對其就緒fd的events的在檢測了。
程序六:

1 #include <stdio.h> 2 #include <unistd.h> 3 #include <sys/epoll.h> 4 5 int main(void) 6 { 7 int epfd,nfds; 8 struct epoll_event ev,events[5]; //ev用於注冊事件,數組用於返回要處理的事件 9 epfd = epoll_create(1); //只需要監聽一個描述符——標准輸入 10 ev.data.fd = STDOUT_FILENO; 11 ev.events = EPOLLOUT; //監聽讀狀態同時設置LT模式 12 epoll_ctl(epfd, EPOLL_CTL_ADD, STDOUT_FILENO, &ev); //注冊epoll事件 13 for(;;) 14 { 15 nfds = epoll_wait(epfd, events, 5, -1); 16 for(int i = 0; i < nfds; i++) 17 { 18 if(events[i].data.fd==STDOUT_FILENO) 19 { 20 printf("welcome to epoll's word!"); 21 } 22 } 23 } 24 }
編譯並運行,結果如下:
程序六相對程序五僅僅是修改ET模式為默認的LT模式,我們發現程序再次死循環。這時候原因已經很清楚了,因為當向buffer寫入”welcome to epoll's world!”后,雖然buffer沒有輸出清空,但是LT模式下只有buffer有寫空間就返回寫就緒,所以會一直輸出”welcome to epoll's world!”,當buffer滿的時候,buffer會自動刷清輸出,同樣會造成epoll_wait返回寫就緒。
程序七:

1 #include <stdio.h> 2 #include <unistd.h> 3 #include <sys/epoll.h> 4 5 int main(void) 6 { 7 int epfd,nfds; 8 struct epoll_event ev,events[5]; //ev用於注冊事件,數組用於返回要處理的事件 9 epfd = epoll_create(1); //只需要監聽一個描述符——標准輸入 10 ev.data.fd = STDOUT_FILENO; 11 ev.events = EPOLLOUT|EPOLLET; //監聽讀狀態同時設置LT模式 12 epoll_ctl(epfd, EPOLL_CTL_ADD, STDOUT_FILENO, &ev); //注冊epoll事件 13 for(;;) 14 { 15 nfds = epoll_wait(epfd, events, 5, -1); 16 for(int i = 0; i < nfds; i++) 17 { 18 if(events[i].data.fd==STDOUT_FILENO) 19 { 20 printf("welcome to epoll's word!"); 21 ev.data.fd = STDOUT_FILENO; 22 ev.events = EPOLLOUT|EPOLLET; //設置ET模式 23 epoll_ctl(epfd, EPOLL_CTL_MOD, STDOUT_FILENO, &ev); //重置epoll事件(ADD無效) 24 } 25 } 26 } 27 }
編譯並運行,結果如下:
程序七相對於程序五在每次向標准輸出的buffer輸出”welcome to epoll's world!”后,重新MOD OUT事件。所以相當於每次都會返回就緒,導致程序循環輸出。
經過前面的案例分析,我們已經了解到,當epoll工作在ET模式下時,對於讀操作,如果read一次沒有讀盡buffer中的數據,那么下次將得不到讀就緒的通知,造成buffer中已有的數據無機會讀出,除非有新的數據再次到達。對於寫操作,主要是因為ET模式下fd通常為非阻塞造成的一個問題——如何保證將用戶要求寫的數據寫完。
要解決上述兩個ET模式下的讀寫問題,我們必須實現:
- 對於讀,只要buffer中還有數據就一直讀;
- 對於寫,只要buffer還有空間且用戶請求寫的數據還未寫完,就一直寫。
ET模式下的accept問題
請思考以下一種場景:在某一時刻,有多個連接同時到達,服務器的 TCP 就緒隊列瞬間積累多個就緒連接,由於是邊緣觸發模式,epoll 只會通知一次,accept 只處理一個連接,導致 TCP 就緒隊列中剩下的連接都得不到處理。在這種情形下,我們應該如何有效的處理呢?
解決的方法是:解決辦法是用 while 循環抱住 accept 調用,處理完 TCP 就緒隊列中的所有連接后再退出循環。如何知道是否處理完就緒隊列中的所有連接呢? accept 返回 -1 並且 errno 設置為 EAGAIN 就表示所有連接都處理完。
關於ET的accept問題,這篇博文的參考價值很高,如果有興趣,可以鏈接過去圍觀一下。
ET模式為什么要設置在非阻塞模式下工作
因為ET模式下的讀寫需要一直讀或寫直到出錯(對於讀,當讀到的實際字節數小於請求字節數時就可以停止),而如果你的文件描述符如果不是非阻塞的,那這個一直讀或一直寫勢必會在最后一次阻塞。這樣就不能在阻塞在epoll_wait上了,造成其他文件描述符的任務飢餓。
epoll的使用實例
這樣的實例,網上已經有很多了(包括參考鏈接),筆者這里就略過了。
小結
LT:水平觸發,效率會低於ET觸發,尤其在大並發,大流量的情況下。但是LT對代碼編寫要求比較低,不容易出現問題。LT模式服務編寫上的表現是:只要有數據沒有被獲取,內核就不斷通知你,因此不用擔心事件丟失的情況。
ET:邊緣觸發,效率非常高,在並發,大流量的情況下,會比LT少很多epoll的系統調用,因此效率高。但是對編程要求高,需要細致的處理每個請求,否則容易發生丟失事件的情況。
從本質上講:與LT相比,ET模型是通過減少系統調用來達到提高並行效率的。
總結
epoll使用的梳理與總結到這里就告一段落了。限於篇幅原因,很多細節都被略過了。后面參考給出的鏈接,強烈推薦閱讀。疏謬之處,萬望斧正!
備注
本文有相當份量的內容參考借鑒了網絡上各位網友的熱心分享,特別是一些帶有完全參考的文章,其后附帶的鏈接內容更直接、更豐富,筆者只是做了一下歸納&轉述,在此一並表示感謝。
參考
《Linux高性能服務器編程》
《徹底學會使用epoll》(系列博文)
《epoll源碼分析(全) 》