深入理解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中的等待隊列中。
接下來我們來看一個簡單的流程:
- 首先第一步是通過網線發送數據到網卡
- 網卡將數據存到內存中
- 網卡對cpu發出中斷信號,提醒cpu過來處理網卡的任務
- cpu接到信號后暫時中斷自己的任務,過來運行網卡准備的中斷程序
- 中斷程序的內容是:將網卡寫到內存中的網絡數據寫入socket的輸入緩沖區
- 然后中斷程序再喚醒處於阻塞狀態的A喚醒,並掛到工作隊列中讓cpu運行它,而cpu就會運行剛剛代碼的最后一段:打印
//將數據打印出來 printf(...)
當然,實際應用中我們不可能只監聽一個socket,接下來我們就直接來看一下,能監聽多個socket的select是怎么實現的:
select
select:上世紀 80 年代就實現了,它支持注冊 FD_SETSIZE(1024) 個 socket,在那個年代肯定是夠用的,不過現在嘛,肯定是不行了。
當然,這里的select不是指nio里的select()方法,而是指操作系統中的一個實現,它的改進版本是poll和epoll,請大家注意不要混淆。
我們先從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 收到數據,然后做出處理。
接下來我們看一下流程圖:
- 流程基本都一樣,A卻存在於多個socket的等待隊列中
- 當某個socket被寫入數據時,A也被喚醒並從多個socket的等待隊列中移除后加入工作隊列
- 但是此時A並不知道是哪個socket被寫入了數據,所以只能遍歷所有socket
- 在A處理完任務后移出工作隊列,但是此時卻需要遍歷所有socket並加入它們的等待隊列中
select的缺點在於:
- 每次調用 select 都需要將進程加入到所有監視 socket 的等待隊列,每次喚醒都需要從每個隊列中移除。這里涉及了兩次遍歷,而且每次都要將整個 fds 列表傳遞給內核,有一定的開銷。正是因為遍歷操作開銷大,出於效率的考量,才會規定 select 的最大監視數量,默認只能監視 1024 個 socket。
- 進程被喚醒后,程序並不知道哪些 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)
- 收集在epoll監控的事件中已經發送的事件。
- 參數events是分配好的epoll_event結構體數組,epoll將會把發生的事件賦值到events數組中(events不可以是空指針,內核只負責把數據復制到這個events數組中,不會去幫助我們在用戶態中分配內存)。
- maxevents告之內核這個events有多大,這個 maxevents的值不能大於創建epoll_create()時的size。
- 參數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