1. 程式人生 > >NIO網路程式設計

NIO網路程式設計

一、NIO概述

NIONon-blocking I/O,在Java領域,也稱為New I/O),這個庫是在JDK1.4中才引入的,是一種同步非阻塞的I/O模型,也是I/O多路複用的基礎,已經被越來越多地應用到大型應用伺服器,成為解決高併發與大量連線、I/O處理問題的有效方式。

那麼NIO的本質是什麼樣的呢?它是怎樣與事件模型結合來解放執行緒、提高系統吞吐的呢?

本文會從傳統的阻塞I/O和執行緒池模型面臨的問題講起,然後對比幾種常見I/O模型,一步步分析NIO怎麼利用事件模型處理I/O,解決執行緒池瓶頸處理海量連線,包括利用面向事件的方式編寫服務端/客戶端程式。最後延展到一些高階主題,如ReactorProactor模型的對比、

Selector的喚醒、Buffer的選擇等。

注:本文的程式碼都是虛擬碼,主要是為了示意,不可用於生產環境。

二、IO模型

1.阻塞IO模型

傳統BIO模型分析

讓我們先回憶一下傳統的伺服器端同步阻塞I/O處理(也就是BIOBlocking I/O)的經典程式設計模型:

{

ExecutorService executor =Excutors.newFixedThreadPollExecutor(100);//執行緒池

ServerSocket serverSocket = newServerSocket();

serverSocket.bind(8088);

while(!Thread.currentThread.isInturrupted()){//主執行緒死迴圈等待新連線到來

Socket socket =serverSocket.accept();

executor.submit(newConnectIOnHandler(socket));//為新的連線建立新的執行緒

}

class ConnectIOnHandler extends Thread{

private Socket socket;

public ConnectIOnHandler(Socketsocket){

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並且程式設計模型簡單,也不用過多考慮系統的過載、限流等問題。執行緒池本身就是一個天然的漏斗,可以緩衝一些系統處理不了的連線或請求。

不過,這個模型最本質的問題在於,嚴重依賴於執行緒。但執行緒是很""的資源,主要表現在:

1.執行緒的建立和銷燬成本很高,Linux這樣的作業系統中,執行緒本質上就是一個程序。建立和銷燬都是重量級的系統函式。

2.執行緒本身佔用較大記憶體,像Java的執行緒棧,一般至少分配512K1M的空間,如果系統中的執行緒數過千,恐怕整個JVM的記憶體都會被吃掉一半。

3.執行緒的切換成本是很高的。作業系統發生執行緒切換的時候,需要保留執行緒的上下文,然後執行系統呼叫。如果執行緒數過高,可能執行執行緒切換的時間甚至會大於執行緒執行的時間,這時候帶來的表現往往是系統load偏高、CPU sy使用率特別高(超過20%以上),導致系統幾乎陷入不可用的狀態。

4.容易造成鋸齒狀的系統負載。因為系統負載是用活動執行緒數或CPU核心數,一旦執行緒數量高但外部網路環境不是很穩定,就很容易造成大量請求的結果同時返回,啟用大量阻塞執行緒從而使系統負載壓力過大。

所以,當面對十萬甚至百萬級連線的時候,傳統的BIO模型是無能為力的。隨著移動端應用的興起和各種網路遊戲的盛行,百萬級長連線日趨普遍,此時,必然需要一種更高效的I/O處理模型。

2.非阻塞IO模型

3.IO複用模型

4.訊號驅動IO模型

5.非同步IO模型

6.常見IO模型對比

所有的系統I/O都分為兩個階段:等待就緒和操作。舉例來說,讀函式,分為等待系統可讀和真正的讀;同理,寫函式分為等待網絡卡可以寫和真正的寫。

需要說明的是等待就緒的阻塞是不使用CPU的,是在“空等”;而真正的讀寫操作的阻塞是使用CPU的,真正在"幹活",而且這個過程非常快,屬於memory copy,頻寬通常在1GB/s級別以上,可以理解為基本不耗時。

下圖是幾種常見I/O模型的對比:

socket.read()為例子:

傳統的BIO裡面socket.read(),如果TCP RecvBuffer裡沒有資料,函式會一直阻塞,直到收到資料,返回讀到的資料。

對於NIO,如果TCP RecvBuffer有資料,就把資料從網絡卡讀到記憶體,並且返回給使用者;反之則直接返回0,永遠不會阻塞。

最新的AIO(AsyncI/O)裡面會更進一步:不但等待就緒是非阻塞的,就連資料從網絡卡到記憶體的過程也是非同步的。

換句話說,BIO裡使用者最關心“我要讀”,NIO裡使用者最關心"我可以讀了",在AIO模型裡使用者更需要關注的是“讀完了”。

NIO一個重要的特點是:socket主要的讀、寫、註冊和接收函式,在等待就緒階段都是非阻塞的,真正的I/O操作是同步阻塞的(消耗CPU但效能非常高)。

三、IO多路複用技術

主要優勢:

把多個IO阻塞複用到同一個select阻塞,單執行緒可以處理多個客戶端請求,效能開銷小,不需其他的程序或執行緒,節省資源

主要應用場景:

>> 伺服器需要同時處理多個監聽狀態或多個連線狀態的套接字

>> 伺服器需要同時處理多種網路協議套接字

Select & Epoll

Epoll優點:

1.支援一個程序開啟的socket描述符(fd)數目不受限制(受限於作業系統的最大檔案控制代碼數)

2.IO效率不會隨著fd的數目增加而線性下降

3.使用mmap加速核心與使用者空間訊息傳遞

四、NIO分析

1.如何結合事件模型使用NIO同步非阻塞特性

回憶BIO模型,之所以需要多執行緒,是因為在進行I/O操作的時候,一是沒有辦法知道到底能不能寫、能不能讀,只能"傻等",即使通過各種估算,算出來作業系統沒有能力進行讀寫,也沒法在socket.read()socket.write()函式中返回,這兩個函式無法進行有效的中斷。所以除了多開執行緒另起爐灶,沒有好的辦法利用CPU

NIO的讀寫函式可以立刻返回,這就給了我們不開執行緒利用CPU的最好機會:如果一個連線不能讀寫(socket.read()返回0或者socket.write()返回0),我們可以把這件事記下來,記錄的方式通常是在Selector上註冊標記位,然後切換到其它就緒的連線(channel)繼續進行讀寫。

下面具體看下如何利用事件模型單執行緒處理所有I/O請求:

NIO的主要事件有幾個:讀就緒、寫就緒、有新連線到來。

我們首先需要註冊當這幾個事件到來的時候所對應的處理器。然後在合適的時機告訴事件選擇器:我對這個事件感興趣。對於寫操作,就是寫不出去的時候對寫事件感興趣;對於讀操作,就是完成連線和系統沒有辦法承載新讀入的資料的時;對於accept,一般是伺服器剛啟動的時候;而對於connect,一般是connect失敗需要重連或者直接非同步呼叫connect的時候。

其次,用一個死迴圈選擇就緒的事件,會執行系統呼叫(Linux 2.6之前是selectpoll2.6之後是epollWindowsIOCP),還會阻塞的等待新事件的到來。新事件到來的時候,會在selector上註冊標記位,標示可讀、可寫或者有連線到來。

注意,select是阻塞的,無論是通過作業系統的通知(epoll)還是不停的輪詢(selectpoll),這個函式是阻塞的。所以你可以放心大膽地在一個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<ChannelChannelHandler>handlerMap;//所有channel的對應事件處理器

}

這個程式很簡短,也是最簡單的Reactor模式:註冊所有感興趣的事件處理器,單執行緒輪詢選擇就緒事件,執行事件處理器。

2.優化執行緒模型

由上面的示例我們大概可以總結出NIO是怎麼解決掉執行緒的瓶頸並處理海量連線的:

NIO由原來的阻塞讀寫(佔用執行緒)變成了單執行緒輪詢事件,找到可以進行讀寫的網路描述符進行讀寫。除了事件的輪詢是阻塞的(沒有可乾的事情必須要阻塞),剩餘的I/O操作都是純CPU操作,沒有必要開啟多執行緒。

並且由於執行緒的節約,連線數大的時候因為執行緒切換帶來的問題也隨之解決,進而為處理海量連線提供了可能。

單執行緒處理I/O的效率確實非常高,沒有執行緒切換,只是拼命的讀、寫、選擇事件。但現在的伺服器,一般都是多核處理器,如果能夠利用多核心進行I/O,無疑對效率會有更大的提高。

仔細分析一下我們需要的執行緒,其實主要包括以下幾種:

1.事件分發器,單執行緒選擇就緒的事件。

2.I/O處理器,包括connectreadwrite等,這種純CPU操作,一般開啟CPU核心個執行緒就可以。

3.業務執行緒,在處理完I/O後,業務一般還會有自己的業務邏輯,有的還會有其他的阻塞I/O,如DB操作,RPC等。只要有阻塞,就需要單獨的執行緒。

JavaSelector對於Linux系統來說,有一個致命限制:同一個channelselect不能被併發的呼叫。因此,如果有多個I/O執行緒,必須保證:一個socket只能屬於一個IoThread,而一個IoThread可以管理多個socket

另外連線的處理和讀寫的處理通常可以選擇分開,這樣對於海量連線的註冊和讀寫就可以分發。雖然read()write()是比較高效無阻塞的函式,但畢竟會佔用CPU,如果面對更高的併發則無能為力。

 

五、NIO客戶端應用場景

通過上面的分析,可以看出NIO在服務端對於解放執行緒,優化I/O和處理海量連線方面,確實有自己的用武之地。那麼在客戶端上,NIO又有什麼使用場景呢?

常見的客戶端BIO+連線池模型,可以建立n個連線,然後當某一個連線被I/O佔用的時候,可以使用其他連線來提高效能。

但多執行緒的模型面臨和服務端相同的問題:如果指望增加連線數來提高效能,則連線數又受制於執行緒數、執行緒很貴、無法建立很多執行緒,則效能遇到瓶頸。

每連線順序請求的Redis

對於Redis來說,由於服務端是全域性序列的,能夠保證同一連線的所有請求與返回順序一致。這樣可以使用單執行緒+佇列,把請求資料緩衝。然後pipeline傳送,返回future,然後channel可讀時,直接在佇列中把future取回來,done()就可以了。

虛擬碼如下:

class RedisClient ImplementsChannelHandler{

 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(Channelchannel,BufferBuffer){

    List result = handleBuffer();//處理資料

    //從cmdQueue取出future,並設值,future.done();

}

 public void ChannelWritable(Channelchannel){

   channel.flush();

}

}

這樣做,能夠充分的利用pipeline來提高I/O能力,同時獲取非同步處理能力。

多連線短連線的HttpClient

類似於競對抓取的專案,往往需要建立無數的HTTP短連線,然後抓取,然後銷燬,當需要單機抓取上千網站執行緒數又受制的時候,怎麼保證效能呢?

何不嘗試NIO,單執行緒進行連線、寫、讀操作?如果連線、讀、寫作業系統沒有能力處理,簡單的註冊一個事件,等待下次迴圈就好了。

如何儲存不同的請求/響應呢?由於http是無狀態沒有版本的協議,又沒有辦法使用佇列,好像辦法不多。比較笨的辦法是對於不同的socket,直接儲存socket的引用作為mapkey

常見的RPC框架,如ThriftDubbo

這種框架內部一般維護了請求的協議和請求號,可以維護一個以請求號為key,結果的resultfuturemap,結合NIO+長連線,獲取非常不錯的效能。

六、ProactorReactor

一般情況下,I/O複用機制需要事件分發器(event dispatcher)。事件分發器的作用,即將那些讀寫事件源分發給各讀寫事件的處理者,就像送快遞的在樓下喊:誰誰誰的快遞到了,快來拿吧!開發人員在開始的時候需要在分發器那裡註冊感興趣的事件,並提供相應的處理者(event handler),或者是回撥函式;事件分發器在適當的時候,會將請求的事件分發給這些handler或者回調函式。

涉及到事件分發器的兩種模式稱為:ReactorProactor Reactor模式是基於同步I/O的,而Proactor模式是和非同步I/O相關的。在Reactor模式中,事件分發器等待某個事件或者可應用或個操作的狀態發生(比如檔案描述符可讀寫,或者是socket可讀寫),事件分發器就把這個事件傳給事先註冊的事件處理函式或者回調函式,由後者來做實際的讀寫操作。

而在Proactor模式中,事件處理者(或者代由事件分發器發起)直接發起一個非同步讀寫操作(相當於請求),而實際的工作是由作業系統來完成的。發起時,需要提供的引數包括用於存放讀到資料的快取區、讀的資料大小或用於存放外發資料的快取區,以及這個請求完後的回撥函式等資訊。事件分發器得知了這個請求,它默默等待這個請求的完成,然後轉發完成事件給相應的事件處理者或者回調。舉例來說,在Windows上事件處理者投遞了一個非同步IO操作(稱為overlapped技術),事件分發器等IO Complete事件完成。這種非同步模式的典型實現是基於作業系統底層非同步API的,所以我們可稱之為“系統級別”的或者“真正意義上”的非同步,因為具體的讀寫是由作業系統代勞的。

舉個例子,將有助於理解ReactorProactor二者的差異,以讀操作為例(寫操作類似)。

Reactor中實現讀

註冊讀就緒事件和相應的事件處理器。

事件分發器等待事件。

事件到來,啟用分發器,分發器呼叫事件對應的處理器。

事件處理器完成實際的讀操作,處理讀到的資料,註冊新的事件,然後返還控制權。

Proactor中實現讀:

處理器發起非同步讀操作(注意:作業系統必須支援非同步IO)。在這種情況下,處理器無視IO就緒事件,它關注的是完成事件。

事件分發器等待操作完成事件。

在分發器等待過程中,作業系統利用並行的核心執行緒執行實際的讀操作,並將結果資料存入使用者自定義緩衝區,最後通知事件分發器讀操作完成。

事件分發器呼喚處理器。

事件處理器處理使用者自定義緩衝區中的資料,然後啟動一個新的非同步操作,並將控制權返回事件分發器。

可以看出,兩個模式的相同點,都是對某個I/O事件的事件通知(即告訴某個模組,這個I/O操作可以進行或已經完成)。在結構上,兩者也有相同點:事件分發器負責提交IO操作(非同步)、查詢裝置是否可操作(同步),然後當條件滿足時,就回調handler;不同點在於,非同步情況下(Proactor),當回撥handler時,表示I/O操作已經完成;同步情況下(Reactor),回撥handler時,表示I/O裝置可以進行某個操作(can read can write)

下面,我們將嘗試應對為ProactorReactor模式建立可移植框架的挑戰。在改進方案中,我們將Reactor原來位於事件處理器內的Read/Write操作移至分發器(不妨將這個思路稱為“模擬非同步”),以此尋求將Reactor多路同步I/O轉化為模擬非同步I/O。以讀操作為例子,改進過程如下:

註冊讀就緒事件和相應的事件處理器。併為分發器提供資料緩衝區地址,需要讀取資料量等資訊。

分發器等待事件(如在select()上等待)。

事件到來,啟用分發器。分發器執行一個非阻塞讀操作(它有完成這個操作所需的全部資訊),最後呼叫對應處理器。

事件處理器處理使用者自定義緩衝區的資料,註冊新的事件(當然同樣要給出資料緩衝區地址,需要讀取的資料量等資訊),最後將控制權返還分發器。
如我們所見,通過對多路I/O模式功能結構的改造,可將Reactor轉化為Proactor模式。改造前後,模型實際完成的工作量沒有增加,只不過參與者間對工作職責稍加調換。沒有工作量的改變,自然不會造成效能的削弱。對如下各步驟的比較,可以證明工作量的恆定:

標準/典型的Reactor

步驟1:等待事件到來(Reactor負責)。

步驟2:將讀就緒事件分發給使用者定義的處理器(Reactor負責)。

步驟3:讀資料(使用者處理器負責)。

步驟4:處理資料(使用者處理器負責)。

改進實現的模擬Proactor

步驟1:等待事件到來(Proactor負責)。

步驟2:得到讀就緒事件,執行讀資料(現在由Proactor負責)。

步驟3:將讀完成事件分發給使用者處理器(Proactor負責)。

步驟4:處理資料(使用者處理器負責)。

對於不提供非同步I/O API的作業系統來說,這種辦法可以隱藏Socket API的互動細節,從而對外暴露一個完整的非同步介面。藉此,我們就可以進一步構建完全可移植的,平臺無關的,有通用對外介面的解決方案。

程式碼示例如下:

interfaceChannelHandler{

      void channelReadComplate(Channel channel,byte[] data);

      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);//如果是新連線,則註冊一個新的讀寫處理器

         Selector.interested(read);

      }

      if(channel.event==write){

        getChannelHandler(channel).channelWritable(channel);//如果可以寫,則執行寫事件

      }

      if(channel.event==read){

          byte[] data = channel.read();

          if(channel.read()==0)//沒有讀到資料,表示本次資料讀完了

          {

         getChannelHandler(channel).channelReadComplate(channel,data;//處理讀完成事件

          }

          if(過載保護){

          Selector.interested(read);

          }

      }

     }

    }

   Map<Channel,ChannelHandler> handlerMap;//所有channel的對應事件處理器

   }

Selector.wakeup()

主要作用

解除阻塞在Selector.select()/select(long)上的執行緒,立即返回。

兩次成功的select之間多次呼叫wakeup等價於一次呼叫。

如果當前沒有阻塞在select上,則本次wakeup呼叫將作用於下一次select——“記憶”作用。

為什麼要喚醒?

註冊了新的channel或者事件。

channel關閉,取消註冊。

優先順序更高的事件觸發(如定時器事件),希望及時處理。

原理

Linux上利用pipe呼叫建立一個管道,Windows上則是一個loopbacktcp連線。這是因為win32的管道無法加入selectfd set,將管道或者TCP連線加入select fd set

wakeup往管道或者連線寫入一個位元組,阻塞的select因為有I/O事件就緒,立即返回。可見,wakeup的呼叫開銷不可忽視。

七、Reactor執行緒模型

1.      單執行緒模型

所有IO相關操作(連線、讀寫等)在一個NIO執行緒完成

2.      多執行緒模型

所有IO操作由一組NIO執行緒完成

一個NIO執行緒可以處理NChannel,一個Channel對應一個NIO執行緒

3.      主從多執行緒模型

八、ZeroCopy

不使用zerocopy >>

 

1.DirectBuffer

Buffer的選擇

通常情況下,作業系統的一次寫操作分為兩步:

1.將資料從使用者空間拷貝到系統空間。

2.從系統空間往網絡卡寫。

同理,讀操作也分為兩步:

1.將資料從網絡卡拷貝到系統空間;
2.將資料從系統空間拷貝到使用者空間。

對於NIO來說,快取的使用可以使用DirectByteBufferHeapByteBuffer。如果使用了DirectByteBuffer,一般來說可以減少一次系統空間到使用者空間的拷貝。但Buffer建立和銷燬的成本更高,更不宜維護,通常會用記憶體池來提高效能。

如果資料量比較小的中小應用情況下,可以考慮使用heapBuffer;反之可以用directBuffer

2.mmap

3.RocketMQ使用

4.sendfile

5.kafka使用

九、NIO存在的問題

使用NIO !=高效能,當連線數<1000,併發程度不高或者區域網環境下NIO並沒有顯著的效能優勢。

NIO並沒有完全遮蔽平臺差異,它仍然是基於各個作業系統的I/O系統實現的,差異仍然存在。使用NIO做網路程式設計構建事件驅動模型並不容易,陷阱重重。

推薦大家使用成熟的NIO框架,如NettyMINA等。解決了很多NIO的陷阱,並遮蔽了作業系統的差異,有較好的效能和程式設計模型。

十、總結

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

事件驅動模型

避免多執行緒

單執行緒處理多工

非阻塞I/OI/O讀寫不再阻塞,而是返回0

基於block的傳輸,通常比基於流的傳輸更高效

更高階的IO函式,zero-copy

IO多路複用大大提高了Java網路應用的可伸縮性和實用性

參考:

https://zhuanlan.zhihu.com/p/23488863?utm_source=zhihu&utm_medium=social

http://ifeve.com/java-nio-all/