java同步非阻塞IO

異步IO編程在javascript中獲得了普遍的應用,以前也寫過一篇博文進行梳理。
js的異步IO便是異步的,也是非阻塞的。非阻塞的IO須要底層操做系統的支持,好比在linux上的epoll系統調用。javascript

從另一個角度看待的話,底層操做系統對於非阻塞IO的系統調用是一種多路複用機制,js對其進行了比較厚的封裝,轉換成了異步IO。
可是,也能夠進行一層稍微薄點的封裝,保留這種多路複用的模型,好比java的NIO,是一種同步非阻塞的IO模型。
非阻塞IO的一大優點是,性能好,快啊!這在對IO性能要求高的場景獲得了大量應用,好比SOA框架。html

<!--more-->java

傳統的同步阻塞IO

同步阻塞IO的特色

傳統的同步IO方式,好比網絡傳輸,好比文件IO,在調用者調用read()時,調用會被一層一層調用下去直到OS的系統調用,調用者的線程會被阻塞。
當讀取完成時,該線程又會被喚醒,read()函數返回IO操做讀取的數據。linux

咱們很容易能發現這種方式的特色及優劣:git

  1. 接口容易理解,編程難度低。對調用者而言,read()就像一個普通的函數調用同樣,返回讀取的數據。只不過可能這個操做有點慢,這個函數執行時間長了一些而已。
  2. 在費時的IO操做時,線程須要等待IO完成。這意味着,若是你須要多個IO操做同時進行,就只能經過開多個線程來解決。

在客戶端編程時,第二點這個問題不大。客戶端程序對IO的併發要求不高,反而由於同步阻塞IO的接口易於編程而可以減輕編程難度,代碼更直觀更可讀,從而變相的提升可調試性和開發效率。github

服務端編程的特色

然而,在服務器端編程的時候,這個劣勢就很明顯了,服務器端程序可能會面臨大量併發IO的考驗。
傳統的同步IO方式,好比說socket編程,服務器端的一個簡單的處理邏輯是這樣的:編程

  1. 使用一個線程監聽端口,若有客戶端的TCP鏈接連入,就交由處理線程處理。
  2. 每來一個TCP鏈接,就須要開一個線程來處理和該客戶端的邏輯。

在實際場景中會有不少優化技術,好比使用線程池。然而線程池僅僅是將TCP鏈接放入一個隊列裏交由線程池中空閒的線程處理。
實質上,即便使用線程池,也改變不了正在被處理的每個請求都須要佔用一個單獨的線程這一事實。
這樣,會形成一些問題:segmentfault

  1. 每個請求須要一個線程來處理,可是服務器的線程數量是有上限的,這就限制了服務器的併發量。
  2. 線程自己的調度也佔用必定的操做系統資源,在線程比較多的狀況下,這個佔用疊加起來就很是客觀。

多路複用IO

概念及模型

java提供的NIO就是一種多路複用IO方式。
它可以將多個IO操做用一個線程去管理,一個線程便可管理多個IO操做。bash

NIO的操做邏輯是這樣的,首先將須要監控的IO操做註冊到某個地方,並由一個線程管理。
當這些IO操做完成,會以事件的形式產生。該線程可以獲取到完成的事件列表,而且對其進行處理。服務器

java的NIO中有三個重要的概念:

  1. Channel通道。表示一種IO原始源。如ServerSocketChannel表示監聽客戶端發起的TCP鏈接。
    經過Channel可以發起某種IO操做,可是卻當即返回不阻塞。
  2. Buffer 緩衝區。Channel讀取或寫入的數據必須經過Buffer。網絡讀寫經常使用的是ByteBuffer。
  3. Selector 選擇器。NIO中最核心的東西,將Channel註冊到Selector中,使得Selector可以監控到該IO操做。
    能夠理解成Selecotr不斷輪詢被註冊的Channel,一旦Channel中有註冊的事件發生,便能處理髮生的事件。

這裏只是作個總結,看下下面的示例代碼就明白了。

Selector和Channel

private void exec(int port) throws IOException {
    Selector selector = Selector.open();
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    serverSocketChannel.socket().bind(new InetSocketAddress(port));
    serverSocketChannel.configureBlocking(false);
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

    while (true) {
        int n = selector.select(); // Block
        Iterator<SelectionKey> it = selector.selectedKeys().iterator();
        while (it.hasNext()) {
            SelectionKey key = it.next();
            if (key.isAcceptable()) {
                ServerSocketChannel server = (ServerSocketChannel) key.channel();
                SocketChannel channel = server.accept();
                if (channel != null) {
                    channel.configureBlocking(false);
                    channel.register(selector, SelectionKey.OP_READ);
                    onAccept(channel);
                }
            }
            if (key.isReadable()) {
                SocketChannel socketChannel = (SocketChannel) key.channel();
                onRead(socketChannel);
            }
            it.remove();
        }
    }
}

來一步一步的分析這些代碼。

首先,第3行到第6行是對通道ServerSocketChannel的操做。
對於這個ServerSocketChannel,首先是設定了它的監聽地址,這個與傳統的阻塞IO一致,給定一些初始的數據。傳統的阻塞IO以後會調用socket.accept()來獲取客戶端鏈接的TCP鏈接,這是一個阻塞的方法。
可是NIO在這裏把ServerSocketChannel註冊到了Selector上,而且監控OP_ACCEPT事件。這個時候socket能夠認爲已經在監聽了,可是沒有阻塞線程。
以後,若是有TCP鏈接鏈接上,OP_ACCEPT事件就會產生,經過selector便可處理該事件。
所以,NIO的操做邏輯實際上是事件驅動的。

後面的循環則是Selector處理的主邏輯。
第9行,這是一個阻塞的方法。它會等待被註冊的這些IO操做處理完成。一旦有一部分IO操做完成,它就會返回。
經過selector.selectedKeys()便可得到完成的IO操做的事件。後面的代碼也就是在處理這些事件。
這部分完成的IO事件處理完畢後,就會循環的去處理下一批完成的IO事件,如此往復。
這裏,咱們能夠清晰的看到,經過NIO的多路複用模型,咱們經過一個線程,就能管理多個IO操做。

循環內部處理的邏輯,key.isAcceptable()能夠認爲是判斷該事件是不是OP_ACCEPT事件。是的話表示已經有客戶端TCP鏈接鏈接上了,第15行獲取該TCP鏈接的socket對象。因爲是NIO編程,這是獲取到的是SocketChannel對象。
以後將該對象的OP_READ註冊到Selector上,發起IO讀操做,而且讓Selector監聽讀完成的事件。

後面的key.isReadable()也是一樣的道理,這裏只有上面的代碼註冊了OP_READ事件,所以這裏必定是上面的讀操做完成了產生的事件。

Buffer

上面的代碼裏,當有新的TCP鏈接連入時,調用回調函數onAccept;當對方傳輸數據給本身時,數據讀取完成後,調用回調函數onRead

下面是這兩個回調函數的實現,它的功能很簡單:

  1. 當有TCP鏈接第一次連入時,發送hello\n給對方。
  2. 當接收到對方傳來的數據時,原封不動的送回去。大概算是一個echo服務器。
private void onRead(SocketChannel socketChannel) throws IOException {
    ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
    int count;
    while ((count = socketChannel.read(buffer)) > 0) {
        buffer.flip();
        while (buffer.hasRemaining()) {
            socketChannel.write(buffer);
        }
        buffer.clear();
    }

    if (count < 0) {
        socketChannel.close();
    }
}

private void onAccept(SocketChannel channel) throws IOException {
    System.out.println(channel.socket().getInetAddress() + "/" + channel.socket().getPort());
    ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
    buffer.put("hello\n".getBytes());
    buffer.flip();
    channel.write(buffer);
}

從上面的代碼能夠看出:

  1. onRead中的讀操做是非阻塞的。在以前數據的網絡傳輸已經完成了,這裏只是處理傳輸完成的數據而已。
  2. 至於這裏的寫操做是否是阻塞的。。。我以爲不是阻塞的,這一點我還不肯定 ,時間有限,以後會通過代碼驗證,查更多資料去確認這一點。
  3. 全部的讀寫操做的數據都須要通過Buffer。那爲何要增長Buffer這一抽象概念?直接使用bytes[]不挺好嗎?
    我猜想和NIO底層原理有關係,可能OS將數據傳輸到了操做系統原生的內存裏,java使用的話複製到jvm內存中。我也不肯定。。。 未來查更多資料去完善這一疑惑吧。

DEMO效果

上面經過一個小DEMO,也就是一個簡單的ECHO服務器演示了NIO編程。下面來測試下結果:

frapples:~ ✔> nc -nvv 127.0.0.1 4040
Connection to 127.0.0.1 4040 port [tcp/*] succeeded!
hello
jfldjfl
jfldjfl
jfldjflieu
jfldjflieu
jfldhgldjfljdl
jfldhgldjfljdl

效果不錯!不過這還沒完。
嘗試開啓多個終端,同時鏈接服務器,你會驚訝的發現,服務器可以完美的同時和多個客戶端鏈接而不會出現「卡死」的狀況。
回顧剛纔的小DEMO咱們能夠發現,剛纔的DEMO是 單線程 的,可是經過多路複用模型,卻能同時處理多個IO操做。

底層原理

硬件機制

以前在博文《異步IO和同步IO》中也提到了一些異步IO的操做系統機制。
非阻塞IO須要操做系統機制的支持,在linux系統上,對應的是select/poll系統調用或epoll系統調用。

操做系統的做用之一是對硬件設備的管理,咱們發現,負責運算的部件CPU和負責網絡傳輸的部件網卡,它們是互相獨立的,所以,它們實際上能夠同時執行任務。那麼,底層硬件的支持使得徹底能夠作到如下步驟:

  1. CPU發送給網卡某些網絡IO操做請求,網卡接收到CPU接收到的請求。
  2. 網卡處理接收到的網絡IO操做任務,於此同時,CPU也能執行其它的計算工做。
  3. 當網卡的網絡IO操做完成後,經過硬件中斷機制給CPU發中斷。
  4. CPU執行中斷處理程序,執行IO操做完成後的邏輯。

這裏有個小小的問題,在讀取數據的時候,上面的步驟網卡讀取數據時顯然是不經過CPU的。以我我的有限的硬件知識推測,非阻塞IO的機制可能須要用到DMA。
仍然是我的推測,之後有時間去查閱相關資料去解決這個疑惑。

咱們能夠看到,硬件的運做方式自然就是異步的,也所以,操做系統也很是容易基於此進行抽象和封裝,向上提供非阻塞的IO系統調用。

OS系統調用

linux操做系統的系統調用提供了多路複用的非阻塞IO的系統調用,這也是java NIO機制實現須要用到的。
在linux2.6以前,採用select/poll系統調用實現,而在linux2.6以後,採用epoll實現,使用紅黑樹優化過,也所以性能更高。

最後

本篇博文梳理的java的NIO機制,這是一種多路複用模型,可以使用一個線程去管理多個IO操做,避免傳統同步IO的線程開銷,大大提高性能。

從我我的的觀點,評判一種模型是否易用,一方面來看該模型是否與實際的問題特色相契合;另一方面,看該模型須要開發者花多少成本在模型自己上而非業務邏輯上。
從這個標準出發,咱們也不難發現,自己異步IO的回調方式就夠讓開發者頭疼的了,然而和異步IO相比,NIO比異步IO還要麻煩。
你須要花大量精力去時間去處理,去理解NIO自己的邏輯。所以,NIO的缺點是較高的開發成本和較晦澀的代碼,不優雅。

NIO在SOA框架,RPC框架等服務器領域有着較大的應用,除了java標準庫的NIO以外,這些實際生產的框架多使用第三方的NIO框架Netty。
緣由之一是,java標準庫的NIO有一個bug,可能形成CPU 100%的佔用。

感謝

今天,是我在公司實習呆的最後一天,我花了一個下午的時間去組織這篇博文。
感謝個人老大對個人器重和信任,給予我不少的機會去鍛鍊,也給予了我很大的自由空間去研究技術,自我提高。
也感謝這段時間對我照顧,給予我幫助的同事們,祝福大家!

注:該文於2018-04-13撰寫於個人github靜態頁博客,現同步到個人segmentfault來。

相關文章
相關標籤/搜索