前提:這里的IO指網(wǎng)絡(luò)IO,區(qū)別于磁盤IO。
BIO:同步阻塞I/O模式;
NIO:同步非阻塞I/O模式;
AIO:異步非阻塞I/O模式。
什么是阻塞與非阻塞?
當(dāng)不能進(jìn)行讀寫(網(wǎng)卡空的時候讀/網(wǎng)卡滿的時候?qū)懀┑臅r候,I/O操作是立即返回還是阻塞。
什么是同步與異步?
當(dāng)數(shù)據(jù)已經(jīng)ready的時候,讀寫操作是同步讀還是異步讀。
阻塞、非阻塞與同步、異步只是階段不同而已。
一、BIO
同步阻塞I/O模式,數(shù)據(jù)的讀取寫入必須阻塞在一個線程內(nèi)等待其完成。
1.傳統(tǒng)BIO
BIO通信(一請求一應(yīng)答)模型圖如下:

采用?BIO 通信模型?的服務(wù)端,通常由一個獨立的 Acceptor 線程負(fù)責(zé)監(jiān)聽客戶端的連接。我們一般通過在?while(true)?循環(huán)中服務(wù)端會調(diào)用?accept()?方法等待接收客戶端的連接的方式監(jiān)聽請求,請求一旦接收到一個連接請求,就可以建立通信套接字在這個通信套接字上進(jìn)行讀寫操作,此時不能再接收其他客戶端連接請求,只能等待同當(dāng)前連接的客戶端的操作執(zhí)行完成。舉個例子,當(dāng)用read去讀取網(wǎng)絡(luò)的數(shù)據(jù)時,是無法預(yù)知對方是否已經(jīng)發(fā)送數(shù)據(jù)的。因此在收到數(shù)據(jù)之前,能做的只有等待,直到對方把數(shù)據(jù)發(fā)過來,或者等到網(wǎng)絡(luò)超時。
對于單線程的網(wǎng)絡(luò)服務(wù),這樣做就會有卡死的問題。因為當(dāng)?shù)却龝r,整個線程會被掛起,無法執(zhí)行,也無法做其他的工作。
于是,網(wǎng)絡(luò)服務(wù)為了同時響應(yīng)多個并發(fā)的網(wǎng)絡(luò)請求,必須實現(xiàn)為多線程的(主要原因是?socket.accept()、socket.read()、?socket.write()?涉及的三個主要函數(shù)都是同步阻塞的)。每個線程處理一個網(wǎng)絡(luò)請求。也就是說它在接收到客戶端連接請求之后為每個客戶端創(chuàng)建一個新的線程進(jìn)行鏈路處理,處理完成之后,通過輸出流返回應(yīng)答給客戶端,線程銷毀。這就是典型的?一請求一應(yīng)答通信模型?。
CPU是被釋放出來的,開啟多線程,就可以讓CPU去處理更多的事情。其實這也是所有使用多線程的本質(zhì):
1>?利用多核。
2>?當(dāng)I/O阻塞系統(tǒng),但CPU空閑的時候,可以利用多線程使用CPU資源。
線程數(shù)隨著并發(fā)連接數(shù)線性增長。這的確能奏效。實際上2000年之前很多網(wǎng)絡(luò)服務(wù)器就是這么實現(xiàn)的。但這帶來兩個問題:
1> 線程越多,Context Switch就越多,而Context Switch是一個比較重的操作,會無謂浪費大量的CPU。
2> 每個線程會占用一定的內(nèi)存作為線程的棧。比如有1000個線程同時運行,每個占用1MB內(nèi)存,就占用了1個G的內(nèi)存。
問題的關(guān)鍵在于,當(dāng)調(diào)用read接受網(wǎng)絡(luò)請求時,有數(shù)據(jù)到了就用,沒數(shù)據(jù)到時,實際上是可以干別的。使用大量線程,僅僅是因為Block發(fā)生,沒有其他辦法。
1.1 當(dāng)客戶端并發(fā)訪問量增加后這種模型會出現(xiàn)什么問題?
線程是寶貴的資源,線程的創(chuàng)建和銷毀成本很高,除此之外,線程的切換成本也是很高的。尤其在 Linux 這樣的操作系統(tǒng)中,線程本質(zhì)上就是一個進(jìn)程,創(chuàng)建和銷毀線程都是重量級的系統(tǒng)函數(shù)。如果并發(fā)訪問量增加會導(dǎo)致線程數(shù)急劇膨脹可能會導(dǎo)致線程堆棧溢出、創(chuàng)建新線程失敗等問題,最終導(dǎo)致進(jìn)程宕機或者僵死,不能對外提供服務(wù)。
1.2 模型優(yōu)化:
我們可以設(shè)想一下如果這個連接不做任何事情的話就會造成不必要的線程開銷,不過可以通過線程池機制改善,線程池還可以讓線程的創(chuàng)建和回收成本相對較低。使用FixedThreadPool?可以有效的控制了線程的最大數(shù)量,保證了系統(tǒng)有限的資源的控制,實現(xiàn)了N(客戶端請求數(shù)量):M(處理客戶端請求的線程數(shù)量)的偽異步I/O模型(N 可以遠(yuǎn)遠(yuǎn)大于 M)。
2. 偽異步IO
為了解決同步阻塞I/O面臨的一個鏈路需要一個線程處理的問題,后來有人對它的線程模型進(jìn)行了優(yōu)化一一一后端通過一個線程池來處理多個客戶端的請求接入,形成客戶端個數(shù)M:線程池最大線程數(shù)N的比例關(guān)系,其中M可以遠(yuǎn)遠(yuǎn)大于N.通過線程池可以靈活地調(diào)配線程資源,設(shè)置線程的最大值,防止由于海量并發(fā)接入導(dǎo)致線程耗盡。
偽異步IO模型圖如下:

采用線程池和任務(wù)隊列可以實現(xiàn)一種叫做偽異步的 I/O 通信框架,它的模型圖如上圖所示。當(dāng)有新的客戶端接入時,將客戶端的 Socket 封裝成一個Task投遞到后端的線程池中進(jìn)行處理。線程池維護(hù)一個消息隊列和 N 個活躍線程,對消息隊列中的任務(wù)進(jìn)行處理。由于線程池可以設(shè)置消息隊列的大小和最大線程數(shù),因此,它的資源占用是可控的,無論多少個客戶端并發(fā)訪問,都不會導(dǎo)致資源的耗盡和宕機。
現(xiàn)在的多線程一般都使用線程池,可以讓線程的創(chuàng)建和回收成本相對較低。在活動連接數(shù)不是特別高(小于單機1000)的情況下,這種模型是比較不錯的,可以讓每一個連接專注于自己的I/O并且編程模型簡單,也不用過多考慮系統(tǒng)的過載、限流等問題。線程池本身就是一個天然的漏斗,可以緩沖一些系統(tǒng)處理不了的連接或請求。
2.2 該模型存在的問題
這個模型最本質(zhì)的問題在于,嚴(yán)重依賴于線程。但線程是很"貴"的資源,主要表現(xiàn)在:
1> 線程的創(chuàng)建和銷毀成本很高,在Linux這樣的操作系統(tǒng)中,線程本質(zhì)上就是一個進(jìn)程。創(chuàng)建和銷毀都是重量級的系統(tǒng)函數(shù)。
2> 線程越多,Context Switch就越多,而Context Switch是一個比較重的操作,會無謂浪費大量的CPU。如果線程數(shù)過高,可能執(zhí)行線程切換的時間甚至?xí)笥诰€程執(zhí)行的時間,這時候帶來的表現(xiàn)往往是系統(tǒng)load偏高、CPU sy使用率特別高(超過20%以上),導(dǎo)致系統(tǒng)幾乎陷入不可用的狀態(tài)。
3> 每個線程會占用一定的內(nèi)存作為線程的棧。比如有1000個線程同時運行,每個占用1MB內(nèi)存,就占用了1個G的內(nèi)存。
4>?容易造成鋸齒狀的系統(tǒng)負(fù)載。因為系統(tǒng)負(fù)載是用活動線程數(shù)或CPU核心數(shù),一旦線程數(shù)量高但外部網(wǎng)絡(luò)環(huán)境不是很穩(wěn)定,就很容易造成大量請求的結(jié)果同時返回,激活大量阻塞線程從而使系統(tǒng)負(fù)載壓力過大。
偽異步I/O通信框架采用了線程池實現(xiàn),因此避免了為每個請求都創(chuàng)建一個獨立線程造成的線程資源耗盡問題。不過因為它的底層任然是同步阻塞的BIO模型,因此無法從根本上解決問題。這樣會限制最大并發(fā)的連接數(shù)。比如你弄4個線程,那么最大4個線程都Block了就沒法響應(yīng)更多請求了。
要是操作IO接口時,操作系統(tǒng)能夠總是直接告訴有沒有數(shù)據(jù),而不是Block去等就好了。于是,NIO登場。
二、NIO
NIO是一種同步非阻塞的I/O模型。NIO一個重要的特點是:socket主要的讀、寫、注冊和接收函數(shù),在等待就緒階段都是非阻塞的,真正的I/O操作是同步阻塞的(消耗CPU但性能非常高)。
1. BIO和NIO的區(qū)別是什么呢?
在BIO模式下,調(diào)用read,如果發(fā)現(xiàn)沒數(shù)據(jù)已經(jīng)到達(dá),就會Block住。
在NIO模式下,調(diào)用read,如果發(fā)現(xiàn)沒數(shù)據(jù)已經(jīng)到達(dá),就會立刻返回-1, 并且errno被設(shè)為EAGAIN。在有些文檔中寫的是會返回EWOULDBLOCK。實際上,在Linux下EAGAIN和EWOULDBLOCK是一樣的,即#define EWOULDBLOCK EAGAIN。
NIO工作方式就是輪詢,不斷的嘗試有沒有數(shù)據(jù)到達(dá),有了就處理,沒有(得到EWOULDBLOCK或者EAGAIN)就等一小會再試。這比之前BIO好多了,起碼程序不會被卡死了。
NIO中的N可以理解為Non-blocking,不單純是New。它支持面向緩沖的,基于通道的I/O操作方法。 NIO提供了與傳統(tǒng)BIO模型中的?Socket?和?ServerSocket?相對應(yīng)的?SocketChannel?和?ServerSocketChannel?兩種不同的套接字通道實現(xiàn),兩種通道都支持阻塞和非阻塞兩種模式。阻塞模式使用就像傳統(tǒng)中的支持一樣,比較簡單,但是性能和可靠性都不好;非阻塞模式正好與之相反。對于低負(fù)載、低并發(fā)的應(yīng)用程序,可以使用同步阻塞I/O來提升開發(fā)速率和更好的維護(hù)性;對于高負(fù)載、高并發(fā)的(網(wǎng)絡(luò))應(yīng)用,應(yīng)使用 NIO 的非阻塞模式來開發(fā)。
1.1 NIO的特性
1> Non-blocking IO(非阻塞IO),IO流是阻塞的,NIO流是不阻塞的。
NIO使我們可以進(jìn)行非阻塞IO操作。比如說,單線程從通道中讀取數(shù)據(jù)到buffer,同時可以繼續(xù)做別的事情,當(dāng)數(shù)據(jù)讀取到buffer中后,線程再繼續(xù)處理數(shù)據(jù)。寫數(shù)據(jù)也是一樣的。另外,非阻塞寫也是如此。一個線程請求寫入一些數(shù)據(jù)到某通道,但不需要等待它完全寫入,這個線程同時可以去做別的事情。
IO的各種流是阻塞的。這意味著,當(dāng)一個線程調(diào)用?read()?或?write()?時,該線程被阻塞,直到有一些數(shù)據(jù)被讀取,或數(shù)據(jù)完全寫入。該線程在此期間不能再干任何事情了。
2>?Buffer(緩沖區(qū)),IO 面向流(Stream oriented),而 NIO 面向緩沖區(qū)(Buffer oriented)。
Buffer是一個對象,它包含一些要寫入或者要讀出的數(shù)據(jù)。在NIO類庫中加入Buffer對象,體現(xiàn)了新庫與原I/O的一個重要區(qū)別。在面向流的I/O中·可以將數(shù)據(jù)直接寫入或者將數(shù)據(jù)直接讀到 Stream 對象中。雖然 Stream 中也有 Buffer 開頭的擴展類,但只是流的包裝類,還是從流讀到緩沖區(qū),而 NIO 卻是直接讀到 Buffer 中進(jìn)行操作。在NIO厙中,所有數(shù)據(jù)都是用緩沖區(qū)處理的。在讀取數(shù)據(jù)時,它是直接讀到緩沖區(qū)中的; 在寫入數(shù)據(jù)時,寫入到緩沖區(qū)中。任何時候訪問NIO中的數(shù)據(jù),都是通過緩沖區(qū)進(jìn)行操作。最常用的緩沖區(qū)是 ByteBuffer,一個 ByteBuffer 提供了一組功能用于操作 byte 數(shù)組。除了ByteBuffer,還有其他的一些緩沖區(qū)。
3>?Channel (通道),NIO 通過Channel(通道) 進(jìn)行讀寫。
通道是雙向的,可讀也可寫,而流的讀寫是單向的。無論讀寫,通道只能和Buffer交互。因為 Buffer,通道可以異步地讀寫。
數(shù)據(jù)讀取寫入示意圖:
從通道進(jìn)行數(shù)據(jù)讀取 :創(chuàng)建一個緩沖區(qū),然后請求通道讀取數(shù)據(jù)。
從通道進(jìn)行數(shù)據(jù)寫入 :創(chuàng)建一個緩沖區(qū),填充數(shù)據(jù),并要求通道寫入數(shù)據(jù)。

4>?Selectors(選擇器),NIO有選擇器,而IO沒有。
選擇器用于使用單個線程處理多個通道。因此,它需要較少的線程來處理這些通道。線程之間的切換對于操作系統(tǒng)來說是昂貴的。 因此,為了提高系統(tǒng)效率選擇器是有用的。
NIO的讀寫函數(shù)可以立刻返回,這就給了我們不開線程利用CPU的最好機會:如果一個連接不能讀寫(socket.read()返回0或者socket.write()返回0),我們可以把這件事記下來,記錄的方式通常是在Selector上注冊標(biāo)記位,然后切換到其它就緒的連接(channel)繼續(xù)進(jìn)行讀寫。

2.NIO相對于BIO的提升
NIO是怎么解決掉線程的瓶頸并處理海量連接的:
NIO由原來的阻塞讀寫(占用線程)變成了單線程輪詢事件,找到可以進(jìn)行讀寫的網(wǎng)絡(luò)描述符進(jìn)行讀寫。除了事件的輪詢是阻塞的(沒有可干的事情必須要阻塞),剩余的I/O操作都是純CPU操作,沒有必要開啟多線程。
并且由于線程的節(jié)約,連接數(shù)大的時候因為線程切換帶來的問題也隨之解決,進(jìn)而為處理海量連接提供了可能。
單線程處理I/O的效率確實非常高,沒有線程切換,只是拼命的讀、寫、選擇事件。但現(xiàn)在的服務(wù)器,一般都是多核處理器,如果能夠利用多核心進(jìn)行I/O,無疑對效率會有更大的提高。
3. 引入了新的問題
但這樣會帶來兩個新問題:
1>如果有大量文件描述符都要等,那么就得一個一個的read。這會帶來大量的Context Switch(read是系統(tǒng)調(diào)用,每調(diào)用一次就得在用戶態(tài)和核心態(tài)切換一次)
2>休息一會的時間不好把握。這里是要猜多久之后數(shù)據(jù)才能到。等待時間設(shè)的太長,程序響應(yīng)延遲就過大;設(shè)的太短,就會造成過于頻繁的重試,干耗CPU而已。
4. 如何解決?
要是操作系統(tǒng)能一口氣告訴程序,哪些數(shù)據(jù)到了就好了。
于是IO多路復(fù)用被搞出來解決這個問題。
三、IO多路復(fù)用
IO多路復(fù)用(IO Multiplexing) 是這么一種機制:程序注冊一組socket文件描述符給操作系統(tǒng),表示“我要監(jiān)視這些fd是否有IO事件發(fā)生,有了就告訴程序處理”。
IO多路復(fù)用是要和NIO一起使用的。盡管在操作系統(tǒng)級別,NIO和IO多路復(fù)用是兩個相對獨立的事情。NIO僅僅是指IO API總是能立刻返回,不會被Blocking;而IO多路復(fù)用僅僅是操作系統(tǒng)提供的一種便利的通知機制。操作系統(tǒng)并不會強制這倆必須得一起用——你可以用NIO,但不用IO多路復(fù)用;也可以只用IO多路復(fù)用 + BIO,這時效果還是當(dāng)前線程被卡住。IO多路復(fù)用和NIO是要配合一起使用才有實際意義。因此,在使用IO多路復(fù)用之前,請總是先把fd設(shè)為O_NONBLOCK。
1. IO多路復(fù)用的一些特點
1>?IO多路復(fù)用說的是多個Socket,只不過操作系統(tǒng)是一起監(jiān)聽他們的事件而已,并不是指多個數(shù)據(jù)流共享同一個Socket。
2>?IO多路復(fù)用是NIO,但并不是不會Block的。其實IO多路復(fù)用的關(guān)鍵API調(diào)用(select,poll,epoll_wait)總是Block的。
3>?IO多路復(fù)用和NIO一起并不會減少了IO。實際上,IO本身(網(wǎng)絡(luò)數(shù)據(jù)的收發(fā))無論用不用IO多路復(fù)用和NIO,都沒有變化。請求的數(shù)據(jù)該是多少還是多少;網(wǎng)絡(luò)上該傳輸多少數(shù)據(jù)還是多少數(shù)據(jù)。IO多路復(fù)用和NIO一起僅僅是解決了調(diào)度的問題,避免CPU在這個過程中的浪費,使系統(tǒng)的瓶頸更容易觸達(dá)到網(wǎng)絡(luò)帶寬,而非CPU或者內(nèi)存。要提高IO吞吐,還是提高硬件的容量(例如,用支持更大帶寬的網(wǎng)線、網(wǎng)卡和交換機)和依靠并發(fā)傳輸(例如HDFS的數(shù)據(jù)多副本并發(fā)傳輸)。
2.?操作系統(tǒng)級別提供了一些接口來支持IO多路復(fù)用
比較老的是select和poll。
2.1 select
2.1.1 select工作原理
它接受3個文件描述符的數(shù)組,分別監(jiān)聽讀取(readfds),寫入(writefds)和異常(expectfds)事件。首先,為了select需要構(gòu)造一個fd數(shù)組(這里為了簡化,沒有構(gòu)造要監(jiān)聽寫入和異常事件的fd數(shù)組)。之后,用select監(jiān)聽了read_fds中的多個socket的讀取時間。調(diào)用select后,程序會Block住,直到一個事件發(fā)生了,或者等到最大1秒鐘(tv定義了這個時間長度)就返回。之后,需要遍歷所有注冊的fd,挨個檢查哪個fd有事件到達(dá)(FD_ISSET返回true)。如果是,就說明數(shù)據(jù)已經(jīng)到達(dá)了,可以讀取fd了。讀取后就可以進(jìn)行數(shù)據(jù)的處理。
2.1.2 select缺點
1> select能夠支持的最大的fd數(shù)組的長度是1024。這對要處理高并發(fā)的web服務(wù)器是不可接受的。
2> fd數(shù)組按照監(jiān)聽的事件分為了3個數(shù)組,為了這3個數(shù)組要分配3段內(nèi)存去構(gòu)造,而且每次調(diào)用select前都要重設(shè)它們(因為select會改這3個數(shù)組);調(diào)用select后,這3數(shù)組要從用戶態(tài)復(fù)制一份到內(nèi)核態(tài);事件到達(dá)后,要遍歷這3數(shù)組。很不爽。
3> select返回后要挨個遍歷fd,找到被“SET”的那些進(jìn)行處理。這樣比較低效。
4> select是無狀態(tài)的,即每次調(diào)用select,內(nèi)核都要重新檢查所有被注冊的fd的狀態(tài)。select返回后,這些狀態(tài)就被返回了,內(nèi)核不會記住它們;到了下一次調(diào)用,內(nèi)核依然要重新檢查一遍。于是查詢的效率很低。
2.2 poll
2.2.1 poll工作原理
poll與select類似,poll的代碼例子和select也差不多。poll這個單詞的意思是“輪詢”,所以很多中文資料都會提到對IO進(jìn)行“輪詢”。上面說的select和下文說的epoll本質(zhì)上都是輪詢。
2.2.2 poll相對于select的提升
poll優(yōu)化了select的一些問題。比如不再有3個數(shù)組,而是1個polldfd結(jié)構(gòu)的數(shù)組了,并且也不需要每次重設(shè)了。數(shù)組的個數(shù)也沒有了1024的限制。
2.2.3 poll仍然存在的問題
1> 依然是無狀態(tài)的,性能的問題與select差不多一樣;
2> 應(yīng)用程序仍然無法很方便的拿到那些“有事件發(fā)生的fd“,還是需要遍歷所有注冊的fd。
目前來看,高性能的web服務(wù)器都不會使用select和poll。他們倆存在的意義僅僅是“兼容性”,因為很多操作系統(tǒng)都實現(xiàn)了這兩個系統(tǒng)調(diào)用。
2.3 epoll
2.3.1 epoll工作原理
第一步與select和poll不同,要使用epoll是需要先創(chuàng)建一下的。
epoll_create在內(nèi)核層創(chuàng)建了一個數(shù)據(jù)表,接口會返回一個“epoll的文件描述符”指向這個表。注意,接口參數(shù)是一個表達(dá)要監(jiān)聽事件列表的長度的數(shù)值。但不用太在意,因為epoll內(nèi)部隨后會根據(jù)事件注冊和事件注銷動態(tài)調(diào)整epoll中表格的大小。

為什么epoll要創(chuàng)建一個用文件描述符來指向的表呢?這里有兩個好處:
1> epoll是有狀態(tài)的,不像select和poll那樣每次都要重新傳入所有要監(jiān)聽的fd,這避免了很多無謂的數(shù)據(jù)復(fù)制。epoll的數(shù)據(jù)是用接口epoll_ctl來管理的(增、刪、改)。
2> epoll文件描述符在進(jìn)程被fork時,子進(jìn)程是可以繼承的。這可以給對多進(jìn)程共享一份epoll數(shù)據(jù),實現(xiàn)并行監(jiān)聽網(wǎng)絡(luò)請求帶來便利。
epoll創(chuàng)建后,第二步是使用epoll_ctl接口來注冊要監(jiān)聽的事件。
其中第一個參數(shù)就是上面創(chuàng)建的epfd。第二個參數(shù)op表示如何對文件名進(jìn)行操作,共有3種。
EPOLL_CTL_ADD- 注冊一個事件
EPOLL_CTL_DEL- 取消一個事件的注冊
EPOLL_CTL_MOD- 修改一個事件的注冊
第三個參數(shù)是要操作的fd,這里必須是支持NIO的fd(比如socket)。
第四個參數(shù)是一個epoll_event的類型的數(shù)據(jù),表達(dá)了注冊的事件的具體信息。
通過epoll_ctl就可以靈活的注冊/取消注冊/修改注冊某個fd的某些事件。

第三步,使用epoll_wait來等待事件的發(fā)生。
特別留意,這一步是"block"的。只有當(dāng)注冊的事件至少有一個發(fā)生,或者timeout達(dá)到時,該調(diào)用才會返回。這與select和poll幾乎一致。但不一樣的地方是evlist,它是epoll_wait的返回數(shù)組,里面只包含那些被觸發(fā)的事件對應(yīng)的fd,而不是像select和poll那樣返回所有注冊的fd。

所有的基于IO多路復(fù)用的代碼都會遵循這樣的寫法:注冊——監(jiān)聽事件——處理——再注冊,無限循環(huán)下去。
2.3.1 epoll的優(yōu)勢
為什么epoll的性能比select和poll要強呢?select和poll每次都需要把完成的fd列表傳入到內(nèi)核,迫使內(nèi)核每次必須從頭掃描到尾。而epoll完全是反過來的。epoll在內(nèi)核的數(shù)據(jù)被建立好了之后,每次某個被監(jiān)聽的fd一旦有事件發(fā)生,內(nèi)核就直接標(biāo)記之。epoll_wait調(diào)用時,會嘗試直接讀取到當(dāng)時已經(jīng)標(biāo)記好的fd列表,如果沒有就會進(jìn)入等待狀態(tài)。
同時,epoll_wait直接只返回了被觸發(fā)的fd列表,這樣上層應(yīng)用寫起來也輕松愉快,再也不用從大量注冊的fd中篩選出有事件的fd了。
簡單說就是select和poll的代價是"O(所有注冊事件fd的數(shù)量)",而epoll的代價是"O(發(fā)生事件fd的數(shù)量)"。于是,高性能網(wǎng)絡(luò)服務(wù)器的場景特別適合用epoll來實現(xiàn)——因為大多數(shù)網(wǎng)絡(luò)服務(wù)器都有這樣的模式:同時要監(jiān)聽大量(幾千,幾萬,幾十萬甚至更多)的網(wǎng)絡(luò)連接,但是短時間內(nèi)發(fā)生的事件非常少。
但是,假設(shè)發(fā)生事件的fd的數(shù)量接近所有注冊事件fd的數(shù)量,那么epoll的優(yōu)勢就沒有了,其性能表現(xiàn)會和poll和select差不多。
epoll除了性能優(yōu)勢,還有一個優(yōu)點——同時支持水平觸發(fā)(Level Trigger)和邊沿觸發(fā)(Edge Trigger)。
3.?水平觸發(fā)和邊沿觸發(fā)
默認(rèn)情況下,epoll使用水平觸發(fā),這與select和poll的行為完全一致。在水平觸發(fā)下,epoll頂多算是一個“跑得更快的poll”。
而一旦在注冊事件時使用了EPOLLET標(biāo)記(如上文中的例子),那么將其視為邊沿觸發(fā)(或者有地方叫邊緣觸發(fā),一個意思)。那么到底什么水平觸發(fā)和邊沿觸發(fā)呢?
考慮下圖中的例子。有兩個socket的fd——fd1和fd2。我們設(shè)定監(jiān)聽f1的“水平觸發(fā)讀事件“,監(jiān)聽fd2的”邊沿觸發(fā)讀事件“。我們使用在時刻t1,使用epoll_wait監(jiān)聽他們的事件。在時刻t2時,兩個fd都到了100bytes數(shù)據(jù),于是在時刻t3,epoll_wait返回了兩個fd進(jìn)行處理。在t4,我們故意不讀取所有的數(shù)據(jù)出來,只各自讀50bytes。然后在t5重新注冊兩個事件并監(jiān)聽。在t6時,只有fd1會返回,因為fd1里的數(shù)據(jù)沒有讀完,仍然處于“被觸發(fā)”狀態(tài);而fd2不會被返回,因為沒有新數(shù)據(jù)到達(dá)。

這個例子很明確的顯示了水平觸發(fā)和邊沿觸發(fā)的區(qū)別。
1> 水平觸發(fā)只關(guān)心文件描述符中是否還有沒完成處理的數(shù)據(jù),如果有,不管怎樣epoll_wait,總是會被返回。簡單說——水平觸發(fā)代表了一種“狀態(tài)”。
2> 邊沿觸發(fā)只關(guān)心文件描述符是否有新的事件產(chǎn)生,如果有,則返回;如果返回過一次,不管程序是否處理了,只要沒有新的事件產(chǎn)生,epoll_wait不會再認(rèn)為這個fd被“觸發(fā)”了。簡單說——邊沿觸發(fā)代表了一個“事件”。那么邊沿觸發(fā)怎么才能迫使新事件產(chǎn)生呢?一般需要反復(fù)調(diào)用read/write這樣的IO接口,直到得到了EAGAIN錯誤碼,再去嘗試epoll_wait才有可能得到下次事件。
那么為什么需要邊沿觸發(fā)呢?
邊沿觸發(fā)把如何處理數(shù)據(jù)的控制權(quán)完全交給了開發(fā)者,提供了巨大的靈活性。比如,讀取一個http的請求,開發(fā)者可以決定只讀取http中的headers數(shù)據(jù)就停下來,然后根據(jù)業(yè)務(wù)邏輯判斷是否要繼續(xù)讀(比如需要調(diào)用另外一個服務(wù)來決定是否繼續(xù)讀)。而不是次次被socket尚有數(shù)據(jù)的狀態(tài)煩擾;寫入數(shù)據(jù)時也是如此。比如希望將一個資源A寫入到socket。當(dāng)socket的buffer充足時,epoll_wait會返回這個fd是準(zhǔn)備好的。但是資源A此時不一定準(zhǔn)備好。如果使用水平觸發(fā),每次經(jīng)過epoll_wait也總會被打擾。在邊沿觸發(fā)下,開發(fā)者有機會更精細(xì)的定制這里的控制邏輯。
但不好的一面時,邊沿觸發(fā)也大大的提高了編程的難度。一不留神,可能就會miss掉處理部分socket數(shù)據(jù)的機會。如果沒有很好的根據(jù)EAGAIN來“重置”一個fd,就會造成此fd永遠(yuǎn)沒有新事件產(chǎn)生,進(jìn)而導(dǎo)致餓死相關(guān)的處理代碼。
本文參考鏈接:http://www.itdecent.cn/p/ef418ccf2f7d