java同步非阻塞IO

weixin_33806914發表於2018-07-02

非同步IO程式設計在javascript中得到了廣泛的應用,之前也寫過一篇博文進行梳理。
js的非同步IO即是非同步的,也是非阻塞的。非阻塞的IO需要底層作業系統的支援,比如在linux上的epoll系統呼叫。

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

<!--more-->

傳統的同步阻塞IO

同步阻塞IO的特點

傳統的同步IO方式,比如網路傳輸,比如檔案IO,在呼叫者呼叫read()時,呼叫會被一層一層呼叫下去直到OS的系統呼叫,呼叫者的執行緒會被阻塞。
當讀取完成時,該執行緒又會被喚醒,read()函式返回IO操作讀取的資料。

我們很容易能發現這種方式的特點及優劣:

  1. 介面容易理解,程式設計難度低。對呼叫者而言,read()就像一個普通的函式呼叫一樣,返回讀取的資料。只不過可能這個操作有點慢,這個函式執行時間長了一些而已。
  2. 在費時的IO操作時,執行緒需要等待IO完成。這意味著,如果你需要多個IO操作同時進行,就只能通過開多個執行緒來解決。

在客戶端程式設計時,第二點這個問題不大。客戶端程式對IO的併發要求不高,反而因為同步阻塞IO的介面易於程式設計而能夠減輕程式設計難度,程式碼更直觀更可讀,從而變相的提高可除錯性和開發效率。

服務端程式設計的特點

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

  1. 使用一個執行緒監聽埠,如有客戶端的TCP連線連入,就交由處理執行緒處理。
  2. 每來一個TCP連線,就需要開一個執行緒來處理和該客戶端的邏輯。

在實際場景中會有很多優化技術,比如使用執行緒池。然而執行緒池僅僅是將TCP連線放入一個佇列裡交由執行緒池中空閒的執行緒處理。
實質上,即使使用執行緒池,也改變不了正在被處理的每一個請求都需要佔用一個單獨的執行緒這一事實。
這樣,會造成一些問題:

  1. 每一個請求需要一個執行緒來處理,但是伺服器的執行緒數量是有上限的,這就限制了伺服器的併發量。
  2. 執行緒本身的排程也佔用一定的作業系統資源,線上程比較多的情況下,這個佔用疊加起來就非常客觀。

多路複用IO

概念及模型

java提供的NIO就是一種多路複用IO方式。
它能夠將多個IO操作用一個執行緒去管理,一個執行緒即可管理多個IO操作。

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來。

相關文章