一文讀懂阻塞、非阻塞、同步、異步IO


介紹

    在談及網絡IO的時候總避不開阻塞、非阻塞、同步、異步、IO多路復用、select、poll、epoll等這幾個詞語。在面試的時候也會被經常問到這幾個的區別。本文就來講一下這幾個詞語的含義、區別以及使用方式。
Unix網絡編程一書中作者給出了五種IO模型:
1、BlockingIO - 阻塞IO
2、NoneBlockingIO - 非阻塞IO
3、IO multiplexing - IO多路復用
4、signal driven IO - 信號驅動IO
5、asynchronous IO - 異步IO
這五種IO模型中前四個都是同步的IO,只有最后一個是異步IO。信號驅動IO使用的比較少,重點介紹其他幾種IO以及在Java中的應用。

阻塞、非阻塞、同步、異步以及IO多路復用

    在進行網絡IO的時候會涉及到用戶態和內核態,並且在用戶態和內核態之間會發生數據交換,從這個角度來說我們可以把IO抽象成兩個階段:1、用戶態等待內核態數據准備好,2、將數據從內核態拷貝到用戶態。之所以會有同步、異步、阻塞和非阻塞這幾種說法就是根據程序在這兩個階段的處理方式不同而產生的。

同步阻塞

        
    當在用戶態調用read操作的時候,如果這時候kernel還沒有准備好數據,那么用戶態會一直阻塞等待,直到有數據返回。當kernel准備好數據之后,用戶態繼續等待kernel把數據從內核態拷貝到用戶態之后才可以使用。這里會發生兩種等待:一個是用戶態等待kernel有數據可以讀,另外一個是當有數據可讀時用戶態等待kernel把數據拷貝到用戶態。
    在Java中同步阻塞的實現對應的是傳統的文件IO操作以及Socket的accept的過程。在Socket調用accept的時候,程序會一直等待知道有描述符就緒,並且把就緒的數據拷貝到用戶態,然后程序中就可以拿到對應的數據。

同步非阻塞

        
        對比第一張同步阻塞IO的圖就會發現,在同步非阻塞模型下第一個階段是不等待的,無論有沒有數據准備好,都是立即返回。第二個階段仍然是需要等待的,用戶態需要等待內核態把數據拷貝過來才能使用。對於同步非阻塞模式的處理,需要每隔一段時間就去詢問一下內核數據是不是可以讀了,如果內核說可以,那么就開始第二階段等待。

IO多路復用

    IO多路復用也是同步的。
        
    IO多路復用的方式看起來跟同步阻塞是一樣的,兩個階段都是阻塞的,但是IO多路復用可以實現以較小的代價同時監聽多個IO。通常情況下是通過一個線程來同時監聽多個描述符,只要任何一個滿足就緒條件,那么內核態就返回。IO多路復用使得傳統的每請求每線程的處理方式得到解耦,一個線程可以同時處理多個IO請求,然后交到后面的線程池里處理,這也是netty等框架的處理方式,所謂的reactor模式。IO多路復用的實現依賴於操作系統的select、poll和epoll,后面會詳細介紹這幾個系統調用。
    IO多路復用在Java中的實現方式是在Socket編程中使用非阻塞模式,然后配置感興趣的事件,通過調用select函數來實現。select函數就是對應的第一個階段。如果給select配置了超時參數,在指定時間內沒有感興趣事件發生的話,select調用也會返回,這也是為什么要做非阻塞模式下運行。

異步IO

        
        異步模式下,前面提到的兩個階段都不會等待。使用異步模式,用戶態調用read方法的時候,相當於告訴內核數據發送給我之后告訴我一聲我先去干別的事情了。在這兩個階段都不會等待,只需要在內核態通知數據准備好之后使用即可。通常情況下使用異步模式都會使用callback,當數據可用之后執行callback函數。

IO多路復用

    現在用Java開發的網絡服務器通常采用IO多路復用的方式來加快網絡IO操作,例如Netty、Tomcat等。IO多路復用的基礎是select、poll和epoll。這三個函數是從操作系統的角度上支持的IO多路復用的操作,下面就分別來看一下這三個函數。

select

函數簽名如下:

int select(int maxfdp1, fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)

maxfdp1為指定的待監聽的描述符的個數,因為描述符是從0開始的,所以需要加1
readset為要監聽的讀描述符
writeset為要監聽的寫描述符
exceptset為要監聽的異常描述符
timeout監聽沒有准備好的描述符的話,多久可以返回,支持按照秒或者毫秒來配置時間
    select操作的邏輯是首先將要監聽的讀、寫以及異常描述符拷貝到內核空間,然后遍歷所有的描述符,如果有感興趣的事件發生,那么就返回。
select在使用的過程中有三個問題:
1、被監控的fds(描述符)集合限制為1024,1024太小了
2、需要將描述符集合從用戶空間拷貝到內核空間
3、當有描述符可操作的時候都需要遍歷一下整個描述符集合才能知道哪個是可操作的,效率很低。

poll

函數簽名如下:

  int poll(struct pollfd[] fds, unsigned int nfds, int timeout);

 poll操作與select操作類似,仍舊避免不了描述符從用戶空間拷貝到內核空間,但是poll不再有1024個描述符的限制。對於事件的觸發通知還是使用遍歷所有描述符的方式,因此在大量連接的情況下也存在遍歷低效的問題。poll函數在傳遞參數的時候統一的將要監聽的描述符和事件封裝在了pollfd結構體數組中。

epoll

    epoll有三個方法:epoll_create、epoll_ctl和epoll_wait。epoll_create是創建一個epoll句柄;epoll_ctl是注冊要監聽的事件類型;epoll_wait則是等待事件的產生。 通過這三個方法epoll解決了select的三個問題。
1、1024數量限制的問題
通過epoll_create方法來創建一個epoll句柄,這個句柄監聽的描述符的數量不再有限制。
2、文件描述符頻繁從用戶空間拷貝到內核空間的問題
通過觀察select的操作會發現描述符從用戶空間到內核空間拷貝發生在調用select方法的時候,只要沒有注冊新的事件或者取消注冊事件,每次拷貝的描述符都是一樣的。因此epoll引入了epoll_ctl調用,該方法用於注冊新事件和取消注冊事件。而在epoll_wait的時候並不會拷貝描述符,描述符始終存在於內核空間,當需要修改的時候只要調用epoll_ctl修改一下內核的描述符即可。如此一來便省去了描述符來回拷貝的開銷。
3、文件描述符可操作的時候遍歷整個描述符集合的問題
在調用epoll_ctl注冊感興趣的事件的時候,實際上會為設置的事件添加一個回調函數,當對應的感興趣的事件發生的時候,回調函數就會觸發,然后將自己加到一個鏈表中。epoll_wait函數的作用就是去查看這個鏈表中有沒有已經准備就緒的事件,如果有的話就通知應用程序處理,如此操作epoll_wait只需要遍歷就緒的事件描述符即可。

epoll在Java中的使用

    目前針對Java服務器的非阻塞編程基本都是基於epoll的。在進行非阻塞編程的時候有兩個步驟:1、注冊感興趣的事情;2、調用select方法,查找感興趣的事件。

注冊感興趣的事件

    我們在編寫Socket的非阻塞代碼的時候需要在Selector上注冊感興趣的事情,通常寫法是serverSocketChannel.register(selector, SelectionKey.XXX)。來看一下這行代碼背后的執行邏輯是什么樣的。
        
注冊的時候實際執行的是EPollSelectorImp。該方法主要有以下三步:
1、implRegister方法。在fdToKey的Map中插入channel對應的文件描述法和SelectionKey的映射,當做注冊Channel、關閉Channel、取消注冊等操作是都是操作此Map。
2、往pollWrapper[Epoll實例]中放入channel實例。
3、往keys[HashSet]中放入SelectionKey

select方法

    通過Java的Selector.select方法來獲取准備好的鍵的時候實際執行的代碼如下:
        
首先調用EPollArrayWrapper的poll方法,該方法做兩件事:1、調用epollCtl方法向epoll中注冊感興趣的事件;2、調用epollWait方法返回已就緒的文件描述符集合
然后調用updateSelectedKeys方法調用把epoll中就緒的文件描述符加到ready隊列中等待上層應用處理, updateSelectedKeys通過fdToKey查找文件描述符對應的SelectionKey,並在SelectionKey對應的channel中添加對應的事件到ready隊列。

水平觸發LT與邊緣觸發ET

    epoll支持兩種觸發模式,分別是水平觸發和邊緣觸發。
    LT是缺省的工作方式,並且同時支持block和no-block socket。在這種做法中,內核告訴你一個文件描述符是否就緒了,然后你可以對這個就緒的fd進行IO操作。如果你不作任何操作,內核還是會繼續通知你的。
    ET是高速工作方式,只支持no-block socket。在這種模式下,當描述符從未就緒變為就緒時,內核會通知你一次,並且除非你做了某些操作導致那個文件描述符不再為就緒狀態了,否則不會再次發送通知。
    可以看到,本來內核在被DMA中斷,捕獲到IO設備來數據后,只需要查找這個數據屬於哪個文件描述符,進而通知線程里等待的函數即可,但是,LT要求內核在通知階段還要繼續再掃描一次剛才所建立的內核fd和io對應的那個數組,因為應用程序可能沒有真正去讀上次通知有數據后的那些fd,這種溝通方式效率是很低下的,只是方便編程而已;

    JDK並沒有實現邊緣觸發,關於邊緣觸發和水平觸發的差異簡單列舉如下,邊緣觸發的性能更高,但編程難度也更高,netty就重新實現了Epoll機制,采用邊緣觸發方式;另外像nginx等也采用的是邊緣觸發。

 

----------------------------------------------------------------

歡迎關注我的微信公眾號:yunxi-talk,分享Java干貨,進階Java程序員必備。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM