概念回顧
這篇文章主要來講一下IO多路復用的一些細節性的東西,雖然我們前面的文章提到了IO多路復用的大致思想,但是實際上IO多路復用在具體的實現方案上還是有着一些區別的,
在講多路復用之前,我們還是要再來回顧一下傳統BIO模型和NIO模型的缺點,通過一步一步的比較,我們才能更好的理解多路復用的優點和本質首先我們知道,對於一次IO,我們有兩個階段會阻塞,分別是內核處理數據階段和內核數據拷貝到用戶空間階段。
那么對於BIO來說,這兩個階段任意一個階段沒有完成,整個主線程都會被阻塞,如果有一個線程一直卡在了內核處理數據階段,比如說有一個客戶端一直占着線程,就是什么都不發,那么服務端也無法接收其他客戶端的連接。就像是你排隊去吃飯,前面有一個人,啥也不干也不離開,這樣隊伍后面所有的人都吃不上飯,這合理嗎?這不合理。
對於傳統的NIO模型,他的解決方案則是,你不是可能會被阻塞在第一階段嗎,那每來一個客戶端,我就開一個線程,由這個線程去看內核數據他准備好沒有,准備好了我就可以開始進行處理。所以這個模型第一階段是非阻塞的。但是我們要知道,服務的資源也是有限的,如果每來一個IO請求,就開一個線程,如果請求多了起來,那么服務器資源遲早會有被耗光的一天。所以這種方案其實也不是很合理。
好,接下來就可以進入到本篇文章的正題了,IO多路復用。以上我們提到的兩種模型,我們能不能想一個好一點的解決辦法,來解決上面這個問題呢。我們來分析一下這個問題,問題在於為每來一個IO請求就要分配一個線程很耗費資源,但是我們又需要知道哪些數據已經是內核准備好了的。那么我們能不能創建一個數組,用來存放所有的IO所對應的那個請求的文件描述符呢?(就像是你去飯店里吃飯,你跟服務員點餐,服務員把你需要的菜記錄在他手里的菜單上,其他顧客的菜也同樣的記錄在這個菜單上)然后用一個線程去遍歷這個數組,去查詢這個數組中有哪些IO請求所對應的數據是已經准備好了的,然后返回通知處理。
看起來這個思路好像確實是解決了上面的新建線程耗資源的問題,所以方向來說是對的,但是,我們再仔細想想,這個其實是在用戶層的調用,一次又一次的反復遍歷,如果得到的結果是數據還沒有准備好,依然是一次浪費資源的調用。這就像是我們在for循環中進行rpc一樣,你A服務反復的調B服務,問他哪些數據好了。還不如先告訴A服務我需要哪些東西,然后讓B服務自己遍歷,看看哪些好了,然后再告訴A服務。這樣的話節省下來的就是n多次RPC調用的開銷。
其實上面說的這種就是IO多路復用中的select/poll 的一個思路,所謂IO多路復用就是多路復用一次調用
select
select所使用的方式就是上面我們說的這種思路,每次會將一組的文件描述符一次性傳入內核中,讓內核自己去遍歷。為什么要讓內核自己去遍歷呢?我們知道用戶態請求內核的資源開銷是比較高的,就相當於我們上面說的那種,在for循環里進行RPC調用一樣,內核態用戶態空間的反復切換比較浪費cpu資源,所以select的方式從用戶態拷貝一組的fds數組到內核,內核自己遍歷標記哪個已經就緒了,然后將文件描述符數組再拷貝回用戶態,返回一個就緒的個數,用戶態再對自己的這個數組進行遍歷,找到可用的文件描述符進行處理。
所以在select整個過程中,要經過兩次拷貝:一次是用戶態向內核態的一次拷貝,一次是內核准備好數據之后,內核向用戶的拷貝。兩次遍歷,一次遍歷發生在內核,一次遍歷發生在用戶空間,以及一次系統調用,即用戶態攜帶fds向內核的一次請求。相比較我們上面說的那種方式,用戶態進行系統調用遍歷,有n個io請求就要進行n次系統調用來說。select這種方式可以大幅度的減少系統調用的次數。
可以總結一下select的特點
- 每次select調用需要傳入一個fds數組。
- 把遍歷就緒操作放在了內核態,讓內核自己去標記哪個文件描述符好了 。
- 只返回一個就緒個數給用戶態,還需要用戶態進行一次遍歷 。

poll
poll和select的總體過程都是一樣的,區別在意select有一個最大只能監聽1024個文件描述符的限制,而poll則去掉了1024大小的限制,select用的是BitsMap 結構,poll取而代之用動態數組,以鏈表形式來組織,突破了 select 的文件描述符個數限制,不過還是會受到系統文件描述符限制。
不過這兩個在本質上沒有什么大的區別,也需要在用戶態與內核態之間拷貝文件描述符數組。時間復雜度也沒有什么變化
epoll
我們上面說的select/poll的特點。但是我們仔細想一想,是不是還有可以優化的點,或者說還存在一些問題。
- 每次select調用需要傳入一個fds數組。(問題:在並發量很高的時候,需要頻繁執行用戶到內核的復制,資源消耗量巨大)
- 把遍歷就緒操作放在了內核態,讓內核自己去標記哪個文件描述符好了 。(問題:能否改成事件驅動機制,哪個文件描述符就緒了,就把那個數組的文件描述符標記起來即可,避免內核的無效遍歷)
- 只返回一個就緒個數給用戶態,還需要用戶態進行一次遍歷 (問題:用戶態的遍歷也存在無效遍歷的可能,可以優化成只返回已經就緒的文件描述符給用戶態直接用,這樣的話用戶態就無需遍歷了,可避免用戶態的無效遍歷)
新技術的出現必然是為了解決舊技術存在的問題,因此epoll的出現,也是為了解決我們上述說到的select/poll所存在的問題。
- epoll在內核中維護了一個文件描述符的集合(采用紅黑樹結構,可以高效的維護文件描述符,增刪查一般時間復雜度是
O(logn)),用戶態無需每次重新傳入,只需要告訴內核修改的部分即可,可以大幅度減少內核和用戶空間之間的數據拷貝的資源消耗 - epoll采用的是事件驅動機制,不需要通過輪詢來找到對應的那個文件描述符。當某個文件描述符就緒的時候,會通過回調函數,把它放到一個就緒鏈表中記錄起來,內核就不用遍歷文件描述符了
- 內核只返回就緒的文件描述符集合,也就是上面提到的那個就緒鏈表,因為返回給用戶態的肯定是已經就緒了的,所以用戶態可以拿來即用,也無需做多余的遍歷

總結一下epoll的過程,首先內核會維護一個所有待檢測的文件描述符的紅黑樹,然后用戶態每次只需傳入有更改或者新增的那個部分的文件描述符,內核會在紅黑樹上維護好傳過來的文件描述符,接着假如有一個文件描述符好了,會觸發回調函數,將這個文件描述符添加到就緒鏈表中,當epoll_wait觸發的時候就返回就緒鏈表給用戶態直接使用。可以看到,epoll很好的解決了我們上面說到的那些select和poll所存在的問題,使得io效率在高並發環境下高了不少。
至於epoll_wait是怎么觸發的,可以看看下面這段描述 ,我覺得是寫的比較通俗易懂的,所以就直接轉過來了,轉自公眾號 【小林coding】
epoll 支持兩種事件觸發模式,分別是邊緣觸發(*edge-triggered,ET*)和水平觸發(*level-triggered,LT*)。
這兩個術語還挺抽象的,其實它們的區別還是很好理解的。
- 使用邊緣觸發模式時,當被監控的 Socket 描述符上有可讀事件發生時,服務器端只會從 epoll_wait 中蘇醒一次,即使進程沒有調用 read 函數從內核讀取數據,也依然只蘇醒一次,因此我們程序要保證一次性將內核緩沖區的數據讀取完;
- 使用水平觸發模式時,當被監控的 Socket 上有可讀事件發生時,服務器端不斷地從 epoll_wait 中蘇醒,直到內核緩沖區數據被 read 函數讀完才結束,目的是告訴我們有數據需要讀取;
舉個例子,你的快遞被放到了一個快遞箱里,如果快遞箱只會通過短信通知你一次,即使你一直沒有去取,它也不會再發送第二條短信提醒你,這個方式就是邊緣觸發;如果快遞箱發現你的快遞沒有被取出,它就會不停地發短信通知你,直到你取出了快遞,它才消停,這個就是水平觸發的方式。
這就是兩者的區別,水平觸發的意思是只要滿足事件的條件,比如內核中有數據需要讀,就一直不斷地把這個事件傳遞給用戶;而邊緣觸發的意思是只有第一次滿足條件的時候才觸發,之后就不會再傳遞同樣的事件了。
如果使用水平觸發模式,當內核通知文件描述符可讀寫時,接下來還可以繼續去檢測它的狀態,看它是否依然可讀或可寫。所以在收到通知后,沒必要一次執行盡可能多的讀寫操作。
如果使用邊緣觸發模式,I/O 事件發生時只會通知一次,而且我們不知道到底能讀寫多少數據,所以在收到通知后應盡可能地讀寫數據,以免錯失讀寫的機會。因此,我們會循環從文件描述符讀寫數據,那么如果文件描述符是阻塞的,沒有數據可讀寫時,進程會阻塞在讀寫函數那里,程序就沒辦法繼續往下執行。所以,邊緣觸發模式一般和非阻塞 I/O 搭配使用,程序會一直執行 I/O 操作,直到系統調用(如
read和write)返回錯誤,錯誤類型為EAGAIN或EWOULDBLOCK。一般來說,邊緣觸發的效率比水平觸發的效率要高,因為邊緣觸發可以減少 epoll_wait 的系統調用次數,系統調用也是有一定的開銷的的,畢竟也存在上下文的切換。
select/poll 只有水平觸發模式,epoll 默認的觸發模式是水平觸發,但是可以根據應用場景設置為邊緣觸發模式。
總結
本篇文章我們回顧了傳統的IO的一些缺陷,因為有這些BIO的阻塞缺陷,只能一對一的服務客戶端。NIO模型的多線程消耗資源缺陷,當有大量客戶端的io請求的時候,建立大量的線程會過度消耗服務器資源,所以引出了IO多路復用這種機制,所謂IO多路復用就是多路復用一次調用,而對於IO多路復用在內核中實現方式又有三種,分別是select、poll、epoll。
select/poll的的方式會把先從用戶空間拷貝一個文件描述符集合到內核態遍歷就緒的文件描述符集合,然后返回一個就緒個數,並將文件描述符集合再拷貝回用戶態進行遍歷。poll則是去掉了select的限制。但是這種方式在高並發的時候,頻繁的內核拷貝還是很浪費資源。
epoll則是采用紅黑樹的集合來維護用戶描述符集合,用戶只需傳入修改部分的文件描述符,無需整個傳入,節省了復制的開銷。並且采用了事件驅動回調的機制,來觸發就緒的文件描述符回調,將其添加到就緒鏈表中再返回給用戶態,減少了內核和用戶的無效遍歷,大大的提升了檢測的效率。也是目前采用的最多的,最好的處理方式。
