在談及網(wǎng)絡(luò)IO的時(shí)候總避不開阻塞、非阻塞、同步、異步、IO多路復(fù)用、select、poll、epoll等這幾個(gè)詞語。在面試的時(shí)候也會(huì)被經(jīng)常問到這幾個(gè)的區(qū)別。本文就來講一下這幾個(gè)詞語的含義、區(qū)別以及使用方式。
Unix網(wǎng)絡(luò)編程一書中作者給出了五種IO模型:
1、BlockingIO - 阻塞IO
2、NoneBlockingIO - 非阻塞IO
3、IO multiplexing - IO多路復(fù)用
4、signal driven IO - 信號驅(qū)動(dòng)IO
5、asynchronous IO - 異步IO
這五種IO模型中前四個(gè)都是同步的IO,只有最后一個(gè)是異步IO。信號驅(qū)動(dòng)IO使用的比較少,重點(diǎn)介紹其他幾種IO以及在Java中的應(yīng)用。
阻塞、非阻塞、同步、異步以及IO多路復(fù)用
在進(jìn)行網(wǎng)絡(luò)IO的時(shí)候會(huì)涉及到用戶態(tài)和內(nèi)核態(tài),并且在用戶態(tài)和內(nèi)核態(tài)之間會(huì)發(fā)生數(shù)據(jù)交換,從這個(gè)角度來說我們可以把IO抽象成兩個(gè)階段:1、用戶態(tài)等待內(nèi)核態(tài)數(shù)據(jù)準(zhǔn)備好,2、將數(shù)據(jù)從內(nèi)核態(tài)拷貝到用戶態(tài)。之所以會(huì)有同步、異步、阻塞和非阻塞這幾種說法就是根據(jù)程序在這兩個(gè)階段的處理方式不同而產(chǎn)生的。
同步阻塞
顯示大圖
當(dāng)在用戶態(tài)調(diào)用read操作的時(shí)候,如果這時(shí)候kernel還沒有準(zhǔn)備好數(shù)據(jù),那么用戶態(tài)會(huì)一直阻塞等待,直到有數(shù)據(jù)返回。當(dāng)kernel準(zhǔn)備好數(shù)據(jù)之后,用戶態(tài)繼續(xù)等待kernel把數(shù)據(jù)從內(nèi)核態(tài)拷貝到用戶態(tài)之后才可以使用。這里會(huì)發(fā)生兩種等待:一個(gè)是用戶態(tài)等待kernel有數(shù)據(jù)可以讀,另外一個(gè)是當(dāng)有數(shù)據(jù)可讀時(shí)用戶態(tài)等待kernel把數(shù)據(jù)拷貝到用戶態(tài)。
在Java中同步阻塞的實(shí)現(xiàn)對應(yīng)的是傳統(tǒng)的文件IO操作以及Socket的accept的過程。在Socket調(diào)用accept的時(shí)候,程序會(huì)一直等待知道有描述符就緒,并且把就緒的數(shù)據(jù)拷貝到用戶態(tài),然后程序中就可以拿到對應(yīng)的數(shù)據(jù)。
同步非阻塞
對比第一張同步阻塞IO的圖就會(huì)發(fā)現(xiàn),在同步非阻塞模型下第一個(gè)階段是不等待的,無論有沒有數(shù)據(jù)準(zhǔn)備好,都是立即返回。第二個(gè)階段仍然是需要等待的,用戶態(tài)需要等待內(nèi)核態(tài)把數(shù)據(jù)拷貝過來才能使用。對于同步非阻塞模式的處理,需要每隔一段時(shí)間就去詢問一下內(nèi)核數(shù)據(jù)是不是可以讀了,如果內(nèi)核說可以,那么就開始第二階段等待。
IO多路復(fù)用
IO多路復(fù)用也是同步的。
IO多路復(fù)用的方式看起來跟同步阻塞是一樣的,兩個(gè)階段都是阻塞的,但是IO多路復(fù)用可以實(shí)現(xiàn)以較小的代價(jià)同時(shí)監(jiān)聽多個(gè)IO。通常情況下是通過一個(gè)線程來同時(shí)監(jiān)聽多個(gè)描述符,只要任何一個(gè)滿足就緒條件,那么內(nèi)核態(tài)就返回。IO多路復(fù)用使得傳統(tǒng)的每請求每線程的處理方式得到解耦,一個(gè)線程可以同時(shí)處理多個(gè)IO請求,然后交到后面的線程池里處理,這也是netty等框架的處理方式,所謂的reactor模式。IO多路復(fù)用的實(shí)現(xiàn)依賴于操作系統(tǒng)的select、poll和epoll,后面會(huì)詳細(xì)介紹這幾個(gè)系統(tǒng)調(diào)用。
IO多路復(fù)用在Java中的實(shí)現(xiàn)方式是在Socket編程中使用非阻塞模式,然后配置感興趣的事件,通過調(diào)用select函數(shù)來實(shí)現(xiàn)。select函數(shù)就是對應(yīng)的第一個(gè)階段。如果給select配置了超時(shí)參數(shù),在指定時(shí)間內(nèi)沒有感興趣事件發(fā)生的話,select調(diào)用也會(huì)返回,這也是為什么要做非阻塞模式下運(yùn)行。
異步IO
異步模式下,前面提到的兩個(gè)階段都不會(huì)等待。使用異步模式,用戶態(tài)調(diào)用read方法的時(shí)候,相當(dāng)于告訴內(nèi)核數(shù)據(jù)發(fā)送給我之后告訴我一聲我先去干別的事情了。在這兩個(gè)階段都不會(huì)等待,只需要在內(nèi)核態(tài)通知數(shù)據(jù)準(zhǔn)備好之后使用即可。通常情況下使用異步模式都會(huì)使用callback,當(dāng)數(shù)據(jù)可用之后執(zhí)行callback函數(shù)。
IO多路復(fù)用
現(xiàn)在用Java開發(fā)的網(wǎng)絡(luò)服務(wù)器通常采用IO多路復(fù)用的方式來加快網(wǎng)絡(luò)IO操作,例如Netty、Tomcat等。IO多路復(fù)用的基礎(chǔ)是select、poll和epoll。這三個(gè)函數(shù)是從操作系統(tǒng)的角度上支持的IO多路復(fù)用的操作,下面就分別來看一下這三個(gè)函數(shù)。
select
函數(shù)簽名如下:
int select(int maxfdp1, fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)
maxfdp1為指定的待監(jiān)聽的描述符的個(gè)數(shù),因?yàn)槊枋龇菑?開始的,所以需要加1
readset為要監(jiān)聽的讀描述符
writeset為要監(jiān)聽的寫描述符
exceptset為要監(jiān)聽的異常描述符
timeout監(jiān)聽沒有準(zhǔn)備好的描述符的話,多久可以返回,支持按照秒或者毫秒來配置時(shí)間
select操作的邏輯是首先將要監(jiān)聽的讀、寫以及異常描述符拷貝到內(nèi)核空間,然后遍歷所有的描述符,如果有感興趣的事件發(fā)生,那么就返回。
select在使用的過程中有三個(gè)問題:
1、被監(jiān)控的fds(描述符)集合限制為1024,1024太小了
2、需要將描述符集合從用戶空間拷貝到內(nèi)核空間
3、當(dāng)有描述符可操作的時(shí)候都需要遍歷一下整個(gè)描述符集合才能知道哪個(gè)是可操作的,效率很低。
poll
函數(shù)簽名如下:
int poll(struct pollfd[] fds, unsigned int nfds, int timeout);
poll操作與select操作類似,仍舊避免不了描述符從用戶空間拷貝到內(nèi)核空間,但是poll不再有1024個(gè)描述符的限制。對于事件的觸發(fā)通知還是使用遍歷所有描述符的方式,因此在大量連接的情況下也存在遍歷低效的問題。poll函數(shù)在傳遞參數(shù)的時(shí)候統(tǒng)一的將要監(jiān)聽的描述符和事件封裝在了pollfd結(jié)構(gòu)體數(shù)組中。
epoll
epoll有三個(gè)方法:epoll_create、epoll_ctl和epoll_wait。epoll_create是創(chuàng)建一個(gè)epoll句柄;epoll_ctl是注冊要監(jiān)聽的事件類型;epoll_wait則是等待事件的產(chǎn)生。 通過這三個(gè)方法epoll解決了select的三個(gè)問題。
1、1024數(shù)量限制的問題
通過epoll_create方法來創(chuàng)建一個(gè)epoll句柄,這個(gè)句柄監(jiān)聽的描述符的數(shù)量不再有限制。
2、文件描述符頻繁從用戶空間拷貝到內(nèi)核空間的問題
通過觀察select的操作會(huì)發(fā)現(xiàn)描述符從用戶空間到內(nèi)核空間拷貝發(fā)生在調(diào)用select方法的時(shí)候,只要沒有注冊新的事件或者取消注冊事件,每次拷貝的描述符都是一樣的。因此epoll引入了epoll_ctl調(diào)用,該方法用于注冊新事件和取消注冊事件。而在epoll_wait的時(shí)候并不會(huì)拷貝描述符,描述符始終存在于內(nèi)核空間,當(dāng)需要修改的時(shí)候只要調(diào)用epoll_ctl修改一下內(nèi)核的描述符即可。如此一來便省去了描述符來回拷貝的開銷。
3、文件描述符可操作的時(shí)候遍歷整個(gè)描述符集合的問題
在調(diào)用epoll_ctl注冊感興趣的事件的時(shí)候,實(shí)際上會(huì)為設(shè)置的事件添加一個(gè)回調(diào)函數(shù),當(dāng)對應(yīng)的感興趣的事件發(fā)生的時(shí)候,回調(diào)函數(shù)就會(huì)觸發(fā),然后將自己加到一個(gè)鏈表中。epoll_wait函數(shù)的作用就是去查看這個(gè)鏈表中有沒有已經(jīng)準(zhǔn)備就緒的事件,如果有的話就通知應(yīng)用程序處理,如此操作epoll_wait只需要遍歷就緒的事件描述符即可。
epoll在Java中的使用
目前針對Java服務(wù)器的非阻塞編程基本都是基于epoll的。在進(jìn)行非阻塞編程的時(shí)候有兩個(gè)步驟:1、注冊感興趣的事情;2、調(diào)用select方法,查找感興趣的事件。
注冊感興趣的事件
我們在編寫Socket的非阻塞代碼的時(shí)候需要在Selector上注冊感興趣的事情,通常寫法是
serverSocketChannel.register(selector, SelectionKey.XXX)。來看一下這行代碼背后的執(zhí)行邏輯是什么樣的。
注冊的時(shí)候?qū)嶋H執(zhí)行的是EPollSelectorImp。該方法主要有以下三步:
1、implRegister方法。在fdToKey的Map中插入channel對應(yīng)的文件描述法和SelectionKey的映射,當(dāng)做注冊Channel、關(guān)閉Channel、取消注冊等操作是都是操作此Map。
2、往pollWrapper[Epoll實(shí)例]中放入channel實(shí)例。
3、往keys[HashSet]中放入SelectionKey
select方法
通過Java的Selector.select方法來獲取準(zhǔn)備好的鍵的時(shí)候?qū)嶋H執(zhí)行的代碼如下:
首先調(diào)用EPollArrayWrapper的poll方法,該方法做兩件事:1、調(diào)用epollCtl方法向epoll中注冊感興趣的事件;2、調(diào)用epollWait方法返回已就緒的文件描述符集合
然后調(diào)用updateSelectedKeys方法調(diào)用把epoll中就緒的文件描述符加到ready隊(duì)列中等待上層應(yīng)用處理, updateSelectedKeys通過fdToKey查找文件描述符對應(yīng)的SelectionKey,并在SelectionKey對應(yīng)的channel中添加對應(yīng)的事件到ready隊(duì)列。
水平觸發(fā)LT與邊緣觸發(fā)ET
epoll支持兩種觸發(fā)模式,分別是水平觸發(fā)和邊緣觸發(fā)。
LT是缺省的工作方式,并且同時(shí)支持block和no-block socket。在這種做法中,內(nèi)核告訴你一個(gè)文件描述符是否就緒了,然后你可以對這個(gè)就緒的fd進(jìn)行IO操作。如果你不作任何操作,內(nèi)核還是會(huì)繼續(xù)通知你的。
ET是高速工作方式,只支持no-block socket。在這種模式下,當(dāng)描述符從未就緒變?yōu)榫途w時(shí),內(nèi)核會(huì)通知你一次,并且除非你做了某些操作導(dǎo)致那個(gè)文件描述符不再為就緒狀態(tài)了,否則不會(huì)再次發(fā)送通知。
可以看到,本來內(nèi)核在被DMA中斷,捕獲到IO設(shè)備來數(shù)據(jù)后,只需要查找這個(gè)數(shù)據(jù)屬于哪個(gè)文件描述符,進(jìn)而通知線程里等待的函數(shù)即可,但是,LT要求內(nèi)核在通知階段還要繼續(xù)再掃描一次剛才所建立的內(nèi)核fd和io對應(yīng)的那個(gè)數(shù)組,因?yàn)閼?yīng)用程序可能沒有真正去讀上次通知有數(shù)據(jù)后的那些fd,這種溝通方式效率是很低下的,只是方便編程而已;
JDK并沒有實(shí)現(xiàn)邊緣觸發(fā),關(guān)于邊緣觸發(fā)和水平觸發(fā)的差異簡單列舉如下,邊緣觸發(fā)的性能更高,但編程難度也更高,netty就重新實(shí)現(xiàn)了Epoll機(jī)制,采用邊緣觸發(fā)方式;另外像nginx等也采用的是邊緣觸發(fā)。