Java中I/O流:阻塞和非阻塞範例

banq發表於2024-04-18

I/O 流是輸入輸出操作的核心。這些是資料在源和目的地之間流動的路徑。

  • 輸入流:程式或應用程式使用這些流從檔案、連線、鍵盤等源讀取資料。
  • 輸出流:程式或應用程式使用這些流將資料寫入目標。

阻塞和非阻塞 I/O
基本 I/O 操作本質上通常是阻塞的,即它們會阻塞執行緒執行,直到有一些資料可供讀取。

例如,在正常的 HTTP 請求中:

  • 客戶端向繫結到某個埠(HTTP 的埠 80)的應用程式發出請求,
  • 它們首先在它們之間建立套接字連線。
  • 連線建立後,伺服器等待客戶端發出請求,
  • 然後透過同一個套接字傳送響應。

在普通的 Socket 連線中,我們希望客戶端和伺服器之間能夠持續通訊,而不需要一次又一次地經歷昂貴的 HTTP 請求過程,因此我們保持套接字連線開啟以進行通訊。伺服器等待客戶端傳送一些內容來響應它。

這意味著執行緒將被阻塞,直到客戶端說些什麼。這個 SocketServer 可以用 Java 來建立,如下所示:

ServerSocket serverSocket = new ServerSocket(port)
while (true) {
  Socket socket = serverSocket.accept();
  System.out.println("New client connected: " + socket);

  //ClientHandler.run 是一個函式,用於無限監聽 
  // 並處理客戶端訊息,直至連線關閉。
  ClientHandler clientHandler = new ClientHandler(socket);
  clientHandler.run();
}

解釋這種情況的一個例子是:假設兩個朋友正在玩一個遊戲,他們有一個管道可以互相通訊。一位朋友(客戶端)說了些什麼,另一位朋友(伺服器)記下訊息並響應“ack”。

這對於只有兩個朋友想玩遊戲的情況來說是很好的,即只有一個朋友在說話,另一個朋友在聽或等待他連續說話。

當多個朋友想要聊天和玩遊戲時,即多個朋友帶著各自的管道到達並希望在另一端寫下他們的訊息時,就會出現問題。但由於只有一個人正在收聽(即friend-2),因此他無法等待每個管道的一端來收聽訊息。對他來說,一個顯而易見的解決方案是僱用多人(每個管道一個人)並將他們分配給管道

這個概念就是多執行緒。在此,您為每個新連線生成一個新執行緒。

ExecutorService executorService = Executors.newSingleThreadExecutor();
ServerSocket serverSocket = new ServerSocket(port)
while (true) {
  Socket socket = serverSocket.accept();
  System.out.println("New client connected: " + socket);

  // ClientHandler.run 是一個無限監聽 和處理客戶端訊息的函式,直到連線關閉。
  ClientHandler clientHandler = new ClientHandler(socket);
  executorService.submit(clientHandler::run);
}

不過,這種方法也有很大的侷限性。作為伺服器方的朋友 2 不可能為每個玩遊戲的客戶朋友繼續僱人。如果朋友們繼續來玩,那麼到了某個時候,朋友 2 就會沒錢了,也就是說,伺服器不可能產生無限多的執行緒。這個問題需要一個更好的解決方案。

問題在於每個連線都會阻塞一個執行緒。這就是為什麼我們需要為每個新連線生成一個新執行緒。為了解決這個問題,非阻塞 I/O 出現了。這意味著一個連線不會阻塞一個執行緒,而且只要連線想傳送訊息,就會有某種機制去監聽它。

在 Java 中,一種流行的實現方式是使用通道和選擇器
在我們當前的例子中,我們的想法是安裝一個記錄器,分別記錄來自每個管道的訊息。現在,朋友 2  只需遍歷所有訊息,逐一回應並處理它們(寫在他的筆記本上)。這樣就不需要僱人了,朋友 2 就可以獨自監聽和處理來自他的每個朋友的訊息。

Java NIO 庫為輸入流提供了通道類。在我們當前的用例中,即 Socket 連線,它有 ServerSocketChannel 類。通道只是資料傳輸的途徑(即管道)。ServerSocketChannel 類允許我們將連線定義為非阻塞連線,並將其註冊到選擇器(記錄器)。

  1. 客戶端
    • 客戶端將訊息資料寫入其出站緩衝區。
    • 然後,客戶端嘗試透過通道將資料透過網路傳送到伺服器。
  2. 網路傳輸
    • 資料從客戶端的出站緩衝區中取出並透過網路傳送到伺服器。
  3. 伺服器端
    • 傳輸的資料到達伺服器的網路介面,並被放入與伺服器連線相關的網路輸入緩衝區中。
    • 伺服器的作業系統管理此輸入緩衝區並使資料可供伺服器的應用程式讀取。
  4. 伺服器應用程式:
    • 當伺服器的應用程式準備好從通道讀取時(透過呼叫channel.read()),它從網路輸入緩衝區讀取資料。
    • 如果資料正在輸入緩衝區中等待,伺服器會將其讀入其應用程式級緩衝區進行處理。
    <ul>
  5. 定義 ServerSocketChannel 並將其註冊到選擇器,如下所示:

    Selector selector = Selector.open();
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    // 配置無阻塞性
    serverSocketChannel.configureBlocking(false);
    InetSocketAddress inetSocketAddress = new InetSocketAddress(8000);
    serverSocketChannel.bind(inetSocketAddress);
    System.out.println("Socket Server started on port 8000");

    // 現在,您可以用所需的興趣鍵在選擇器上註冊頻道(將進一步解釋)。
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);


    Interest Key(興趣鍵)是您對通道感興趣的操作的鍵值。在當前上下文中,當我們建立一個新的套接字伺服器時,我們對新連線感興趣。該操作的興趣鍵是 SelectionKey.OP_ACCEPT。

    建立 ServerSocketChanel、將其繫結到埠 8000 並用選擇器註冊後,我們就可以開始處理連線了。

    Selector selector = Selector.open();
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    // configure non blocking nature
    serverSocketChannel.configureBlocking(false);
    InetSocketAddress inetSocketAddress = new InetSocketAddress(8000);
    serverSocketChannel.bind(inetSocketAddress);
    System.out.println("Socket Server started on port 8000");

    // 現在,您可以使用所需的興趣鍵(將進一步解釋)透過選擇器註冊通道。
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

    while(true) {
     //這是我們的選擇器中已準備好進行 
     //處理的鍵,即已準備好接受 (OP_ACCEPT) 的新連線、已準備好讀取 (OP_READ) 的連線訊息等。

     int readyCount = selector.select();
     if(readyCount==0){
      // skip when there is no key to be processed
      continue;
     }
     // get the ready keys set
     Set<SelectionKey> readyKeysSet = selector.selectedKeys();
     // iterate over the readyKeySet
     Iterator iterator = readyKeysSet.iterator();
     // 檢查是否有任何連線請求,伺服器是否準備好接受連線,即 OP_ACCEPT 已註冊。
     while(iterator.hasNext()) {
      SelectionKey key = (SelectionKey) iterator.next();
      iterator.remove();
      if (key.isAcceptable()) {
       System.out.println("Accepting connection");
       // 獲取客戶端連線的通道
       ServerSocketChannel server = (ServerSocketChannel) key.channel();
       // accept the connection
       SocketChannel client = server.accept();
       // 為該通道配置非阻塞行為
       client.configureBlocking(false);
       // 用相同的選擇器註冊客戶端通道。我們感興趣的操作是隻讀,即我們只想監聽客戶端訊息。選擇鍵為 OP_READ。
       SelectionKey clientKey = client.register(selector, SelectionKey.OP_READ); 
      }

      //檢查是否有任何客戶端要傳送訊息 
      // (只有當有任何訊息且 
      // OP_READ 已註冊時才為真)。
      if(key.isReadable()) {
       SocketChannel client = (SocketChannel) key.channel();
       int BUFFER_SIZE = 1024;
       // create a buffer
       ByteBuffer buffer = ByteBuffer.allocate(BUFFER_SIZE);
       try {
        // read data and store it into created buffer
        int bytesRead = client.read(buffer);
        if (bytesRead == -1) {
         System.out.println("Connection close");
         // Connection closed by client
         key.cancel();
         client.close();
         continue;
        }
        //將緩衝區從寫入模式翻轉為讀取模式
        buffer.flip();
        // 定義一個位元組陣列,其大小為緩衝區的位元組數。
        byte[] receivedBytes = new byte[buffer.remaining()];
        buffer.get(receivedBytes);
        // print the length of byte array
        System.out.println(receivedBytes.length);
        // 現在,在收到訊息後,我們要立即將回執寫入客戶端。因此,我們現在要註冊 OP_WRITE,這樣伺服器就可以向出站緩衝區寫入資訊了。
        key.interestOpsOr(SelectionKey.OP_WRITE);
       } catch (SocketException e) {
         e.printStackTrace();
         key.cancel();
         client.close();
         continue;
        } catch (Exception e) {
            e.printStackTrace();
        }
      }
      if(key.isWritable()) {
       SocketChannel client = (SocketChannel) key.channel();
       // 寫入與該客戶端連線相關的出站緩衝區
       client.write("ack");
       // 立即刪除 OP_WRITE 息鍵,這樣伺服器就不會準備好寫入。
       key.interestOps(key.interestOps() & ~SelectionKey.OP_WRITE);
      }
     }
    }

    這樣,我們就能在一個執行緒中處理多個客戶端連線及其讀寫。由於我們希望擴充套件並增加處理時間,因此應將處理讀取資訊等阻塞任務(處理過程包括向資料庫儲存內容等)解除安裝到單獨的執行緒中。這樣,一旦這些任務結束,執行緒就會被殺死。為了進一步最佳化,我們可以為任務的上下文建立執行緒池。

    ExecutorService executorService = Executors.newFixedThreadPool(n);

    這樣就建立了一個最多有 n 個可用執行緒的執行緒池。如果沒有可用執行緒,任務將在佇列中等待,直到有執行緒可用。

    為了進一步最佳化,我們還可以建立一個客戶端池,為每個池分配一個單獨的選擇器,並在單獨的執行緒中執行對每個池的處理。這樣,我們就可以增加處理量,從而延長客戶端的響應時間。

    該方法的全部 Java 程式碼如下:

    import java.io.IOException;
    import java.net.InetSocketAddress;
    import java.nio.channels.*;
    import java.util.Iterator;
    import java.util.Set;
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    public class MultiThreadedServer {

        private static final int PORT = 8080;
        private static final int MAX_CLIENTS_PER_POOL = 20;
        private static final int MIN_NUM_POOLS = 5; // Number of separate pools

        public static void main(String[] args) throws IOException {
            ExecutorService poolExecutor = Executors.newFixedThreadPool(MIN_NUM_POOLS);

            // Create and open a server socket channel
            ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
            serverSocketChannel.socket().bind(new InetSocketAddress(PORT));
            serverSocketChannel.configureBlocking(false);

            // Create a single selector for the server socket channel
            Selector serverSelector = Selector.open();
            serverSocketChannel.register(serverSelector, SelectionKey.OP_ACCEPT);

            // Array to hold selectors for client channels
            Selector[] clientSelectors = new Selector[MIN_NUM_POOLS];
            for (int i = 0; i < MIN_NUM_POOLS; i++) {
                clientSelectors[i] = Selector.open();
                ClientPoolHandler clientPoolHandler = new ClientPoolHandler(clientSelectors[i]);
                poolExecutor.submit(clientPoolHandler::run);
            }

            // Accept and handle client connections in separate threads for each pool
            while (true) {
                serverSelector.select();
                Set<SelectionKey> selectedKeys = serverSelector.selectedKeys();
                Iterator<SelectionKey> keyIterator = selectedKeys.iterator();

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

                    if (!key.isValid()) {
                        continue;
                    }

                    if (key.isAcceptable()) {
                        // Accept the connection
                        ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
                        SocketChannel clientChannel = serverChannel.accept();
                        boolean clientAdded = false;

                        // Check if any selector has capacity, otherwise create a new one
                        for (Selector selector : clientSelectors) {
                            int numClients = selector.keys().size() - 1; // Subtract 1 for the server channel
                            if (numClients < MAX_CLIENTS_PER_POOL) {
                                clientChannel.configureBlocking(false);
                                clientChannel.register(selector, SelectionKey.OP_READ);
                                clientAdded = true;
                                break;
                            }
                        }

                        // If no selector has capacity, create a new one and spawn a new thread
                        if (!clientAdded) {
                            Selector newSelector = Selector.open();
                            clientChannel.configureBlocking(false);
                            clientChannel.register(newSelector, SelectionKey.OP_READ);
                            ClientPoolHandler clientPoolHandler = new ClientPoolHandler(newSelector); 
                            poolExecutor.submit(clientPoolHandler::run);
                        }
                    }
                }
            }
        }

        private static class ClientPoolHandler{
            private Selector selector;

            public ClientPoolHandler(Selector selector) {
                this.selector = selector;
            }

            public void run() {
                try {
                    while (true) {
                        selector.select();
                        // Handle selected keys
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    在這裡,我們維持最低數量的池執行,以保證應用程式在需要時隨時可用。

    結論
    基本 I/O 操作通常是阻塞式的,會導致執行緒等待資料可用。然而,隨著可擴充套件性需求的出現,特別是在有多個併發連線的情況下,由於資源限制,阻塞 I/O 變得不切實際。

    在 Java NIO(非阻塞 I/O)等技術的推動下,向非阻塞 I/O 的轉變提供了更具擴充套件性的解決方案。透過將連線與執行緒解耦,並採用通道和選擇器等機制,非阻塞 I/O 允許伺服器在不耗盡系統資源的情況下處理大量連線。

    從本質上講,非阻塞 I/O 使伺服器能夠非同步管理多個連線,從而提高效能和可擴充套件性。透過採用這種方法,我們可以構建穩健高效的系統,既能處理高負載,又能保持響應速度。

    相關文章