如何解讀 Java IO、NIO 中的同步阻塞與同步非阻塞?

原文鏈接:如何解讀 Java IO、NIO 中的同步阻塞與同步非阻塞?

一、前言

最近剛讀完一本書:《Netty、Zookeeper、Redis 併發實戰》,個人覺得 Netty 部分是寫得很不錯的,讀完之後又對 Netty 進行了一波很好的複習(之前用 spring boot + netty + zookeeper 模仿 dubbo 做 rpc 框架,那時候是剛學 netty 後自己造的小輪子)。

雖然對於 Netty 的使用已經比較熟悉了,而且還知道它的底層是基於 Java NIO 做進一步的封裝,使得併發性能和開發效率得到大大的提升。但是,對於同步阻塞、同步非阻塞、異步這些概念,還是比較的模糊,一直處於似懂非懂的狀態。

所以這兩天,一直在網上看看大家對此的評論,也得到了一些啓發。而且還有很多同學們提到了 《Netty 權威指南 第二版》 這本書,說前兩章對於網絡 I/O 模型和 Java I/O 的介紹很不錯,所以我也特意去找了一本 pdf 來看看(比較窮。。。)。看了前兩章後,確實對於這方面的概念清晰了不少,所以決定寫下此文章來記錄一下,也分享給更多不清楚這方面理論的同學們,並且也下定決定,有空一定把這本書繼續看完,哈哈哈。

二、Linux 網絡 I/O 模型

其實我們一直說到的同步異步、阻塞非阻塞,都是基於系統內核提供的系統命令來說的;而我們通常都是使用 Linux 系統的服務器,所以我們很有必要去了解關於 Linux 系統內核的相關概念,而最重要的是,UNIX 網絡編程對 I/O 模型的分類。

UNIX 提供了五種 I/O 模型:

  1. 阻塞 I/O 模型:缺省情況下,所有文件操作都是阻塞的。我們以套接字接口爲例講解此模型:在進程空間中調用 recvfrom,其系統調用知道數據包到達且被複制到應用進程的緩衝區中或者發生錯誤時才返回,在此期間一直會等待,進程在從調用 recvfrom 開始到它返回的整段時間內都是被阻塞的,因此被稱爲阻塞 I/O 模型。
    阻塞I/O模型

  2. 非阻塞 I/O 模型:recvfrom 從應用層到內核的時候,如果該緩衝區沒有數據的話,就直接返回一個 EWOULDBLOCK 錯誤,一般都對非阻塞 I/O 模型進行輪詢檢查這個狀態,看內核是不是有數據到來。
    非阻塞 I/O 模型

  3. I/O 複用模型:Linux 提供 select/poll 進程通過將一個或多個 fd 傳遞給 select 或 poll 系統調用,阻塞在 select 操作上,這樣 select/poll 可以幫我們偵測多個 fd 是否處於就緒狀態。select/poll 是順序掃描 fd 是否就緒,而且支持的 fd 數量有限,因此它的使用收到了一下制約。Linux 還提供了一個 epoll 系統調用,epoll 使用基於事件驅動方式代替順序掃描,因此性能更高。當有 fd 就緒時,立刻回調函數 rollback。
    I/O 複用模型

  4. 信號驅動 I/O 模型:首先開啓套接口信號驅動 I/O 功能,並通過系統調用 sigaction 執行一個信號處理函數(此係統調用立刻返回,進程繼續工作,它是非阻塞的)。當數據準備就緒時,就爲該進程生成一個 SIGIO 信號,通過信號回調通知應用程序調用 recvfrom 來讀取數據,並通知主循環函數處理數據。
    信號驅動 I/O 模型

  5. 異步 I/O:告知內核啓動某個操作,並讓內核在整個操作完成後(包括將數據從內核複製到用戶自己的緩衝區)通知我們。這種模型與信號驅動模型的主要區別是:信號驅動 I/O 由內核通知我們何時可以開始一個 I/O 操作;而異步 I/O 模型由內核通知我們 I/O 操作何時已經完成。
    異步 I/O

以上資料摘自《Netty 權威指南 第2版》。

三、Java 中 IO 和 NIO

我們都知道 Java 中:IO 是同步阻塞,而 NIO 是同步非阻塞;而經過上面關於 Liunx 網絡 I/O 模型的解讀,我們都已經比較清楚地瞭解了同步異步和阻塞非阻塞的概念。那麼我們接下來應該從編程中去解讀 Java IO 的同步阻塞和 Java NIO 的同步非阻塞。

Java IO 編程:

1、我們先看看 Java IO 編程中的服務端代碼:

public class IOServer {
    public static void main(String[] args) throws Exception {

        ServerSocket serverSocket = new ServerSocket(8000);

        // (1) 接收新連接線程
        new Thread(() -> {
            while (true) {
                try {
                    // (1) 阻塞方法獲取新的連接
                    Socket socket = serverSocket.accept();

                    // (2) 每一個新的連接都創建一個線程,負責讀取數據
                    new Thread(() -> {
                        try {
                            int len;
                            byte[] data = new byte[2];
                            InputStream inputStream = socket.getInputStream();
                            // (3) 按字節流方式讀取數據
                            while ((len = inputStream.read(data)) != -1) {
                                System.out.println(new String(data, 0, len));
                            }
                        } catch (IOException e) {
                        }
                    }).start();

                } catch (IOException e) {
                }

            }
        }).start();
    }
}

在 IOServer 中,會開着 while 死循環一直調用 ServerSocket#accpet() 方法來監聽等待客戶端連接:ServerSocket 主動監聽是否有客戶端請求連接,如果沒有的話就會一直阻塞等待着,所以說 IO 是同步阻塞的;

當 ServerSocket 接收到新的連接請求,一般會創建一條新線程來處理接下來客戶端的寫請求(當然了,也可以在同一條線程中處理);在線程裏面,會調用 Socket 輸入流(InputStream)的 read(byte b[]) 方法來讀取客戶端發送過來的數據:該方法會一直阻塞着,直到客戶端發送數據過來;當發現內核態中有數據了,就會將數據複製到用戶態中(也就是字節數組中),所以說 IO 是同步阻塞的。

弊端:當消息發送方發送請求比較緩慢,或者網絡傳輸比較慢時,消息接收方的讀取輸入流會被長時間堵塞,直到發送方的數據發送完成。

2、接下來繼續看看 Java IO 編程中的客戶端代碼:

public class IOClient {

    public static void main(String[] args) {
        new Thread(() -> {
            try {
                Socket socket = new Socket("127.0.0.1", 8000);
                while (true) {
                    try {
                        socket.getOutputStream().write((new Date() + ": hello world").getBytes());
                        Thread.sleep(2000);
                    } catch (Exception e) {
                    }
                }
            } catch (IOException e) {
            }
        }).start();
    }
}

在 IOClient 中,開着 while 死循環一直調用客戶端 Socket 輸出流(OutputStream)的 write(byte b[]) 方法往服務端發送數據;而此時,客戶端會一直阻塞着,直到所有的字節全部寫入完畢或者發生異常。

弊端:當消息接收方處理比較緩慢時,最後可能會導致 TCP 的緩衝區充滿未被處理的數據;此時消息發送方不能再繼續往 TCP 緩衝區寫入消息,會一直被阻塞着。

3、Java IO 同步阻塞解讀:

在 Java IO 中,不管是服務端還是客戶端,不管是讀取數據還是寫入數據,都需要自己主動去完成這個 I/O 操作,這就是同步。而如果對方處理消息的效率比較慢,進程可能會因爲執行此次 I/O 操作而導致被一直阻塞着,這就是阻塞。

Java NIO 編程:

1、我們先看看 Java NIO 編程中的服務端代碼:

public class NIOServer {
    public static void main(String[] args) throws IOException {
        Selector serverSelector = Selector.open();
        Selector clientSelector = Selector.open();

        new Thread(() -> {
            try {
                // 對應IO編程中服務端啓動
                ServerSocketChannel listenerChannel = ServerSocketChannel.open();
                listenerChannel.socket().bind(new InetSocketAddress(8000));
                listenerChannel.configureBlocking(false);
                listenerChannel.register(serverSelector, SelectionKey.OP_ACCEPT);

                while (true) {
                    // 監測是否有新的連接,這裏的1指的是阻塞的時間爲 1ms
                    if (serverSelector.select(1) > 0) {
                        Set<SelectionKey> set = serverSelector.selectedKeys();
                        Iterator<SelectionKey> keyIterator = set.iterator();

                        while (keyIterator.hasNext()) {
                            SelectionKey key = keyIterator.next();

                            if (key.isAcceptable()) {
                                try {
                                    // (1) 每來一個新連接,不需要創建一個線程,而是直接註冊到clientSelector
                                    SocketChannel clientChannel = ((ServerSocketChannel) key.channel()).accept();
                                    clientChannel.configureBlocking(false);
                                    clientChannel.register(clientSelector, SelectionKey.OP_READ);
                                } finally {
                                    keyIterator.remove();
                                }
                            }

                        }
                    }
                }
            } catch (IOException ignored) {
            }

        }).start();


        new Thread(() -> {
            try {
                while (true) {
                    // (2) 批量輪詢是否有哪些連接有數據可讀,這裏的1指的是阻塞的時間爲 1ms
                    if (clientSelector.select(1) > 0) {
                        Set<SelectionKey> set = clientSelector.selectedKeys();
                        Iterator<SelectionKey> keyIterator = set.iterator();

                        while (keyIterator.hasNext()) {
                            SelectionKey key = keyIterator.next();

                            if (key.isReadable()) {
                                try {
                                    SocketChannel clientChannel = (SocketChannel) key.channel();
                                    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                                    // (3) 面向 Buffer
                                    clientChannel.read(byteBuffer);
                                    byteBuffer.flip();
                                    System.out.println(Charset.defaultCharset().newDecoder().decode(byteBuffer)
                                            .toString());
                                } finally {
                                    keyIterator.remove();
                                    key.interestOps(SelectionKey.OP_READ);
                                }
                            }

                        }
                    }
                }
            } catch (IOException ignored) {
            }
        }).start();


    }
}

在 NIOServer 中,會創建並打開兩個 Selector ,Selecotr 是 Java NIO 中的核心組件,底層利用的是 I/O 多路複用模型。

  • 一個 Selector 負責監聽 ServerSocketChannel 中的客戶端連接請求,如果有新的客戶端請求連接,那麼就會創建對應的 SocketChannel,然後往另外一個 Selector 中註冊;如果沒有,則直接返回,不會在這裏阻塞着,進程可以繼續做別的事情,所以 NIO 是同步非阻塞。

  • 第二個 Selecotr,就是負責監聽哪些 SocketChannel 有讀寫事件,如果有的話則進行對應的 I/O 操作;而如果沒有,也是直接返回,不會在這裏一直阻塞着,進程可以繼續做別的事情,所以 NIO 是同步非阻塞。

2、Java NIO 中客戶端的編程:

這個我們就不用上代碼了,其實和服務端中第二個 Selector 的使用一樣的。

3、Java NIO 同步非阻塞解讀:

在 Java NIO 中,不管是服務端還是客戶端,都會將自己註冊到 Selector 中,如果哪個 Channel 有請求連接事件( ServerSocketChannel)或者是讀寫事件(SocketChannel),那麼這個 Channel
就會處於就緒狀態;接着會被 Selector 輪詢出來,進行後續的 I/O 操作。這就不會出現 IO 編程中的阻塞狀態,所以 NIO 是同步非阻塞的。

四、總結

通過上面的講解分析,可能還是會有很多同學不能真正理解同步異步、阻塞非阻塞這些概念,畢竟這些是我自己個人的理解和解讀,所以我還是非常推薦同學們自己去看看《Netty 權威指南》這本書,和看看 Java 中關於 IO 和 NIO 編程的相關源碼,一定要讓自己理解地更加深刻。

通過上面的 NIO 源碼展示,我相信很多同學會發現使用 Java NIO 來進行開發,會比較的費勁:

  1. Java NIO 的類庫和 API 比較複雜,我們需要熟練掌握相關類和接口的使用。
  2. Java NIO 的可靠性是比較低的,例如斷開重連、半包問題和序列化都是需要開發者自己去搞定的。
  3. Java NIO 中有一個非常出名的 BUG,那就是關於 epoll 的 bug,它會導致 Selector 空輪詢,最終導致 CPU 100%。

所以,如果我們進行 NIO 編程,都會首選 Netty 這款 NIO 框架。而至於 Netty 是如何的強大,那麼就需要大家去自己體驗和摸索了~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章