關于epoll原理

作者:靜海聽風鏈接:https://www.zhihu.com/question/20122137/answer/146866418來源:知乎著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。
第一部分:select和epoll的任務
** 關鍵詞:應用程序 文件句柄 用戶態 內核態 監控者**
要比較epoll相比較select高效在什么地方,就需要比較二者做相同事情的方法。
要完成對I/O流的復用需要完成如下幾個事情:
1.用戶態怎么將文件句柄傳遞到內核態?
2.內核態怎么判斷I/O流可讀可寫?
3.內核怎么通知監控者有I/O流可讀可寫?
4.監控者如何找到可讀可寫的I/O流并傳遞給用戶態應用程序?
5.繼續循環時監控者怎樣重復上述步驟?
搞清楚上述的步驟也就能解開epoll高效的原因了。
select的做法:
步驟1的解法:select創建3個文件描述符集,并將這些文件描述符拷貝到內核中,這里限制了文件句柄的最大的數量為1024(注意是全部傳入---第一次拷貝);
步驟2的解法:內核針對讀緩沖區和寫緩沖區來判斷是否可讀可寫,這個動作和select無關;
步驟3的解法:內核在檢測到文件句柄可讀/可寫時就產生中斷通知監控者select,select被內核觸發之后,就返回可讀可寫的文件句柄的總數;
步驟4的解法:select會將之前傳遞給內核的文件句柄再次從內核傳到用戶態(第2次拷貝),select返回給用戶態的只是可讀可寫的文件句柄總數,再使用FD_ISSET宏函數來檢測哪些文件I/O可讀可寫(遍歷);
步驟5的解法:select對于事件的監控是建立在內核的修改之上的,也就是說經過一次監控之后,內核會修改位,因此再次監控時需要再次從用戶態向內核態進行拷貝(第N次拷貝)
epoll的做法:
步驟1的解法:首先執行epoll_create在內核專屬于epoll的高速cache區,并在該緩沖區建立紅黑樹和就緒鏈表,用戶態傳入的文件句柄將被放到紅黑樹中(第一次拷貝)。
步驟2的解法:內核針對讀緩沖區和寫緩沖區來判斷是否可讀可寫,這個動作與epoll無關;
步驟3的解法:epoll_ctl執行add動作時除了將文件句柄放到紅黑樹上之外,還向內核注冊了該文件句柄的回調函數,內核在檢測到某句柄可讀可寫時則調用該回調函數,回調函數將文件句柄放到就緒鏈表。
步驟4的解法:epoll_wait只監控就緒鏈表就可以,如果就緒鏈表有文件句柄,則表示該文件句柄可讀可寫,并返回到用戶態(少量的拷貝);
步驟5的解法:由于內核不修改文件句柄的位,因此只需要在第一次傳入就可以重復監控,直到使用epoll_ctl刪除,否則不需要重新傳入,因此無多次拷貝。
簡單說:epoll是繼承了select/poll的I/O復用的思想,并在二者的基礎上從監控IO流、查找I/O事件等角度來提高效率,具體地說就是內核句柄列表、紅黑樹、就緒list鏈表來實現的。
第二部分:epoll詳解
先簡單回顧下如何使用C庫封裝的3個epoll系統調用吧。
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event event);
int epoll_wait(
int* epfd, struct epoll_event events,int* maxevents, int timeout);

使用起來很清晰:
A.epoll_create建立一個epoll對象。參數size是內核保證能夠正確處理的最大句柄數,多于這個最大數時內核可不保證效果。
B.epoll_ctl可以操作上面建立的epoll,例如,將剛建立的socket加入到epoll中讓其監控,或者把 epoll正在監控的某個socket句柄移出epoll,不再監控它等等(也就是將I/O流放到內核)。
C.epoll_wait在調用時,在給定的timeout時間內,當在監控的所有句柄中有事件發生時,就返回用戶態的進程(也就是在內核層面捕獲可讀寫的I/O事件)。
從上面的調用方式就可以看到epoll比select/poll的優越之處:
因為后者每次調用時都要傳遞你所要監控的所有socket給select/poll系統調用,這意味著需要將用戶態的socket列表copy到內核態,如果以萬計的句柄會導致每次都要copy幾十幾百KB的內存到內核態,非常低效。而我們調用epoll_wait時就相當于以往調用select/poll,但是這時卻不用傳遞socket句柄給內核,因為內核已經在epoll_ctl中拿到了要監控的句柄列表。
====>select監控的句柄列表在用戶態,每次調用都需要從用戶態將句柄列表拷貝到內核態,但是epoll中句柄就是建立在內核中的,這樣就減少了內核和用戶態的拷貝,高效的原因之一。
所以,實際上在你調用epoll_create后,內核就已經在內核態開始準備幫你存儲要監控的句柄了,每次調用epoll_ctl只是在往內核的數據結構**里塞入新的socket句柄。
在內核里,一切皆文件。所以,epoll向內核注冊了一個文件系統,用于存儲上述的被監控socket。當你調用epoll_create時,就會在這個虛擬的epoll文件系統里創建一個file結點。當然這個file不是普通文件,它只服務于epoll。
epoll在被內核初始化時(操作系統**啟動),同時會開辟出epoll自己的內核高速cache區,用于安置每一個我們想監控的socket,這些socket會以紅黑樹的形式保存在內核cache里,以支持快速的查找、插入、刪除。這個內核高速cache區,就是建立連續的物理內存頁,然后在之上建立slab層,簡單的說,就是物理上分配好你想要的size的內存對象,每次使用時都是使用空閑的已分配好的對象。
epoll高效的原因:
這是由于我們在調用epoll_create時,內核除了幫我們在epoll文件系統里建了個file結點,在內核cache里建了個紅黑樹用于存儲以后epoll_ctl傳來的socket外,還會再建立一個list鏈表,用于存儲準備就緒的事件.
epoll_wait調用時,僅僅觀察這個list鏈表里有沒有數據即可。有數據就返回,沒有數據就sleep,等到timeout時間到后即使鏈表沒數據也返回。所以,epoll_wait非常高效。而且,通常情況下即使我們要監控百萬計的句柄,大多一次也只返回很少量的準備就緒句柄而已,所以,epoll_wait僅需要從內核態copy少量的句柄到用戶態而已.
那么,這個準備就緒list鏈表是怎么維護的呢?
當我們執行epoll_ctl時,除了把socket放到epoll文件系統里file對象對應的紅黑樹上之外,還會給內核中斷處理程序注冊一個回調函數,告訴內核,如果這個句柄的中斷到了,就把它放到準備就緒list鏈表里。所以,當一個socket上有數據到了,內核在把網卡上的數據copy到內核中后就來把socket插入到準備就緒鏈表里了。
epoll綜合的執行過程:
如此,一棵紅黑樹,一張準備就緒句柄鏈表,少量的內核cache,就幫我們解決了大并發下的socket處理問題。執行epoll_create時,創建了紅黑樹和就緒鏈表,執行epoll_ctl時,如果增加socket句柄,則檢查在紅黑樹中是否存在,存在立即返回,不存在則添加到樹干上,然后向內核注冊回調函數,用于當中斷事件來臨時向準備就緒鏈表中插入數據。執行epoll_wait時立刻返回準備就緒鏈表里的數據即可。
epoll水平觸發和邊緣觸發的實現:
當一個socket句柄上有事件時,內核會把該句柄插入上面所說的準備就緒list鏈表,這時我們調用epoll_wait,會把準備就緒的socket拷貝到用戶態內存,然后清空準備就緒list鏈表, 最后,epoll_wait干了件事,就是檢查這些socket,如果不是ET模式(就是LT模式的句柄了),并且這些socket上確實有未處理的事件時,又把該句柄放回到剛剛清空的準備就緒鏈表了,所以,非ET的句柄,只要它上面還有事件,epoll_wait每次都會返回。而ET模式的句柄,除非有新中斷到,即使socket上的事件沒有處理完,也是不會次次從epoll_wait返回的。
*====>區別就在于epoll_wait將socket返回到用戶態時是否情況就緒鏈表。 *
第三部分:epoll高效的本質
1.減少用戶態和內核態之間的文件句柄拷貝;
2.減少對可讀可寫文件句柄的遍歷;

作者:林曉峰
鏈接:https://www.zhihu.com/question/20122137/answer/63120488
來源:知乎
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。

搞清楚這個問題,有三個關鍵的操作系統背景知識需要理解:
進程執行調度方法
內核執行中斷上下文(Interrupt Context)
Wait Queue
這里挑重點過程說一下,忽略很多細節和邊緣 case,詳細過程網上很容易查找到,或者有能力的可以直接看內核源碼。
進程執行調度方法:
操作系統總體上按照時間片來調度進程執行,進程執行調度狀態分為三種:Running、Ready 和 Block(具體狀態命名可能不是教科書準確)。
等待資源就緒的進程會置為 Block 狀態(比如調用 accept 并阻塞的進程),資源就緒可以隨時運行的進程會放在每個 CPU 的調度隊列里,獲得當前 CPU 時間片運行中的進程是 Running 狀態,等待 CPU 時間片分配的進程是 Ready 狀態。
內核執行中斷上下文:
內核在處理硬件中斷時,會直接打斷正在執行的 Running 狀態進程(包括系統調用),進行必要的內存拷貝和狀態更新(比如處理 TCP 握手),結束中斷處理后恢復運行被打斷的進程。
Wait Queue:
Linux 內核實現進程喚醒的關鍵數據結構。通常一個事件體有一個 wait queue,對這個事件體感興趣的進程或者系統會提供回調函數,并將自己注冊到這個事件體的 wait queue 上。當事件發生時,會調用注冊在 wait queue 上的回調函數。常見的回調函數是,將對這個事件感興趣的進程的調度狀態置為 Ready,于是在調度系統重新分配 CPU 時間片時,將該進程重新執行,從而實現進程等待資源就緒而喚醒的過程。

有了這三個基本的概念,那么以網絡 IO 為代表的事件是如何從網卡到內核,最終通知進程做相關處理的?epoll & kqueue 其實是更復雜的設計和實現,基本原理是一致的。下面以 TCP 新建連接為例子,描述這一內核的處理過程:
1.網卡收到 SYN,觸發內核中斷,直接打斷當前執行的進程,CPU 進行中斷處理邏輯(不展開 NAPI & 軟中斷過程),最終將該 SYN 連接信息保存在相應 listen socket 的半連接隊列里,并向對方發送 SYN-ACK,然后恢復運行被打斷的進程。
2.進程執行完當前作業,調用 accept 系統調用(阻塞)繼續處理新連接。accept 發現連接隊列當前沒有新連接后,于是在 listen socket 的 wait queue 的上注冊喚醒自身進程的回調函數,然后內核將這個進程置為 Block 狀態,并讓出 CPU 執行其他 Ready 狀態的進程。
3.網卡收到 ACK,繼續觸發內核中斷,內核完成標準的三次握手,將連接從半連接隊列移入連接隊列,于是 listen socket 有可讀事件,內核調用 listen socket 的 wait queue 的喚醒回調函數,將之前阻塞的 accept 進程置為 Ready 調度狀態。在內核下一個 CPU 調度窗口來臨時,Ready 調度狀態的 accept 進程被選中執行,發現連接隊列有新連接,于是讀取連接信息,并最終返回給用戶態進程。

從上面過程可見,內核處理硬件事件是一個同步的過程,而把事件傳遞給用戶進程是一個異步的過程。
“epoll & kqueue 其實是更復雜的設計和實現,基本原理是一致的”

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容