您好,登錄后才能下訂單哦!
這篇文章主要介紹“NIO與BIO的區別、NIO的運行原理和并發使用場景是什么”,在日常操作中,相信很多人在NIO與BIO的區別、NIO的運行原理和并發使用場景是什么問題上存在疑惑,小編查閱了各式資料,整理出簡單好用的操作方法,希望對大家解答”NIO與BIO的區別、NIO的運行原理和并發使用場景是什么”的疑惑有所幫助!接下來,請跟著小編一起來學習吧!
NIO(Non-blocking I/O,在Java領域,也稱為New I/O),是一種同步非阻塞的I/O模型,也是I/O多路復用的基礎,已經被越來越多地應用到大型應用服務器,成為解決高并發與大量連接、I/O處理問題的有效方式。
那么NIO的本質是什么樣的呢?它是怎樣與事件模型結合來解放線程、提高系統吞吐的呢?
本文會先從傳統的阻塞I/O和線程池模型面臨的問題講起,然后對比幾種常見I/O模型,一步步分析NIO怎么利用事件模型處理I/O,解決線程池瓶頸處理海量連接,包括利用面向事件的方式編寫服務端/客戶端程序。最后延展到一些高級主題,如Reactor與Proactor模型的對比、Selector的喚醒、Buffer的選擇等。
注:本文的代碼都是偽代碼,主要是為了示意,不可用于生產環境。
讓我們先回憶一下傳統的服務器端同步阻塞I/O處理(也就是BIO,Blocking I/O)的經典編程模型:
{ ExecutorService executor = Excutors.newFixedThreadPollExecutor(100);//線程池 ServerSocket serverSocket = new ServerSocket(); serverSocket.bind(8088); while(!Thread.currentThread.isInturrupted()){//主線程死循環等待新連接到來 Socket socket = serverSocket.accept(); executor.submit(new ConnectIOnHandler(socket));//為新的連接創建新的線程 } class ConnectIOnHandler extends Thread{ private Socket socket; public ConnectIOnHandler(Socket socket){ this.socket = socket; } public void run(){ while(!Thread.currentThread.isInturrupted()&&!socket.isClosed()){死循環處理讀寫事件 String someThing = socket.read()....//讀取數據 if(someThing!=null){ ......//處理數據 socket.write()....//寫數據 } } } }
這是一個經典的每連接每線程的模型,之所以使用多線程,主要原因在于socket.accept()、socket.read()、socket.write()三個主要函數都是同步阻塞的,當一個連接在處理I/O的時候,系統是阻塞的,如果是單線程的話必然就掛死在那里;但CPU是被釋放出來的,開啟多線程,就可以讓CPU去處理更多的事情。
其實這也是所有使用多線程的本質:
利用多核。
當I/O阻塞系統,但CPU空閑的時候,可以利用多線程使用CPU資源。
現在的多線程一般都使用線程池 ,可以讓線程的創建和回收成本相對較低。在活動連接數不是特別高(小于單機1000)的情況下,這種模型是比較不錯的,可以讓每一個連接專注于自己的I/O并且編程模型簡單,也不用過多考慮系統的過載、限流等問題。線程池本身就是一個天然的漏斗,可以緩沖一些系統處理不了的連接或請求。
不過,這個模型最本質的問題在于,嚴重依賴于線程。但線程是很"貴"的資源,主要表現在:
線程的創建和銷毀成本很高,在Linux這樣的操作系統中,線程本質上就是一個進程。創建和銷毀都是重量級的系統函數。
線程本身占用較大內存,像Java的線程棧,一般至少分配512K~1M的空間,如果系統中的線程數過千,恐怕整個JVM的內存都會被吃掉一半。
線程的切換成本是很高的。操作系統發生線程切換的時候,需要保留線程的上下文,然后執行系統調用。如果線程數過高,可能執行線程切換的時間甚至會大于線程執行的時間,這時候帶來的表現往往是系統load偏高、CPU sy使用率特別高(超過20%以上),導致系統幾乎陷入不可用的狀態。
容易造成鋸齒狀的系統負載。因為系統負載是用活動線程數或CPU核心數,一旦線程數量高但外部網絡環境不是很穩定,就很容易造成大量請求的結果同時返回,激活大量阻塞線程從而使系統負載壓力過大。
所以,當 面對十萬甚至百萬級連接的時候,傳統的BIO模型是無能為力的 。隨著移動端應用的興起和各種網絡游戲的盛行,百萬級長連接日趨普遍,此時,必然需要一種更高效的I/O處理模型。
很多剛接觸NIO的人,第一眼看到的就是Java相對晦澀的API,比如:Channel,Selector,Socket什么的;然后就是一坨上百行的代碼來演示NIO的服務端Demo……瞬間頭大有沒有?
我們不管這些,拋開現象看本質,先分析下NIO是怎么工作的。
1.常見I/O模型對比
所有的系統I/O都分為兩個階段:等待就緒和操作。舉例來說,讀函數,分為等待系統可讀和真正的讀;同理,寫函數分為等待網卡可以寫和真正的寫。
需要說明的是等待就緒的阻塞是不使用CPU的,是在“空等”;而真正的讀寫操作的阻塞是使用CPU的,真正在"干活",而且這個過程非常快,屬于memory copy,帶寬通常在1GB/s級別以上,可以理解為基本不耗時。
下圖是幾種常見I/O模型的對比:
以socket.read()為例子:
傳統的BIO里面socket.read(),如果TCP RecvBuffer里沒有數據,函數會一直阻塞,直到收到數據,返回讀到的數據。
對于NIO,如果TCP RecvBuffer有數據,就把數據從網卡讀到內存,并且返回給用戶;反之則直接返回0,永遠不會阻塞。
最新的AIO(Async I/O)里面會更進一步:不但等待就緒是非阻塞的,就連數據從網卡到內存的過程也是異步的。
換句話說,BIO里用戶最關心“我要讀”,NIO里用戶最關心"我可以讀了",在AIO模型里用戶更需要關注的是“讀完了”。
NIO一個重要的特點是:socket主要的讀、寫、注冊和接收函數,在等待就緒階段都是非阻塞的,真正的I/O操作是同步阻塞的(消耗CPU但性能非常高)。
2.如何結合事件模型使用NIO同步非阻塞特性
下面具體看下如何利用事件模型單線程處理所有I/O請求:
NIO的主要事件有幾個:
讀就緒
寫就緒
有新連接到來
我們首先需要注冊當這幾個事件到來的時候所對應的處理器。然后在合適的時機告訴事件選擇器:我對這個事件感興趣。對于寫操作,就是寫不出去的時候對寫事件感興趣;對于讀操作,就是完成連接和系統沒有辦法承載新讀入的數據的時;對于accept,一般是服務器剛啟動的時候;而對于connect,一般是connect失敗需要重連或者直接異步調用connect的時候。
其次,用一個死循環選擇就緒的事件,會執行系統調用(Linux 2.6之前是select、poll,2.6之后是epoll,Windows是IOCP),還會阻塞的等待新事件的到來。新事件到來的時候,會在selector上注冊標記位,標示可讀、可寫或者有連接到來。
注意,select是阻塞的,無論是通過操作系統的通知(epoll)還是不停的輪詢(select,poll),這個函數是阻塞的。所以你可以放心大膽地在一個while(true)里面調用這個函數而不用擔心CPU空轉。
所以我們的程序大概的模樣是:
interface ChannelHandler{ void channelReadable(Channel channel); void channelWritable(Channel channel); } class Channel{ Socket socket; Event event;//讀,寫或者連接 } //IO線程主循環: class IoThread extends Thread{ public void run(){ Channel channel; while(channel=Selector.select()){//選擇就緒的事件和對應的連接 if(channel.event==accept){ registerNewChannelHandler(channel);//如果是新連接,則注冊一個新的讀寫處理器 } if(channel.event==write){ getChannelHandler(channel).channelWritable(channel);//如果可以寫,則執行寫事件 } if(channel.event==read){ getChannelHandler(channel).channelReadable(channel);//如果可以讀,則執行讀事件 } } } Map<Channel,ChannelHandler> handlerMap;//所有channel的對應事件處理器 }
這個程序很簡短,也是最簡單的Reactor模式:注冊所有感興趣的事件處理器,單線程輪詢選擇就緒事件,執行事件處理器。
3.優化線程模型
由上面的示例我們大概可以總結出NIO是怎么解決掉線程的瓶頸并處理海量連接的:
NIO由原來的阻塞讀寫(占用線程)變成了單線程輪詢事件,找到可以進行讀寫的網絡描述符進行讀寫。除了事件的輪詢是阻塞的(沒有可干的事情必須要阻塞),剩余的I/O操作都是純CPU操作,沒有必要開啟多線程。
并且由于線程的節約,連接數大的時候因為線程切換帶來的問題也隨之解決,進而為處理海量連接提供了可能。
單線程處理I/O的效率確實非常高,沒有線程切換,只是拼命的讀、寫、選擇事件。但現在的服務器,一般都是多核處理器,如果能夠利用多核心進行I/O,無疑對效率會有更大的提高。
仔細分析一下我們需要的線程,其實主要包括以下幾種:
事件分發器,單線程選擇就緒的事件。
I/O處理器,包括connect、read、write等,這種純CPU操作,一般開啟CPU核心個線程就可以。
業務線程,在處理完I/O后,業務一般還會有自己的業務邏輯,有的還會有其他的阻塞I/O,如DB操作,RPC等。只要有阻塞,就需要單獨的線程。
Java的Selector對于Linux系統來說,有一個致命限制:同一個channel的select不能被并發的調用。因此,如果有多個I/O線程,必須保證:一個socket只能屬于一個IoThread,而一個IoThread可以管理多個socket。
另外連接的處理和讀寫的處理通常可以選擇分開,這樣對于海量連接的注冊和讀寫就可以分發。雖然read()和write()是比較高效無阻塞的函數,但畢竟會占用CPU,如果面對更高的并發則無能為力。
通過上面的分析,可以看出NIO在服務端對于解放線程,優化I/O和處理海量連接方面,確實有自己的用武之地。
1.NIO又有什么使用場景呢?
常見的客戶端BIO+連接池模型,可以建立n個連接,然后當某一個連接被I/O占用的時候,可以使用其他連接來提高性能。
但多線程的模型面臨和服務端相同的問題:如果指望增加連接數來提高性能,則連接數又受制于線程數、線程很貴、無法建立很多線程,則性能遇到瓶頸。
每連接順序請求的Redis
對于Redis來說,由于服務端是全局串行的,能夠保證同一連接的所有請求與返回順序一致。這樣可以使用單線程+隊列,把請求數據緩沖。然后pipeline發送,返回future,然后channel可讀時,直接在隊列中把future取回來,done()就可以了。
偽代碼如下:
class RedisClient Implements ChannelHandler{ private BlockingQueue CmdQueue; private EventLoop eventLoop; private Channel channel; class Cmd{ String cmd; Future result; } public Future get(String key){ Cmd cmd= new Cmd(key); queue.offer(cmd); eventLoop.submit(new Runnable(){ List list = new ArrayList(); queue.drainTo(list); if(channel.isWritable()){ channel.writeAndFlush(list); } }); } public void ChannelReadFinish(Channel channel,Buffer Buffer){ List result = handleBuffer();//處理數據 //從cmdQueue取出future,并設值,future.done(); } public void ChannelWritable(Channel channel){ channel.flush(); } }
這樣做,能夠充分的利用pipeline來提高I/O能力,同時獲取異步處理能力。
3.多連接短連接的HttpClient
類似于競對抓取的項目,往往需要建立無數的HTTP短連接,然后抓取,然后銷毀,當需要單機抓取上千網站線程數又受制的時候,怎么保證性能呢?
何不嘗試NIO,單線程進行連接、寫、讀操作?如果連接、讀、寫操作系統沒有能力處理,簡單的注冊一個事件,等待下次循環就好了。
如何存儲不同的請求/響應呢?由于http是無狀態沒有版本的協議,又沒有辦法使用隊列,好像辦法不多。比較笨的辦法是對于不同的socket,直接存儲socket的引用作為map的key。
4.常見的RPC框架,如Thrift,Dubbo
這種框架內部一般維護了請求的協議和請求號,可以維護一個以請求號為key,結果的result為future的map,結合NIO+長連接,獲取非常不錯的性能。
1.Proactor與Reactor
一般情況下,I/O 復用機制需要事件分發器(event dispatcher)。 事件分發器的作用,即將那些讀寫事件源分發給各讀寫事件的處理者,就像送快遞的在樓下喊: 誰誰誰的快遞到了, 快來拿吧!開發人員在開始的時候需要在分發器那里注冊感興趣的事件,并提供相應的處理者(event handler),或者是回調函數;事件分發器在適當的時候,會將請求的事件分發給這些handler或者回調函數。
涉及到事件分發器的兩種模式稱為:Reactor和Proactor。 Reactor模式是基于同步I/O的,而Proactor模式是和異步I/O相關的。在Reactor模式中,事件分發器等待某個事件或者可應用或個操作的狀態發生(比如文件描述符可讀寫,或者是socket可讀寫),事件分發器就把這個事件傳給事先注冊的事件處理函數或者回調函數,由后者來做實際的讀寫操作。
而在Proactor模式中,事件處理者(或者代由事件分發器發起)直接發起一個異步讀寫操作(相當于請求),而實際的工作是由操作系統來完成的。發起時,需要提供的參數包括用于存放讀到數據的緩存區、讀的數據大小或用于存放外發數據的緩存區,以及這個請求完后的回調函數等信息。事件分發器得知了這個請求,它默默等待這個請求的完成,然后轉發完成事件給相應的事件處理者或者回調。舉例來說,在Windows上事件處理者投遞了一個異步IO操作(稱為overlapped技術),事件分發器等IO Complete事件完成。這種異步模式的典型實現是基于操作系統底層異步API的,所以我們可稱之為“系統級別”的或者“真正意義上”的異步,因為具體的讀寫是由操作系統代勞的。
2.Buffer的選擇
對于NIO來說,緩存的使用可以使用DirectByteBuffer和HeapByteBuffer。如果使用了DirectByteBuffer,一般來說可以減少一次系統空間到用戶空間的拷貝。但Buffer創建和銷毀的成本更高,更不宜維護,通常會用內存池來提高性能。
如果數據量比較小的中小應用情況下,可以考慮使用heapBuffer;反之可以用directBuffer。
使用NIO != 高性能,當連接數<1000,并發程度不高或者局域網環境下NIO并沒有顯著的性能優勢。
NIO并沒有完全屏蔽平臺差異,它仍然是基于各個操作系統的I/O系統實現的,差異仍然存在。使用NIO做網絡編程構建事件驅動模型并不容易,陷阱重重。
推薦大家使用成熟的 NIO框架:如Netty,MINA等 ,解決了很多NIO的陷阱,并屏蔽了操作系統的差異,有較好的性能和編程模型。
到此,關于“NIO與BIO的區別、NIO的運行原理和并發使用場景是什么”的學習就結束了,希望能夠解決大家的疑惑。理論與實踐的搭配能更好的幫助大家學習,快去試試吧!若想繼續學習更多相關知識,請繼續關注億速云網站,小編會繼續努力為大家帶來更多實用的文章!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。