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

不送花的程式猿發表於2020-09-03

原文連結:如何解讀 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 是如何的強大,那麼就需要大家去自己體驗和摸索了~

相關文章