1、select、poll的些許缺點
先回憶下select和poll的接口
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
這兩個多路復用實現的特點是:
- 每次調用select和poll都要把用戶關心的事件集合(select為readfds,writefds,exceptfds集合,poll為fds結構體數組)從用戶空間到內核空間。
- 如果某一時間段內,只有少部分事件是活躍的(用戶關心的事件集合只有少部分事件會發生),會浪費cpu在對無效事件輪詢上,使得效率較低,比如,用戶關心1024個tcp socket的讀事件,當是,每次調用select或poll時只有1個tcp鏈接是活躍的,那么對其他1023個事件的輪詢是沒有必要的。
select支持的文件描述符數量較小,一般只有1024,poll雖然沒有這個限制,但基於上面兩個原因,poll和select存在同樣一個缺點,就是包含大量文件描述符的數組被整體復制於用戶態和內核的地址空間之間,而且不論這些文件描述符是否就緒,每次都會輪詢所有描述符的狀態,使得他們的開銷隨着文件描述符數量的增加而線性增大。epoll針對這幾個缺點進行了改進,不再像select和poll那樣,每次調用select和poll都把描述符集合拷貝到內核空間,而是一次注冊永久使用;另一方面,epoll也不會對每個描述符都輪詢時間是否發生,而是只針對事件已經發生的文件描述符進行資源搶占(因為同一個描述符資源(如可讀或可寫)可能阻塞了多個進程,調用epoll的進程需要與這些進程搶占該相應資源)。下面記錄一下自己對epoll的學習和理解。
2、epoll的幾個接口
上面說到每次調用select和poll都把描述符集合拷貝到內核空間,這是因為select和poll注冊事件和監聽事件是綁定在一起的,為甚這么說呢,我們看select和poll的編程模式就明白了:
while(true){ select(maxfd+1,readfds,writefds,execpfds,timeout)/poll(pollfd,nfds,timeout); }
在I/O多路復用之select中說到了select的實現,調用select時就會進行一次用戶空間到內核空間的拷貝。epoll的改進其實就是把注冊事件和監聽事件分開了,epoll使用了一個特殊的文件來管理用戶關心的事件集合,這個文件存在於內核之中,由特殊的數據結構和一組操作構成,這樣的話,用戶就可以提前告知內核自己關心的事件,然后再進行監聽,因此,就只需要一次用戶空間到內核空間的拷貝了。其中管理事件集合的文件通過epoll_create創建,注冊用戶行為通過epoll_ctl實現,監聽通過epoll_wait實現。那么編程模型大概是這個樣子:
epoll_fd=epoll_create(size); epoll_ctl(epoll_fd,operation,fd,event); while(true){ epoll_wait(epoll_fd,events,max_events,timeout); }
2.1、epoll_create接口
#include <sys/epoll.h>
int epoll_create(int size);
epoll_create創建epoll文件,其返回epoll的句柄,size用來告訴內核監聽文件描述符的最大數目,這個參數不同於select()中的第一個參數(給出最大監聽的fd+1的值)。需要注意的是,當創建好epoll句柄后,它會占用一個fd值,在linux下如果查看/proc/進程id/fd/,能夠看到這個fd,所以在使用完epoll后,必須調用close()關閉,否則可能導致fd被耗盡。(摘自epoll精髓)
epoll_create會在內核初始化完成epoll所需的數據結構,其中一個關鍵的結構就是rdlist,表示就緒的文件描述符鏈表,epoll_wait函數就是直接檢查該鏈表,從而搶占准備好的事件;另一個關鍵的結構是一顆紅黑樹,這棵樹專門用於管理用戶關心的文件描述符集合。
注:關於epoll文件的核心數據結構以及epoll_create的源碼請參考這兩份資料
2.2、epoll_ctl接口
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll_ctl用於用戶告知內核自己關心哪個描述符(fd)的什么事件(event),
- epfd,使用epoll_create函數創建的epoll句柄,epfd文件描述符對應的結構中,有一顆紅黑樹,專門用於管理用戶關心的事件集合。
- op,用於指定用戶行為,op參數有三種取值:fd,用戶關心的文件描述符
- EPOLL_CTL_ADD,注冊新的fd到epfd中;
- EPOLL_CTL_MOD,修改已注冊fd的事件;
- EPOLL_CTL_DEL,從epfd中刪除一個fd;
- event,用戶關心的事件(讀,寫)
參數event的結構如下:
struct epoll_event { __uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable,內核會修改該屬性 */ };
events可以是以下幾個宏的集合:
- EPOLLIN ,表示對應的文件描述符可以讀(包括對端SOCKET正常關閉);
- EPOLLOUT,表示對應的文件描述符可以寫;
- EPOLLPRI,表示對應的文件描述符有緊急的數據可讀(這里應該表示有帶外數據到來);
- EPOLLERR,表示對應的文件描述符發生錯誤;
- EPOLLHUP,表示對應的文件描述符被掛起;
- EPOLLET,將EPOLL設為邊緣觸發(Edge Triggered)模式,這是相對於水平觸發(Level Triggered)來說的。
- EPOLLONESHOT,只監聽一次事件,當監聽完這次事件之后,如果還需要繼續監聽這個socket的話,需要再次把這個socket加入到EPOLL隊列里
2.2.1、EPOLL_CTL_ADD
重點說一下這個取值,當op=EPOLL_CTL_ADD時,epoll_ctl主要做了四件事:
- 把當前文件描述符及其對應的事件(fd,epoll_event)加入紅黑樹,便於內核管理
- 注冊設備驅動poll的回調函數ep_ptable_queue_proc,當調用f_op->poll()時,最終會調用該回調函數ep_ptable_queue_proc()
- 在ep_ptable_queue_proc回調函數中,注冊回調函數ep_poll_callback,ep_poll_callback表示當描述符fd上相應的事件發生時該如何告知進程。
- 在ep_ptable_queue_proc回調函數中,檢測是文件描述符fd對應的設備的epoll_event事件是否發生,如果發生則把fd及其epoll_event加入上面提到的就緒隊列rdlist中
注:關於epoll_ctl、ep_ptable_queue_proc、ep_poll_callback的原理及源碼請參考這兩份資料
2.3、epoll_wait接口
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
- epfd,使用epoll_create函數創建的epoll句柄,epfd文件描述符對應的結構中,有一顆紅黑樹,專門用於管理用戶關心的事件集合。
- events,傳出參數,表示發生的事件
- maxevents,傳入參數,表示events數組的最大容量,其值不能超過epoll_create函數的參數size
- timeout,0,不阻塞;整數,阻塞timeout時間;負數,無限阻塞
epoll_wait函數的原理就是去檢查上面提到的rdlist鏈表中每個結點,rdlist的每一個結點能夠索引到監聽的文件描述符,就可以調用該文件描述符對應設備的poll驅動函數f_op->poll,用以檢查該設備是否可用。這里有個問題需要思考一下,既然rdlist就表示就緒的事件,也就是設備對應的資源可用了,為什么還要進行檢查?這是因為設備的某個資源可能被多個進程等待,當設備資源准備好后,設備會喚醒阻塞在這個資源上的所有進程,當前調用epoll_wait的進程未必能搶占這個資源,所以需要再調用檢查一次資源是否可用,以防止被其他進程搶占而導致再次不可用,檢查的方法就是調用fd設備的驅動f_op->poll。
這也是為什么epoll效率可能比較高的原因,epoll每次只檢查已經就緒的設備,不像select、poll,不管有沒有就緒,都去檢查。
注:關於epoll_wait的原理及源碼請參考這兩份資料
3、epoll的兩種觸發模式ET<
二者的差異在於level-trigger模式下只要某個socket處於readable/writable狀態,無論什么時候進行epoll_wait都會返回該socket;而edge-trigger模式下只有某個socket從unreadable變為readable或從unwritable變為writable時,epoll_wait才會返回該socket,et模式注重的是狀態發生改變的時候才觸發。下面兩幅圖清晰反映了二者區別,這兩幅圖摘自Epoll在LT和ET模式下的讀寫方式
在ET模式下,在使用epoll_ctl注冊文件描述符的事件時,應該把描述符設置為非阻塞,為什么呢?以上面左邊這幅圖為例,當數據到來之后,該socket實例從不可讀狀態邊為可讀狀態,從該socket讀取一部分數據后,再次調用epoll_wait,由於socket的狀態沒有發生改變(buffer上一次空到有數據可讀觸發了et,而這一次buffer還有數據可讀,狀態沒改變),所以該次調用epoll_wait並不會返回這個socket的可讀事件,而且之后也不會再發生改變,這個socket實例將永遠也得不到處理。這就是為什么將監聽的描述符設置為非阻塞的原因。
使用ET模式時,正確的讀寫方式應該是這樣的:
設置監聽的文件描述符為非阻塞 while(true){ epoll_wait(epoll_fd,events,max_evens); 讀,只要可讀,就一直讀,直到返回0,或者-1,errno=EAGAIN/EWOULDBLOCK }
正確的寫方式應該是這樣的:
設置監聽的文件描述符為非阻塞 while(true){ epoll_wait(epoll_fd,events,max_evens); 寫,只要可寫,就一直寫,直到返回0,或者-1,errno=EAGAIN/EWOULDBLOCK }
4、兩個問題
使用單進程單線程IO多路復用,服務器端該如何正確使用accept函數?
應該將監聽的socket實例設置為非阻塞。
使用io多路復用時,一般會把監聽連接的socket實例listen_fd交給select、poll或epoll管理,如果使用阻塞模式,假設,select、poll或epoll調用返回時,有大量描述符的讀或寫事件准備好了,而且listen_fd也可讀,
我們知道,從select、poll或epoll返回到調用accept接收新連接是有一個時間差的,如果這個時間內,發起請求的一端主動發送RST復位請求,服務器會把該連接從ACCEPT隊列(socket原理詳解,3.6節)中取出,並把該連接復位,這個時候再調用accept接收連接時,服務器將被阻塞,那其他的可讀可寫的描述符將得不到處理,直到有新連接時,accept才得以返回,才能去處理其他早已准備好的描述符。所以應該將listen_fd設置為非阻塞。
騰訊后台開發面試題。使用Linux epoll模型,LT觸發模式,當socket可寫時,會不停的觸發socket可寫的事件,但並不總是需要寫,該如何處理?
第一種最普遍的方式,步驟如下:
- 需要向socket寫數據的時候才把socket加入epoll,等待可寫事件。
- 接受到可寫事件后,調用write或者send發送數據,直到數據寫完。
- 把socket移出epoll。
這種方式的缺點是,即使發送很少的數據,也要把socket加入epoll,寫完后在移出epoll,有一定操作代價。
一種改進的方式,步驟如下:
- 設置socket為非阻塞模式
- 調用write或者send發送數據,直到數據寫完
- 如果返回EAGAIN,把socket加入epoll,在epoll的驅動下寫數據,全部數據發送完畢后,再移出epoll。
這種方式的優點是:數據不多的時候可以避免epoll的事件處理,提高效率。
參考資料:
Epoll在LT和ET模式下的讀寫方式(搞不懂這兩個誰是原創,很多同樣的博文,都標志着原創的字樣)