深入理解NIO(四)—— epoll的實現原理


深入理解NIO(四)—— epoll的實現原理

本文鏈接:https://www.cnblogs.com/fatmanhappycode/p/12362423.html

終於來到最后了,萬里長征只差最后一步 ( `д´) !

 

簡單流程梳理

我們先從只監聽一個socket開始講起:

首先我們有一個程序A,他運行這下面這樣一段代碼:

//創建socket
int s = socket(AF_INET, SOCK_STREAM, 0);   
//綁定端口
bind(s, ...)
//監聽
listen(s, ...)
//接受客戶端連接
int c = accept(s, ...)
//接收客戶端數據,沒有數據就先阻塞在這里
recv(c, ...);
//將數據打印出來
printf(...)

當程序A運行到recv()的時候它阻塞了,

之后就掛起在等待隊列中,等待被喚醒之后繼續執行,

而在Linux中,萬事萬物皆為文件,我們的socket也占用了一個fd

我們的A就掛在socket中的等待隊列中。

 

 接下來我們來看一個簡單的流程:

  1. 首先第一步是通過網線發送數據到網卡
  2. 網卡將數據存到內存中
  3. 網卡對cpu發出中斷信號,提醒cpu過來處理網卡的任務
  4. cpu接到信號后暫時中斷自己的任務,過來運行網卡准備的中斷程序
  5. 中斷程序的內容是:將網卡寫到內存中的網絡數據寫入socket的輸入緩沖區
  6. 然后中斷程序再喚醒處於阻塞狀態的A喚醒,並掛到工作隊列中讓cpu運行它,而cpu就會運行剛剛代碼的最后一段:打印
//將數據打印出來
printf(...)

 

當然,實際應用中我們不可能只監聽一個socket,接下來我們就直接來看一下,能監聽多個socket的select是怎么實現的:

 

select

 

select:上世紀 80 年代就實現了,它支持注冊 FD_SETSIZE(1024) 個 socket,在那個年代肯定是夠用的,不過現在嘛,肯定是不行了。

當然,這里的select不是指nio里的select()方法,而是指操作系統中的一個實現,它的改進版本是pollepoll,請大家注意不要混淆。

我們先從select說起:

int s = socket(AF_INET, SOCK_STREAM, 0);  
bind(s, ...);
listen(s, ...);
int fds[] =  // 存放需要監聽的socket;
while(1){
    // 這里就是我們的select
    int n = select(..., fds, ...)
    for(int i=0; i < fds.count; i++){
        if(FD_ISSET(fds[i], ...)){
            //fds[i]的數據處理
        }
    }
}

這段代碼中,先准備了一個數組 fds,讓 fds 存放着所有需要監視的 socket。然后調用 select,如果 fds 中的所有 socket 都沒有數據,select 會阻塞,直到有一個 socket 接收到數據,select 返回,喚醒進程。用戶可以遍歷 fds,通過 FD_ISSET 判斷具體哪個 socket 收到數據,然后做出處理。

 

接下來我們看一下流程圖:

 

 

  1.  流程基本都一樣,A卻存在於多個socket的等待隊列中
  2. 當某個socket被寫入數據時,A也被喚醒並從多個socket的等待隊列中移除后加入工作隊列
  3. 但是此時A並不知道是哪個socket被寫入了數據,所以只能遍歷所有socket
  4. 在A處理完任務后移出工作隊列,但是此時卻需要遍歷所有socket並加入它們的等待隊列中

 

select的缺點在於:

  1. 每次調用 select 都需要將進程加入到所有監視 socket 的等待隊列,每次喚醒都需要從每個隊列中移除。這里涉及了兩次遍歷,而且每次都要將整個 fds 列表傳遞給內核,有一定的開銷。正是因為遍歷操作開銷大,出於效率的考量,才會規定 select 的最大監視數量,默認只能監視 1024 個 socket。
  2. 進程被喚醒后,程序並不知道哪些 socket 收到數據,還需要遍歷一次。

 

poll

因此人們先提出了poll

poll:1997 年,出現了 poll 作為 select 的替代者,最大的區別就是,poll 不再限制 socket 數量。

但是poll並沒有解決剛剛提到的select的問題,所以就有了epoll

 

epoll

select 低效的原因之一是將“維護等待隊列”和“阻塞進程”兩個步驟合二為一。如下圖所示,每次調用 select 都需要這兩步操作,然而大多數應用場景中,需要監視的 socket 相對固定,並不需要每次都修改。epoll 將這兩個操作分開,先用 epoll_ctl 維護等待隊列,再調用 epoll_wait 阻塞進程。顯而易見地,效率就能得到提升。

 

還記得我們的NIO源碼分析嗎,里面就調用了epoll_create、epoll_ctl和epoll_wait

 

epoll_create

當某個進程調用 epoll_create 方法時,內核會創建一個 eventpoll 對象(也就是程序中 epfd 所代表的對象)。eventpoll 對象也是文件系統中的一員,和 socket 一樣,它也會有等待隊列。

 

epoll_ctl

如果通過 epoll_ctl 添加 sock1、sock2 和 sock3 的監視,內核會將 eventpoll 添加到這三個 socket 的等待隊列中。

 

epoll_wait

進程 A 運行到了 epoll_wait 語句。如下圖所示,內核會將進程 A 放入 eventpoll 的等待隊列中,阻塞進程。(這里的rdlist和等待隊列都是eventpoll對象的屬性一樣的東西,都是屬於它的)

 

當 socket 接收到數據,中斷程序一方面修改 rdlist,另一方面喚醒 eventpoll 等待隊列中的進程,進程 A 再次進入運行狀態(如下圖)。也因為 rdlist 的存在,進程 A 可以知道哪些 socket 發生了變化。

 

 

數據結構

監聽所有socket的等待隊列(不是存放進程A的)的結構是紅黑樹,紅黑樹是一種自平衡二叉查找樹,搜索、插入和刪除時間復雜度都是O(log(N)),效率較好

而rdlist是一個雙向鏈表,進程A應該是放在poll_wait里(我猜的,錯了不怪我)

 

Linux epoll API

之前說Java NIO封裝了epoll的api,那么我們就來看一下它的api吧(雖然並不重要,但是還是看看吧):

 

int epoll_create(int size)

創建一個epoll的句柄。自從linux2.6.8之后,size參數是被忽略的。需要注意的是,當創建好epoll句柄后,它就是會占用一個fd值,在linux下如果查看/proc/進程id/fd/,是能夠看到這個fd的,所以在使用完epoll后,必須調用close()關閉,否則可能導致fd被耗盡。

 

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event) 

 

1. 第一個參數是epoll_create()的返回值。

 

2. 第二個參數表示動作,用三個宏來表示:

  • EPOLL_CTL_ADD:注冊新的fd到epfd中;
  • EPOLL_CTL_MOD:修改已經注冊的fd的監聽事件;
  • EPOLL_CTL_DEL:從epfd中刪除一個fd;

 

3. 第三個參數是需要監聽的fd。

 

4. 第四個參數是告訴內核需要監聽什么事:

  • EPOLLIN :表示對應的文件描述符可以讀(包括對端SOCKET正常關閉);
  • EPOLLOUT:表示對應的文件描述符可以寫;
  • EPOLLPRI:表示對應的文件描述符有緊急的數據可讀(這里應該表示有帶外數據到來);
  • EPOLLERR:表示對應的文件描述符發生錯誤;
  • EPOLLHUP:表示對應的文件描述符被掛斷;
  • EPOLLET: 將EPOLL設為邊緣觸發(Edge Triggered)模式,這是相對於水平觸發(Level Triggered)來說的。
  • EPOLLONESHOT:只監聽一次事件,當監聽完這次事件之后,如果還需要繼續監聽這個socket的話,需要再次把這個socket加入到EPOLL隊列里

 

int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)

  1. 收集在epoll監控的事件中已經發送的事件。
  2. 參數events是分配好的epoll_event結構體數組,epoll將會把發生的事件賦值到events數組中(events不可以是空指針,內核只負責把數據復制到這個events數組中,不會去幫助我們在用戶態中分配內存)
  3. maxevents告之內核這個events有多大,這個 maxevents的值不能大於創建epoll_create()時的size。
  4. 參數timeout是超時時間(毫秒,0會立即返回,-1是永久阻塞)。如果函數調用成功,返回對應I/O上已准備好的文件描述符數目,如返回0表示已超時。

 

epoll的兩種觸發模式

 

epoll有EPOLLLT和EPOLLET兩種觸發模式,LT是默認的模式,ET是“高速”模式。

 

LT(水平觸發)模式下,只要這個文件描述符還有數據可讀,每次 epoll_wait都會返回它的事件,提醒用戶程序去操作;

 

ET(邊緣觸發)模式下,在它檢測到有 I/O 事件時,通過 epoll_wait 調用會得到有事件通知的文件描述符,對於每一個被通知的文件描述符,如可讀,則必須將該文件描述符一直讀到空,讓 errno 返回 EAGAIN 為止,否則下次的 epoll_wait 不會返回余下的數據,會丟掉事件。如果ET模式不是非阻塞的,那這個一直讀或一直寫勢必會在最后一次阻塞。

 

 

 

采用ET這種邊緣觸發模式的話,當被監控的文件描述符上有可讀寫事件發生時,epoll_wait()會通知處理程序去讀寫。如果這次沒有把數據全部讀寫完(如讀寫緩沖區太小),那么下次調用epoll_wait()時,它不會通知你,也就是它只會通知你一次,直到該文件描述符上出現第二次可讀寫事件才會通知你

 

如果你能看到這里

終於結束了 ( `д´) !經過四篇深入理解NIO系列博文,你已經是個成熟Java調包師了,你將和我一樣找不到工作,おめでとう !

 

 


 

本文參考自 : https://my.oschina.net/editorial-story/blog/3052308#comments,我很喜歡這種講解風格,不過原博主好像主要寫游戲開發類的,可能很難再有我想看的博文了。

Linux epoll API小節搬運自https://www.cnblogs.com/yangang92/p/7469970.html

兩種觸發模式搬運自https://blog.csdn.net/daaikuaichuan/article/details/83862311?utm_source=distribute.pc_relevant.none-task


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM