天天看點

epoll詳解

  epoll在核心中維護一個事件表,提供一個獨立的系統調用poll_ctl來控制往其中添加删除修改事件,epoll_wait可從核心事件表中直接取得使用者注冊事件,無需反複從使用者空間讀這些事件,無需掃描整個檔案描述符集合來檢測哪些是就緒事件,其參數events僅用來傳回就緒的事件,使得索引的就緒檔案描述符時間複雜度達到了o(1)。

建立epoll描述符==epoll_create()

epoll_ctrl(epoll描述符,添加或者删除所有待監控的連接配接)

傳回的活躍連接配接 ==epoll_wait( epoll描述符 )

      與select相比,epoll厘清了頻繁調用和不頻繁調用的操作。例如,epoll_ctrl是不太頻繁調用的,而epoll_wait是非常頻繁調用的。這時,epoll_wait卻幾乎沒有入參,這比select的效率高出一大截,而且,它也不會随着并發連接配接的增加使得入參越發多起來,導緻核心執行效率下降。

      要深刻了解epoll,首先得了解epoll的三大關鍵要素:mmap、紅黑樹、連結清單。

      epoll是通過核心與使用者空間mmap同一塊記憶體實作的。mmap将使用者空間的一塊位址和核心空間的一塊位址同時映射到相同的一塊實體記憶體位址(不管是使用者空間還是核心空間都是虛拟位址,最終要通過位址映射映射到實體位址),使得這塊實體記憶體對核心和對使用者均可見,減少使用者态和核心态之間的資料交換。核心可以直接看到epoll監聽的句柄,效率高。

      紅黑樹将存儲epoll所監聽的套接字。上面mmap出來的記憶體如何儲存epoll所監聽的套接字,必然也得有一套資料結構,epoll在實作上采用紅黑樹去存儲所有套接字,當添加或者删除一個套接字時(epoll_ctl),都在紅黑樹上去處理,紅黑樹本身插入和删除性能比較好,時間複雜度o(logn)。

epoll詳解

      通過epoll_ctl函數添加進來的事件都會被放在紅黑樹的某個節點内,是以,重複添加是沒有用的。當把事件添加進來的時候時候會完成關鍵的一步,那就是該事件都會與相應的裝置(網卡)驅動程式建立回調關系,當相應的事件發生後,就會調用這個回調函數,該回調函數在核心中被稱為:ep_poll_callback,這個回調函數其實就所把這個事件添加到rdllist這個雙向連結清單中。一旦有事件發生,epoll就會将該事件添加到雙向連結清單中。那麼當我們調用epoll_wait時,epoll_wait隻需要檢查rdlist雙向連結清單中是否有存在注冊的事件,效率非常可觀。這裡也需要将發生了的事件複制到使用者态記憶體中即可。

epoll詳解

   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傳回)。     

系統調用

select

poll

epoll

事件集合

用哦過戶通過3個參數分别傳入感興趣的可讀,可寫及異常等事件

核心通過對這些參數的線上修改來回報其中的就緒事件

這使得使用者每次調用select都要重置這3個參數

統一處理所有事件類型,是以隻需要一個事件集參數。

使用者通過pollfd.events傳入感興趣的事件,核心通過

修改pollfd.revents回報其中就緒的事件

核心通過一個事件表直接管理使用者感興趣的所有事件。

是以每次調用epoll_wait時,無需反複傳入使用者感興趣

的事件。epoll_wait系統調用的參數events僅用來回報就緒的事件

應用程式索引就緒檔案

描述符的時間複雜度

o(n)

o(1)

最大支援檔案描述符數

一般有最大值限制

65535

工作模式

lt

支援et高效模式

核心實作和工作效率

采用輪詢方式檢測就緒事件,時間複雜度:o(n)

采用回調方式檢測就緒事件,時間複雜度:o(1)

  尤其是當活動連接配接比較多的時候,回調函數被觸發得過于頻繁的時候,epoll的效率也會受到顯著影響!是以,epoll特别适用于連接配接數量多,但活動連接配接較少的情況。

 在epoll早期的實作中,對于監控檔案描述符的組織并不是使用紅黑樹,而是hash表。這裡的size實際上已經沒有意義

函數說明:

     fd:要操作的檔案描述符

     op:指定操作類型

操作類型:

     epoll_ctl_add:往事件表中注冊fd上的事件

     epoll_ctl_mod:修改fd上的注冊事件

     epoll_ctl_del:删除fd上的注冊事件

     event:指定事件,它是epoll_event結構指針類型

     epoll_event定義:

結構體說明:

     events:描述事件類型,和poll支援的事件類型基本相同(兩個額外的事件:epollet和epolloneshot,高效運作的關鍵)

     data成員:存儲使用者資料

函數說明:(隻用于輸出檢測到就緒的事件,不想select和poll既用于輸入有用于輸出)

     傳回:成功時傳回就緒的檔案描述符的個數,失敗時傳回-1并設定errno

     timeout:指定epoll的逾時時間,機關是毫秒。當timeout為-1是,epoll_wait調用将永遠阻塞,直到某個時間發生。當timeout為0時,epoll_wait調用将立即傳回。

     maxevents:指定最多監聽多少個事件,必須大于0,與poll中的nfds類似,兩者都可以達到系統允許的最大檔案描述符數:65535。select允許的值雖然可以修改但是是未定義的行為

     events:檢測到事件,将所有就緒的事件從核心事件表中複制到它的第二個參數events指向的數組中。

使用場合:

  一個線程在讀取完某個socket上的資料後開始處理這些資料,而資料的處理過程中該socket又有新資料可讀,此時另外一個線程被喚醒來讀取這些新的資料。于是,就出現了兩個線程同時操作一個socket的局面。可以使用epoll的epolloneshot事件實作一個socket連接配接在任一時刻都被一個線程處理。

作用:

  對于注冊了epolloneshot事件的檔案描述符,作業系統最多出發其上注冊的一個可讀,可寫或異常事件,且隻能觸發一次。

使用:

  注冊了epolloneshot事件的socket一旦被某個線程處理完畢,該線程就應該立即重置這個socket上的epolloneshot事件,以確定這個socket下一次可讀時,其epollin事件能被觸發,進而讓其他工作線程有機會繼續處理這個sockt。

效果:

      盡管一個socket在不同僚件可能被不同的線程處理,但同一時刻肯定隻有一個線程在為它服務,這就保證了連接配接的完整性,進而避免了很多可能的競态條件。

  如果一個監聽socket,listenfd注冊為epolloneshot事件,那麼應用程式隻能處理一個客戶連接配接,後續的可不連接配接不在觸發listenfd事件。

  是标準模式,相當于高效的poll,意味着每次epoll_wait()傳回後,事件處理後,如果之後還有資料,會不斷觸發,也就是說,一個套接字上一次完整的資料,epoll_wait()可能會傳回多次,直到沒有資料為止。

  使用et下的檔案描述符應該是非阻塞的,如果是阻塞的,那麼讀寫操作會因為沒有後續的事件一直處于阻塞狀态。

  有資料過來後,<code>epoll_wait()</code>會傳回一次,一段時間内,該套接字就算有資料源源不斷地過來<code>,epoll_wait()</code>也不會傳回了,很大程度降低了epoll事件被重複出觸發的次數,是以比lt模式效率高。這裡注意,是一段時間,不代表這個套接字上有資料就隻觸發一次。時間過長,還是會傳回多次的。每次套接字上有資訊就開線程處理,同一時間内希望一個套接字隻被一個線程持有,但是因為檔案傳輸時間過長,就算使用et模式,套接字還是會傳回多次。這裡要特别強調一個參數epolloneshot,如果要保證套接字同一時段隻被一個線程處理,必須加上。

  解決方案:給accept()後的套接字加上參數epolloneshot,線程結束後處理完之後,再重置epolloneshot屬性,但是,千萬不可以給listen()後的監聽套接字設定此屬性,這會造成同一時刻隻能處理一個連接配接的情況。

  二者的差異在于level-trigger模式下隻要某個socket處于readable/writable狀态,無論什麼時候進行epoll_wait都會傳回該socket;而edge-trigger模式下隻有某個socket從unreadable變為readable或從unwritable變為writable時,epoll_wait才會傳回該socket。如下兩個示意圖: 

從socket讀資料: 

epoll詳解

從socket寫資料: 

epoll詳解

在epoll的et模式下,正确的讀寫方式為: 

讀:隻要可讀,就一直讀,直到傳回0,或者 errno = eagain或ewouldblock,

寫:隻要可寫,就一直寫,直到資料發送完,或者 errno = eagain。

程式一:

epoll詳解

當使用者輸入一組字元,這組字元被送入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行做如下修改:

epoll詳解

程式陷入死循環,因為使用者輸入任意資料後,資料被送入buffer且沒有被讀出,是以lt模式下每次epoll_wait都認為buffer可讀傳回讀就緒。導緻每次都會輸出”welcome to epoll's world!”。

程式二:

epoll詳解

本程式依然使用lt模式,但是每次epoll_wait傳回讀就緒的時候我們都将buffer(緩沖)中的内容read出來,是以導緻buffer再次清空,下次調用epoll_wait就會阻塞。是以能夠實作我們所想要的功能——當使用者從控制台有任何輸入操作時,輸出”welcome to epoll's world!”

程式三:

epoll詳解

 程式依然使用et,但是每次讀就緒後都主動的再次mod in事件,我們發現程式再次出現死循環,也就是每次傳回讀就緒。但是注意,如果我們将mod改為add,将不會産生任何影響。别忘了每次add一個描述符都會在epitem組成的紅黑樹中添加一個項,我們之前已經add過一次,再次add将阻止添加,是以在次調用add in事件不會有任何影響。

程式四:

epoll詳解

    這個程式的功能是隻要标準輸出寫就緒,就輸出“welcome to epoll's world”。我們發現這将是一個死循環。下面具體分析一下這個程式的執行過程:

首先初始buffer為空,buffer中有空間可寫,這時無論是et還是lt都會将對應的epitem加入rdlist,導緻epoll_wait就傳回寫就緒。

程式想标準輸出輸出”welcome to epoll's world”和換行符,因為标準輸出為控制台的時候緩沖是“行緩沖”,是以換行符導緻buffer中的内容清空,這就對應第二節中et模式下寫就緒的第二種情況——當有舊資料被發送走時,即buffer中待寫的内容變少得時候會觸發fd狀态的改變。是以下次epoll_wait會傳回寫就緒。如此循環往複。

程式五:

epoll詳解

與程式四相比,程式五隻是将輸出語句的printf的換行符移除。我們看到程式成挂起狀态。因為第一次epoll_wait傳回寫就緒後,程式向标準輸出的buffer中寫入“welcome to epoll's world!”,但是因為沒有輸出換行,是以buffer中的内容一直存在,下次epoll_wait的時候,雖然有寫空間但是et模式下不再傳回寫就緒。回憶第一節關于et的實作,這種情況原因就是第一次buffer為空,導緻epitem加入rdlist,傳回一次就緒後移除此epitem,之後雖然buffer仍然可寫,但是由于對應epitem已經不再rdlist中,就不會對其就緒fd的events的在檢測了。

程式六:

epoll詳解

 程式六相對程式五僅僅是修改et模式為預設的lt模式,我們發現程式再次死循環。這時候原因已經很清楚了,因為當向buffer寫入”welcome to epoll's world!”後,雖然buffer沒有輸出清空,但是lt模式下隻有buffer有寫空間就傳回寫就緒,是以會一直輸出”welcome to epoll's world!”,當buffer滿的時候,buffer會自動刷清輸出,同樣會造成epoll_wait傳回寫就緒。

程式七:

epoll詳解

 程式七相對于程式五在每次向标準輸出的buffer輸出”welcome to epoll's world!”後,重新mod out事件。是以相當于每次都會傳回就緒,導緻程式循環輸出。

      經過前面的案例分析,我們已經了解到,當epoll工作在et模式下時,對于讀操作,如果read一次沒有讀盡buffer中的資料,那麼下次将得不到讀就緒的通知,造成buffer中已有的資料無機會讀出,除非有新的資料再次到達。對于寫操作,主要是因為et模式下fd通常為非阻塞造成的一個問題——如何保證将使用者要求寫的資料寫完。

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

對于讀,隻要buffer中還有資料就一直讀;

對于寫,隻要buffer還有空間且使用者請求寫的資料還未寫完,就一直寫。

考慮這種情況:tcp連接配接被用戶端夭折,即在伺服器調用accept之前,用戶端主動發送rst終止連接配接,導緻剛剛建立的連接配接從就緒隊列中移出,如果套接口被設定成阻塞模式,伺服器就會一直阻塞在accept調用上,直到其他某個客戶建立一個新的連接配接為止。但是在此期間,伺服器單純地阻塞在accept調用上,就緒隊列中的其他描述符都得不到處理。

解決方案:把監聽套接口設定為非阻塞,當客戶在伺服器調用accept之前中止某個連接配接時,accept調用可以立即傳回-1,這時源自berkeley的實作會在核心中處理該事件,并不會将該事件通知給epoll,而其他實作把errno設定為econnaborted或者eproto錯誤,我們應該忽略這兩個錯誤。

考慮這種情況:多個連接配接同時到達,伺服器的tcp就緒隊列瞬間積累多個就緒連接配接,由于是邊緣觸發模式,epoll隻會通知一次,accept隻處理一個連接配接,導緻tcp就緒隊列中剩下的連接配接都得不到處理。 

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

綜合以上兩種情況,伺服器應該使用非阻塞地accept,accept在et模式下的正确使用方式為:

一道騰訊背景開發的面試題:

使用<code>linux epoll</code>模型,水準觸發模式;當socket可寫時,會不停的觸發socket可寫的事件,如何處理? 

第一種最普遍的方式: 

需要向socket寫資料的時候才把socket加入epoll,等待可寫事件。接受到可寫事件後,調用write或者send發送資料。當所有資料都寫完後,把socket移出epoll。 

這種方式的缺點是,即使發送很少的資料,也要把socket加入epoll,寫完後在移出epoll,有一定操作代價。

第二種的方式: 

開始不把socket加入epoll,需要向socket寫資料的時候,直接調用write或者send發送資料。如果傳回eagain,把socket加入epoll,在epoll的驅動下寫資料,全部資料發送完畢後,再移出epoll。 

這種方式的優點是:資料不多的時候可以避免epoll的事件處理,提高效率。

      因為et模式下的讀寫需要一直讀或寫直到出錯(對于讀,當讀到的實際位元組數小于請求位元組數時就可以停止),而如果你的檔案描述符如果不是非阻塞的,那這個一直讀或一直寫勢必會在最後一次阻塞。這樣就不能在阻塞在epoll_wait上了,造成其他檔案描述符的任務饑餓。

       lt:水準觸發,效率會低于et觸發,尤其在大并發,大流量的情況下。但是lt對代碼編寫要求比較低,不容易出現問題。lt模式服務編寫上的表現是:隻要有資料沒有被擷取,核心就不斷通知你,是以不用擔心事件丢失的情況。

       et:邊緣觸發,效率非常高,在并發,大流量的情況下,會比lt少很多epoll的系統調用,是以效率高。但是對程式設計要求高,需要細緻的處理每個請求,否則容易發生丢失事件的情況。

      從本質上講:與lt相比,et模型是通過減少系統調用來達到提高并行效率的。  

繼續閱讀