Java NIO

借學習netty的機會,簡單複習一下Java中的NIO模型。java

參考博文:Java NIO淺析編程

NIO(Non-bloking I/O),是一種同步非阻塞的I/O模型,也是I/O多路複用的基礎。在大型應用服務器上應用普遍,已經成爲解決高併發與大量鏈接、I/O處理問題的有效方式。緩存

傳統的BIO模型

傳統的服務端同步阻塞I/O處理(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));
    } 
}

/** * ConnectIOnHandler */
public class ConnectIOnHandler extends Thread {

    private Socket socket;

    public ConnectIOnHandler (Socket socket) {
        this.socket = socket;
    }

    public void run() {
        // 死循環處理讀寫事件
        while(!Thead.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處理模型。app

NIO

常見的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但性能很是高)。

結合事件模型使用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空轉。

因此咱們的程序大概的模樣是:

/** * ChannelHandler */
public interface ChannelHandler {
    void channelReadable(Channel channel);
    void channelWritable(Channel channel);
}

/** * Channel */
public 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);
            }
        }
    }
    // 全部channel的對應事件處理器
    Map<Channel, ChannelHandler> handlerMap;
}
複製代碼

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

優化線程模型

由上面的示例咱們大概能夠總結出NIO是怎麼解決掉線程的瓶頸並處理海量鏈接的:

NIO由原來的阻塞讀寫(佔用線程)變成了單線程輪詢事件,找到能夠進行讀寫的網絡描述符進行讀寫。除了事件的輪詢是阻塞的(沒有可乾的事情必需要阻塞),剩餘的I/O操做都是純CPU操做,沒有必要開啓多線程。

而且因爲線程的節約,鏈接數大的時候由於線程切換帶來的問題也隨之解決,進而爲處理海量鏈接提供了可能。

單線程處理I/O的效率確實很是高,沒有線程切換,只是拼命的讀、寫、選擇事件。但如今的服務器,通常都是多核處理器,若是可以利用多核心進行I/O,無疑對效率會有更大的提升。

仔細分析一下咱們須要的線程,其實主要包括如下幾種:

事件分發器,單線程選擇就緒的事件。 I/O處理器,包括connectreadwrite等,這種純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和處理海量鏈接方面,確實有本身的用武之地。那麼在客戶端上,NIO又有什麼使用場景呢?

常見的客戶端BIO+鏈接池模型,能夠創建n個鏈接,而後當某一個鏈接被I/O佔用的時候,可使用其餘鏈接來提升性能。

但多線程的模型面臨和服務端相同的問題:若是期望增長鏈接數來提升性能,則鏈接數又受制於線程數、線程很貴、沒法創建不少線程,則性能遇到瓶頸。

每鏈接順序請求的Redis

對於Redis來講,因爲服務端是全局串行的,可以保證同一鏈接的全部請求與返回順序一致。這樣可使用單線程+隊列,把請求數據緩衝。而後pipeline發送,返回future,而後channel可讀時,直接在隊列中把future取回來,done()就能夠了。

僞代碼以下:

/** * RedisClient */
public 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能力,同時獲取異步處理能力。

多鏈接短鏈接的HttpClient

相似於競對抓取的項目,每每須要創建無數的HTTP短鏈接,而後抓取,而後銷燬,當須要單機抓取上千網站線程數又受制的時候,怎麼保證性能呢?

何不嘗試NIO,單線程進行鏈接、寫、讀操做?若是鏈接、讀、寫操做系統沒有能力處理,簡單的註冊一個事件,等待下次循環就行了。

如何存儲不一樣的請求/響應呢?因爲http是無狀態沒有版本的協議,又沒有辦法使用隊列,好像辦法很少。比較笨的辦法是對於不一樣的socket,直接存儲socket的引用做爲map的key。

常見的RPC框架,如Thrift,Dubbo

這種框架內部通常維護了請求的協議和請求號,能夠維護一個以請求號爲key,結果的result爲future的map,結合NIO+長鏈接,獲取很是不錯的性能。

NIO高級主題

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的,因此咱們可稱之爲「系統級別」的或者「真正意義上」的異步,由於具體的讀寫是由操做系統代勞的。

舉個例子,將有助於理解Reactor與Proactor兩者的差別,以讀操做爲例(寫操做相似)。

Reactor中實現讀

  • 註冊讀就緒事件和相應的事件處理器
  • 事件分發器等待事件
  • 事件到來,激活分發器,分發器調用事件對應的處理器
  • 事件處理器完成實際的讀操做,處理讀到的數據,註冊新的事件,而後返回控制權

在Proactor中實現讀

  • 處理器發起異步讀操做(注意:操做系統必須支持異步IO)。在這種狀況下,處理器無視IO就緒事件,它關注的是完成事件
  • 事件分發器等待操做完成事件
  • 在分發器等待過程當中,操做系統利用並行的內核線程執行實際的讀操做,並將結果數據存入用戶自定義緩衝區,最後通知事件分發器讀操做完成
  • 事件分發器呼喚處理器
  • 事件處理器處理用戶自定義緩衝區中的數據,而後啓動一個新的異步操做,並將控制權返回事件分發器

能夠看出,兩個模式的相同點,都是對某個I/O事件的事件通知(即告訴某個模塊,這個I/O操做能夠進行或已經完成)。在結構上,二者也有相同點:事件分發器負責提交IO操做(異步)、查詢設備是否可操做(同步),而後當條件知足時,就回調handler;不一樣點在於,異步狀況下(Proactor),當回調handler時,表示I/O操做已經完成;同步狀況下(Reactor),回調handler時,表示I/O設備能夠進行某個操做(can read 或 can write)。

下面,咱們將嘗試應對爲Proactor和Reactor模式創建可移植框架的挑戰。在改進方案中,咱們將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的交互細節,從而對外暴露一個完整的異步接口。藉此,咱們就能夠進一步構建徹底可移植的,平臺無關的,有通用對外接口的解決方案。

代碼實例以下:

interface ChannelHandler {
    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);
                }

            }
        }
    }
    //全部channel的對應事件處理器
    Map<Channel, ChannelHandler> handlerMap;
}
複製代碼

Selector.wakeup()

主要做用

解除阻塞在Selector.select()/select(long)上的線程,當即返回。

兩次成功的select之間屢次調用wakeup等價於一次調用。

若是當前沒有阻塞在select上,則本次wakeup調用將做用於下一次select——「記憶」做用。

爲何要喚醒?

註冊了新的channel或者事件。

channel關閉,取消註冊。

優先級更高的事件觸發(如定時器事件),但願及時處理。

原理

Linux上利用pipe調用建立一個管道,Windows上則是一個loopback的tcp鏈接。這是由於win32的管道沒法加入select的fd set,將管道或者TCP鏈接加入select fd set。

wakeup往管道或者鏈接寫入一個字節,阻塞的select由於有I/O事件就緒,當即返回。可見,wakeup的調用開銷不可忽視。

Buffer的選擇

一般狀況下,操做系統的一次寫操做分爲兩步:

  1. 將數據從用戶空間拷貝到系統空間。

  2. 從系統空間往網卡寫。同理,讀操做也分爲兩步:

    • 將數據從網卡拷貝到系統空間;
    • 將數據從系統空間拷貝到用戶空間。

對於NIO來講,緩存的使用可使用DirectByteBuffer和HeapByteBuffer。若是使用了DirectByteBuffer,通常來講能夠減小一次系統空間到用戶空間的拷貝。但Buffer建立和銷燬的成本更高,更不宜維護,一般會用內存池來提升性能。

若是數據量比較小的中小應用狀況下,能夠考慮使用heapBuffer;反之能夠用directBuffer。

NIO存在的問題

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

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

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

總結

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