1. 程式人生 > >Java NIO(一) -我們需要NIO

Java NIO(一) -我們需要NIO

1.NIO的非阻塞機制

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

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

  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是怎麼工作的。

常見I/O模型對比

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

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

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

以socket.read()為例子:

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

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

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

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

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

如何結合事件模型使用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之前是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模式:註冊所有感興趣的事件處理器,單執行緒輪詢選擇就緒事件,執行事件處理器。

優化執行緒模型

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

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

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

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

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

  1. 事件分發器,單執行緒選擇就緒的事件。
  2. I/O處理器,包括connect、read、write等,這種純CPU操作,一般開啟CPU核心個執行緒就可以。
  3. 業務執行緒,在處理完I/O後,業務一般還會有自己的業務邏輯,有的還會有其他的阻塞I/O,如DB操作,RPC等。只要有阻塞,就需要單獨的執行緒。

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

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

emma_1

2.NIO的buffer機制

NIO效能的優勢就來源於緩衝的機制,不管是讀或者寫都需要以塊的形式寫入到緩衝區中。NIO實際上讓我們對IO的操作更接近於作業系統的實際過程。 所有的系統I/O都分為兩個階段:等待就緒和操作。舉例來說,讀函式,分為等待系統可讀和真正的讀;同理,寫函式分為等待網絡卡可以寫和真正的寫。 以socket為例: 先從應用層獲取資料到核心的緩衝區,然後再從核心的緩衝區複製到程序的緩衝區。所以實際上底層的機制也是不斷利用緩衝區來讀寫資料的。即使傳統IO抽象成了從流直接讀取資料,但本質上也依然是利用緩衝區來讀取和寫入資料。 所以,為了更好的理解nio,我們就需要知道IO的底層機制,這樣對我們將來理解channel和buffer就打下了基礎。這裡簡單提一下,我們可以把bufffer就理解為核心緩衝區,所以不論讀寫,自然都要經過這個區域,讀的話,先從裝置讀取資料到核心,再讀到程序緩衝區,寫的話,先從程序緩衝區寫到核心,再從核心寫回裝置。