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的選擇等。緩存

注:本文的代碼都是僞代碼,主要是爲了示意,不可用於生產環境。bash

傳統BIO模型分析

讓咱們先回憶一下傳統的服務器端同步阻塞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去處理更多的事情。網絡

其實這也是全部使用多線程的本質:多線程

  1. 利用多核。
  2. 當I/O阻塞系統,但CPU空閒的時候,能夠利用多線程使用CPU資源。

如今的多線程通常都使用線程池 ,可讓線程的建立和回收成本相對較低。在活動鏈接數不是特別高(小於單機1000)的狀況下,這種模型是比較不錯的,可讓每個鏈接專一於本身的I/O而且編程模型簡單,也不用過多考慮系統的過載、限流等問題。線程池自己就是一個自然的漏斗,能夠緩衝一些系統處理不了的鏈接或請求。併發

不過,這個模型最本質的問題在於,嚴重依賴於線程。但線程是很"貴"的資源,主要表如今:app

  1. 線程的建立和銷燬成本很高,在Linux這樣的操做系統中,線程本質上就是一個進程。建立和銷燬都是重量級的系統函數。
  2. 線程自己佔用較大內存,像Java的線程棧,通常至少分配512K~1M的空間,若是系統中的線程數過千,恐怕整個JVM的內存都會被吃掉一半。
  3. 線程的切換成本是很高的。操做系統發生線程切換的時候,須要保留線程的上下文,而後執行系統調用。若是線程數太高,可能執行線程切換的時間甚至會大於線程執行的時間,這時候帶來的表現每每是系統load偏高、CPU sy使用率特別高(超過20%以上),致使系統幾乎陷入不可用的狀態。
  4. 容易形成鋸齒狀的系統負載。由於系統負載是用活動線程數或CPU核心數,一旦線程數量高但外部網絡環境不是很穩定,就很容易形成大量請求的結果同時返回,激活大量阻塞線程從而使系統負載壓力過大。

因此,當 面對十萬甚至百萬級鏈接的時候,傳統的BIO模型是無能爲力的 。隨着移動端應用的興起和各類網絡遊戲的盛行,百萬級長鏈接日趨廣泛,此時,必然須要一種更高效的I/O處理模型。框架

NIO是怎麼工做的

不少剛接觸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在客戶端的魔力

經過上面的分析,能夠看出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+長鏈接,獲取很是不錯的性能。

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存在的問題

使用NIO != 高性能,當鏈接數<1000,併發程度不高或者局域網環境下NIO並無顯著的性能優點。

NIO並無徹底屏蔽平臺差別,它仍然是基於各個操做系統的I/O系統實現的,差別仍然存在。使用NIO作網絡編程構建事件驅動模型並不容易,陷阱重重。

推薦你們使用成熟的 NIO框架:如Netty,MINA等 ,解決了不少NIO的陷阱,並屏蔽了操做系統的差別,有較好的性能和編程模型。

若是想免費學習Java工程化、高性能及分佈式、深刻淺出。微服務、Spring,MyBatis,Netty源碼分析的朋友能夠加個人Java後端技術羣:479499375,羣裏有阿里大牛直播講解技術,以及Java大型互聯網技術的視頻免費分享給你們。

總結

最後總結一下到底NIO給咱們帶來了些什麼:

  • 事件驅動模型
  • 避免多線程
  • 單線程處理多任務
  • 非阻塞I/O,I/O讀寫再也不阻塞,而是返回0
  • 基於block的傳輸,一般比基於流的傳輸更高效
  • 更高級的IO函數,zero-copy
  • IO多路複用大大提升了Java網絡應用的可伸縮性和實用性
相關文章
相關標籤/搜索