epoll全面講解:從實現到應用

編程語言 Java Linux UNIX IT優就業 IT優就業 2017-09-09

epoll全面講解:從實現到應用

多路複用的適用場合

• 當客戶處理多個描述符時(例如同時處理交互式輸入和網絡套接口),必須使用I/O複用。

• 如果一個TCP服務器既要處理監聽套接口,又要處理已連接套接口,一般也要用到I/O複用。

• 如果一個服務器即要處理TCP,又要處理UDP,一般要使用I/O複用。

• 如果一個服務器要處理多個服務或多個協議,一般要使用I/O複用。

Select/poll/epoll差別

  1. Poll返回的時候用戶態需要輪詢判斷每個描述符的狀態,即使只有一個描述符就緒,也要遍歷整個集合。如果集合中活躍的描述符很少,遍歷過程的開銷就會變得很大,而如果集合中大部分的描述符都是活躍的,遍歷過程的開銷又可以忽略。epoll的實現中每次只遍歷活躍的描述符,在活躍描述符較少的情況下就會很有優勢,在代碼的分析過程中可以看到epoll的實現過於複雜並且其實現過程為實現線程安全需要同步處理(鎖),如果大部分描述符都是活躍的,遍歷這點區別相對於加鎖來說已經微不足道了,epoll的效率可能不如select或poll。

  2. 傳參方式不同

  • 支持的最大描述符不同,根本原因是內核管理每個文件句柄的數據結構不同,select能夠處理的最大fd無法超出FDSETSIZE,因為調用select傳入的參數fd_set是一個位數組,數組大小就是FDSETSIZE默認為1024,所以調用方式限制了併發量。Poll是利用一個數組傳入的參數,沒有最大限制。Epoll不需要每次都傳入,因為會調用epoll_ctl添加。

  • 使用方式不同,select調用每次都由於內核會對數組進行在線修改,應用程序下次調用select前不得不重置這三個fdset,而poll比他聰明點,將句柄與事件綁定在一起通過一個struct pollfd實現,返回時是通過其revets實現,所以不需要重置該結構,直接傳遞就行,epoll不需要傳遞。

  • 支持的事件類型數不同:select應為沒有將句柄與事件進行綁定,所以fd_set僅僅是個文件描述符集合,因此需要三個fd_set分別傳入可讀可寫及異常事件,這使得他不能處理更多類型的事件,而poll採用的pollfd中event需要使用64個bit,epoll採用的 epoll_event則需要96個bit,支持更多的事件類型。

  1. Poll每次需要從用戶態將所有的句柄複製到內核態,如果以萬計的句柄會導致每次都要copy幾十幾百KB的內存到內核態,非常低效。使用epoll時你只需要調用epoll_ctl事先添加到對應紅黑樹,真正用epoll_wait時不用傳遞socket句柄給內核,節省了拷貝開銷。

  2. 內核實現上:輪流調用所有fd對應的poll(把current掛到各個fd對應的設備等待隊列上),等到有事件發生的時候會通知他,在調用結束後,又把進程從各個等待隊列中刪除。在 epoll_wait時,把current輪流的加入fd對應的設備等待隊列,在設備等待隊列醒來時調用一個回調函數(當然,這就需要“喚醒回調”機制),把產生事件的fd歸入一個鏈表,然後返回這個鏈表上的fd。

  3. Select 不是線程安全的,epoll是線程安全的,內部提供了鎖的保護,就算一個線程在epoll_wait的時候另一個線程epoll_ctl也沒問題。

  4. 內核使用了slab機制,為epoll提供了快速的數據結構。

  5. Select和poll相當於epoll的LT模式,不支持ET模式,epoll支持更為該高效的ET模式 (ET和LT差別見下文)

Epoll工作原理

epoll_create

操作系統在啟動時會註冊一個evnetpollfs的文件系統,對應的file operations只是實現了poll跟release操作,然後初始化一些創建一個slab緩存,為了後面分配epitem和eppoll_entry, 初始化遞歸檢查隊列。

創建一個eventpoll對象, 裡邊有用戶信息,是不是root,最大監聽fd數目,等待隊列,就緒鏈表,紅黑樹的頭結點等信息,並且創建一個fd即epollfd跟fd對應的file對象,

而eventpoll對象保存在struct file結構的private指針中,為方便從fd得到eventpoll對象,並且返回,

epoll_ctl

將epoll_event結構拷貝到內核空間中;

並且判斷加入的fd是否支持poll結構;

並且從epfd->file->privatedata獲取event_poll對象,根據op區分是添加刪除還是修改;

首先在eventpoll結構中的紅黑樹查找是否已經存在了相對應的fd,沒找到就支持插入操作,否則報重複的錯誤;

相對應的修改,刪除比較簡單就不囉嗦了

插入時會進行上鎖。

插入操作時,會創建一個與fd對應的epitem結構,並且初始化相關成員,比如保存監聽的fd跟file結構之類的,

最後調用加入的fd的file operation->poll函數(最後會調用poll_wait操作)用於來將當前進程註冊到設備的等待隊列:在其內傳遞poll_table變量調用poll_wait,poll_table會提供一個函數指針,事實上調用的就是這個函數指針指向的對象,該函數就是將當前進行掛在設備的等待隊列中,並指定設備事件就緒時的回調函數callback,該callback的實現就是將該epitem放在rdlist鏈表中。

最後將epitem結構添加到紅黑樹中

epoll_wait

計算睡眠時間(如果有),判斷eventpoll對象的鏈表是否為空,不為空那就幹活不睡明.並且初始化一個等待隊列,把自己掛上去,設置自己的進程狀態為可睡眠狀態.判斷是否有信號到來(有的話直接被中斷醒來,),如果啥事都沒有那就調用schedule_timeout進行睡眠,如果超時或者被喚醒,首先從自己初始化的等待隊列刪除 ,然後開始拷貝資源給用戶空間了。

拷貝資源則是先把就緒事件鏈表轉移到中間鏈表,然後挨個遍歷拷貝到用戶空間,

並且挨個判斷其是否為水平觸發,是的話再次插入到就緒鏈表。

具體實現由很多細節: 如果拷貝rdlist過程中又有事件就緒了怎麼辦,如果epollfd被另一個epoll監聽會不會循環喚醒,lt什麼時候會從rdlist中刪除等,見下文?

EPOll的ET與LT

內核實現:

只是在從rdlist中返回的時候有區別,內核首先會將rdlist拷貝到一個臨時鏈表txlist, 然後如果是LT事件並且事件就緒的話fd被重新放回了rdllist。那麼下次epoll_wait當然會又把rdllist裡的fd拿來拷給用戶了。舉個例子。假設一個socket,只是connect,還沒有收發數據,那麼它的poll事件掩碼總是有POLLOUT的,每次調用epoll_wait總是返回POLLOUT事件,因為它的fd就總是被放回rdllist;假如此時有人往這個socket裡寫了一大堆數據,造成socket塞住,fd不會放回rdllist,epoll_wait將不會再返回用戶POLLOUT事件。如果我們給這個socket加上EPOLLET,然後connect,沒有收發數據,epoll_wait只會返回一次POLLOUT通知給用戶(因為此fd不會再回到rdllist了),接下來的epoll_wait都不會有任何事件通知了。

注意上面LT fd拷貝回rdlist並不是向用戶處理完之後發生的,而是向用戶拷貝完之後直接複製到rdlist中,那麼如果用戶消費這個事件使事件不就緒了怎麼辦,比如說本來是可讀的,返回給用戶,用戶讀到不可讀為止,繼續調用epoll_wait 返回rdlist,則發現不可讀,事實上每次返回之前會以NULL繼續調用poll,判斷事件是否變化,平時調用poll會傳遞個poll_table變量,就進行添加到等待隊列中,而此時不需要添加,只是判斷一下狀態,如果rdlist中狀態變化了,就不會給用戶返回了。

觸發方式:

根據對兩種加入rdlist途徑的分析,可以得出ET模式下被喚醒(返回就緒)的條件為:

對於讀取操作:

(1) 當buffer由不可讀狀態變為可讀的時候,即由空變為不空的時候。

(2) 當有新數據到達時,即buffer中的待讀內容變多的時候。

(3) 當buffer中有數據可讀(即buffer不空)且用戶對相應fd進行epoll_mod IN事件時

對於寫操作:

(1) 當buffer由不可寫變為可寫的時候,即由滿狀態變為不滿狀態的時候。

(2) 當有舊數據被髮送走時,即buffer中待寫的內容變少得時候。

(3) 當buffer中有可寫空間(即buffer不滿)且用戶對相應fd進行epoll_mod OUT事件時

對於LT模式則簡單多了,除了上述操作為讀了一條事件就緒就一直通知。

ET比LT高效的原因:

經過上面的分析,可得到LT每次都需要處理rdlist,無疑向用戶拷貝的數據變多,且epoll_wait循環也變多,性能自然下降了。

另外一方面從用戶角度考慮,使用ET模式,它可以便捷的處理EPOLLOUT事件,省去打開與關閉EPOLLOUT的epoll_ctl(EPOLL_CTL_MOD)調用。從而有可能讓你的性能得到一定的提升。例如你需要寫出1M的數據,寫出到socket 256k時,返回了EAGAIN,ET模式下,當再次epoll返回EPOLLOUT事件時,繼續寫出待寫出的數據,當沒有數據需要寫出時,不處理直接略過即可。而LT模式則需要先打開EPOLLOUT,當沒有數據需要寫出時,再關閉EPOLLOUT(否則會一直返回EPOLLOUT事件),而調用epoll_ctl是系統調用,要陷入內核並且需要操作加鎖紅黑樹,總體來說,ET處理EPOLLOUT方便高效些,LT不容易遺漏事件、不易產生bug,如果server的響應通常較小,不會觸發EPOLLOUT,那麼適合使用LT,例如redis等,這種情況下甚至不需要關注EPOLLOUT,流量足夠小的時候直接發送,如果發送不完在進行關注EPOLLOUT,發送完取消關注就行了,可以進行稍微的優化。而nginx作為高性能的通用服務器,網絡流量可以跑滿達到1G,這種情況下很容易觸發EPOLLOUT,則使用ET。

實際應用:

當epoll工作在ET模式下時,對於讀操作,如果read一次沒有讀盡buffer中的數據,那麼下次將得不到讀就緒的通知,造成buffer中已有的數據無機會讀出,除非有新的數據再次到達。對於寫操作,主要是因為ET模式下fd通常為非阻塞造成的一個問題——如何保證將用戶要求寫的數據寫完。

要解決上述兩個ET模式下的讀寫問題,我們必須實現:

a. 對於讀,只要buffer中還有數據就一直讀;

b. 對於寫,只要buffer還有空間且用戶請求寫的數據還未寫完,就一直寫。

使用這種方式一定要使每個連接的套接字工作於非阻塞模式,因為讀寫需要一直讀或寫直到出錯(對於讀,當讀到的實際字節數小於請求字節數時就可以停止),而如果你的文件描述符如果不是非阻塞的,那這個一直讀或一直寫勢必會在最後一次阻塞。這樣就不能在阻塞在epoll_wait上了,造成其他文件描述符的任務餓死。

所以也就常說“ET需要工作在非阻塞模式”,當然這並不能說明ET不能工作在阻塞模式,而是工作在阻塞模式可能在運行中會出現一些問題。

ET模式下的accept

考慮這種情況:多個連接同時到達,服務器的 TCP 就緒隊列瞬間積累多個就緒

連接,由於是邊緣觸發模式,epoll 只會通知一次,accept 只處理一個連接,導致 TCP 就緒隊列中剩下的連接都得不到處理。

解決辦法是用 while 循環抱住 accept 調用,處理完 TCP 就緒隊列中的所有連接後再退出循環。如何知道是否處理完就緒隊列中的所有連接呢? accept 返回 -1 並且 errno 設置為 EAGAIN 就表示所有連接都處理完。

的正確使用方式為:

while ((conn_sock = accept(listenfd,(struct sockaddr *) &remote, (size_t *)&addrlen)) > 0) {

handle_client(conn_sock);

}

if (conn_sock == -1) {

if (errno != EAGAIN && errno != ECONNABORTED

&& errno != EPROTO && errno != EINTR)

perror("accept");

}

擴展:服務端使用多路轉接技術(select,poll,epoll等)時,accept應工作在非阻塞模式。

原因:如果accept工作在阻塞模式,考慮這種情況: TCP 連接被客戶端夭折,即在服務器調用 accept 之前(此時select等已經返回連接到達讀就緒),客戶端主動發送 RST 終止連接,導致剛剛建立的連接從就緒隊列中移出,如果套接口被設置成阻塞模式,服務器就會一直阻塞在 accept 調用上,直到其他某個客戶建立一個新的連接為止。但是在此期間,服務器單純地阻塞在accept 調用上(實際應該阻塞在select上),就緒隊列中的其他描述符都得不到處理。

解決辦法是把監聽套接口設置為非阻塞, 當客戶在服務器調用 accept 之前中止

某個連接時,accept 調用可以立即返回 -1, 這時源自 Berkeley 的實現會在內核中處理該事件,並不會將該事件通知給 epoll,而其他實現把 errno 設置為 ECONNABORTED 或者 EPROTO 錯誤,我們應該忽略這兩個錯誤。(具體可參看UNP v1 p363)

EPOLlONSHOT

在一些監聽事件和讀取分開的場景中,比如說在主線程中監聽,在子線程中接收數據並處理,這時候會出現兩個線程同時操作一個socket的局面,比如說主線程監聽到事件交由線程1處理,還未處理完又有事件到達,主線程交由線程2處理,這就導致數據不一致,一般情況下需要在該文件描述符上註冊EPOLLONESHOT事件,操作系統最多觸發其上註冊的一個可讀可寫或異常事件,且只觸發一次,除非我們使用epoll_ctl函數重置該EPOLLONESHOT事件。反過來思考也一樣,註冊了該事件的線程處理完數據後必須重新註冊,否則下次不會再次觸發。

但是有一個缺陷,這樣的話會每次都調用epoll_ctrl陷入內核,並且epoll為保證線程安全會使用了加鎖紅黑樹,這樣會嚴重影響性能,此時就需要換一種思路,在應用層維護一個原子整數來記錄當前句柄是否有線程在處理,每次有事件到來得時候會檢查這個原子整數,如果在處理就不會分配線程處理,否則會分配線程,這樣就避免了陷入內核,使用epoll_data來存儲這個原子整數就行。

對於使用EPOLLSHOT方式來防止數據不一致既可以使用ET也可以使用LT,因為他防止了再次觸發,但是使用原子整數的方式只能使用ET模式,他不是防止再次觸發,而是防止被多個線程處理,在有些情況下可能計算的速度跟不上io湧來的速度,就是無法及時接收緩衝區的內容,此時接收線程和主線程是分開的,如果使用LT的話主線程會一直觸發事件,導致busy-loop。 而使用ET觸發只有在事件到來得時候會觸發,緩衝區有內容並不會觸發,觸發的次數就變少了,雖然主線程還是可能空轉(fd有事件到來,但已被線程處理,此時不需要處理,繼續epoll_wait就好),但這樣空轉比屢次調用epoll_ctl的概率小多了。

EPOLL的誤區

1. Epoll ET模式只支持非阻塞句柄?

其實也支持阻塞句柄,只不過根據應用的使用場景,一般只適合非阻塞使用,參見上文“EPOLL ET與LT的實際應用”

2. Epoll的共享內存?

Epoll相對於select高效是因為從內核拷貝就緒文件描述符的時候用了共享內存? 這是不對的,實現的時候只是用了使用了copy_from_user跟__put_user進行內核跟用戶虛擬空間數據交互,並沒有共享內存的api。

問題集錦

epoll需要再次op->poll的原因

因為等待隊列中的有事件後會喚醒所有的進程,可能有的進程位於對頭把事件消費後就直接刪除了這個事件,後面的進程喚醒後可能再沒有事件消費了,所以需要再次判斷poll,如果事件還在則加入rdlist中。當然消費完事件後不一定會刪除,等待隊列中可以通過flag選項設置消費的方式。

Epoll每次都將txlist中的LT事件不等用戶消費就直接返回給rdlist,那麼在用戶消費了該事件後,導致事件不就緒,再次調用epoll_wait,epoll_wait還會返回rdlist嗎?

不會再次返回,因為在返回就緒列表之前會還調用一次revents = epi->ffd.file->f_op->poll(epi->ffd.file, NULL) 來判斷事件,如果事件發生了變化,就不在返回。

內核的等待隊列:

內核為了支持對設備的阻塞訪問,就需要設計一個等待隊列,等待隊列中是一個個進程,當設備事件就緒後會喚醒等待隊列中的進程來消費事件。但是在使用select監聽非阻塞的句柄時候,這個隊列不是用來實現非阻塞,而是實現狀態的等待,即等待某個可讀可寫事件發生後通知監聽的進程

內核的 poll技術就是為了poll/select設計的?

每個設備的驅動為了支持操作系統虛擬文件系統對其的使用需要提供一系列函數,比如說read,write等,其中poll就是其中一個函數,為了select,poll實現,用來查詢設備是否可讀或可寫,或是否處於某種特殊狀態。

EventPoll的兩個隊列

Evnetpoll中有兩個等待隊列,

wait_queue_head_t wq;

wait_queue_head_t poll_wait;

前者用於調用epoll_wait()時, 我們就是"睡"在了這個等待隊列上...

後者用於這個用於epollfd本事被poll的時候... 也就是說epollfd被其他epoll監視,調用其file->poll() 時。

對於本epoll監視的句柄有消息的時候會向wq消息隊列進行wakeup,同時對於poll_wait也會進行wakeup

EventPollfs實現的file opetion

只實現了poll和realse,由於epoll自身也是文件系統,其描述符也可以被poll/select/epoll監視,因此需要實現poll方法,具體就是ep_eventpoll_poll方法,他內部實現是將監聽當前epollfd的線程插入到自己的poll_wait隊列中,判斷自己接聽的句柄是否有事件發生,如果有的話需要將信息返回給監聽epollfd的epoll_wait, 具體方法是然後掃描就緒的文件列表, 調用每個文件上的poll 檢測是否真的就緒, 然後複製到用戶空間,但是文件列表中有可能有epoll文件, 調用poll的時候有可能會產生遞歸, 所以用ep_call_nested 包裝一下, 防止死循環和過深的調用。具體參見問題遞歸深度檢測(ep_call_nested)

Epoll的線程安全問題

當一個線程阻塞在epoll_wait()上的時候,其他線程向其中添加新的文件描述符是沒問題的,如果這個文件描述符就緒的話,阻塞線程的epoll_wait()會被喚醒。但是如果正在監聽的某文件描述符被其他線程關閉的話詳表現是未定義的。在有些 UNIX系統下,select會解除阻塞返回,而文件描述符會被認為就緒,然而對這個文件描述符進行IO操作會失敗(除非這個文件描述符又被分配了),在Linux下,另一個線程關閉文件描述符沒有任何影響。但不管怎樣,應當儘量壁面一個線程關閉另一個線程在監聽的文件描述符。

遞歸深度檢測(ep_call_nested)

epoll本身也是文件,也可以被poll/select/epoll監視,如果epoll之間互相監視就有可能導致死循環。epoll的實現中,所有可能產生遞歸調用的函數都由函數ep_call_nested進行包裹,遞歸調用過程中出現死循環或遞歸過深就會打破死循環和遞歸調用直接返回。該函數的實現依賴於一個外部的全局鏈表nested_call_node(不同的函數調用使用不同的節點),每次調用可能發生遞歸的函數(nproc)就向鏈表中添加一個包含當前函數調用上下文ctx(進程,CPU,或epoll文件)和處理的對象標識cookie的節點,通過檢測是否有相同的節點就可以知道是否發生了死循環,檢查鏈表中同一上下文包含的節點個數就可以知道遞歸的深度。

為什麼需要創建一個文件系統:

一是可以在內核維護一些信息,這些信息在多次epoll_wait之間是保持的(保存的是eventpoll結構)第二點是epoll本身也可以被poll/epoll

兩個回調函數

Epoll向等待隊列有兩個函數交互,分別是調用對應設備的poll函數,在poll函數中調用ep_ptable_queue_proc函數,將當前進程插入到等待隊列,指定ep_poll_callback為喚醒時的回調函數。Ep_poll_callback實現將當前的句柄複製到rdlist並wakeup,eventpoll的wq等待隊列。

文章摘自博客園

瞭解更多幹貨:

IT職業在線教育:http://xue.ujiuye.com/

大數據時代下做java開發工程師:http://www.ujiuye.com/zt/java/?wt.bd=zy35844tt

相關推薦

推薦中...