Java 非同步 I/O

Robothy發表於2022-01-19

Java 中的非同步 I/O 簡稱 AIO, A 即 Asynchronous。AIO 在 JDK1.7 時引入,基於作業系統提供的非同步 I/O 通訊模型,封裝了一些進行非同步 I/O 操作的 API。

1. 非同步 I/O 模型

學習 Java I/O 相關操作之前應該先了解其背後的 I/O 模型。Java 典型的基於流的檔案操作和網路通訊都是基於同步阻塞 I/O 模型,JDK1.4 引入的 NIO 基於多路複用 I/O 模型,而 AIO 則基於非同步 I/O 模型。在 Linux 作業系統中,非同步模型從 I/O 裝置讀取資料的流程如下圖所示。

應用程式核心aio_read資料未準備好程式繼續執行等待資料資料已準備好複製完成複製資料到使用者空間處理資料系統呼叫傳遞訊號立即返回

  • 應用程式向核心發起 aio_read 系統呼叫,傳遞快取區資訊,要讀取的檔案資訊;
  • 核心接收請求之後立即返回,應用程式未阻塞;
  • 核心等待 CPU 或者 DMA 裝置將資料從 I/O 裝置複製到核心緩衝區;
  • 核心將資料複製到使用者空間緩衝區;
  • 核心傳送一個訊號給使用者程式,告知資料已複製完成;
  • 應用程式處理使用者空間緩衝區中的資料。

2. 非同步通道

基於非同步 I/O 模型,JDK 提供了面向通道和緩衝區程式設計的 API。事實上,Java 為基於同步阻塞 I/O 模型的 “舊I/O” 和基於多路複用 I/O 模型的 “新I/O” 也提供了面向通道和緩衝區的 API。非同步 I/O 的核心介面是 AsynchronousChannel,這個介面有檔案 I/O 的實現和網路 I/O 的實現。

java.nio.channels<<interface>>AsynchronousChannelAsynchronousServerSocketChannelAsynchronousSocketChannel<<interface>>AsynchronousByteChannelAsynchronousFileChannel

  • AsynchronousFileChannel 非同步檔案通道,用於非同步操作檔案;
  • AsynchronousSocketChannel 非同步套接字通道,用於 TCP 通訊;
  • AsynchronousServerSocketChannel 非同步套接字監聽通道,作為服務端,接收 TCP 連線並建立 AsynchronousSocketChannel

3 非同步操作的兩種形式

非同步通道 AsynchronousChannel 並沒有顯式提供一些必須實現的非同步操作的抽象方法(事實上,這個介面僅提供了抽象方法 close()),但是它在註釋中給出了非同步操作 API 的兩種形式。一種非同步操作是返回一個 Future,另一種是往非同步方法中傳遞一個回撥函式,也就是一個 CompletionHandler 的物件,這兩種形式一般的非同步程式設計框架中很常見。

3.1 返回 Future 形式

Future<V> operation(...)
void operation(... A attachment, CompletionHandler<V,? super A> handler)

其中 operation() 代表非同步操作,比如從 I/O 裝置中讀取資料 read,往 I/O 裝置中寫入資料 write。

第一種是非同步操作返回 Future<V>,其中 V 是非同步操作返回值的型別。開發人員可以呼叫 Future#isDone() 或者 Future#isCancelled() 查詢非同步操作的狀態,也可以呼叫 Future#get(),阻塞當前執行緒,直到非同步操作完成。

讀取資料

Future<Integer> future = readChan.read(buff); // 非同步讀取資料,並立即返回
future.get(); // 阻塞,等到非同步操作完成,效率低

寫入資料

Future<Integer> future = writeChan.write(buff, position); // 非同步寫入資料,並立即返回
len = future.get(); // 阻塞等待非同步操作完成,效率低

當然,為了提高效率,開發過程中也可以不呼叫 Future#get() 方法來阻塞程式碼,可以通過輪詢的方式檢查 Future 是否已經完成,完成之後再呼叫 Future#get() 來獲取結果。

3.2 回撥形式

第二種操作是往非同步函式中傳遞一個 A attachmentCompletionHandler<V, ? super A>。其中 A 表示附件的型別,附件通常用來往 CompletionHandler 物件中傳入一些上下文資訊,V 表示非同步操作返回值型別。CompletionHandler 提供了兩個抽象方法:completed(V result, A attachment) 和 failed(Throwable t, A attachment)。當非同步操作成功,completed 會被呼叫;當非同步操作失敗,failed 會被呼叫。讀取到一個資料塊就會呼叫回撥程式碼,不會阻塞。

可以採用匿名內部類的方式去實現回撥介面,也可以採用一般實現類,通過 attachment 傳遞上下文的形式實現回撥邏輯。

匿名內部類方式

readChan.read(buff, 0, null, new CompletionHandler<>() { // 從位置 0 開始讀取資料,資料讀取到緩衝區 buff 中
      long readSize = 0; // 已經讀取的位元組數
      @Override
      public void completed(Integer result, Object attachment) {
        // 列印讀取到的資料
        System.out.println(Thread.currentThread() + new String(buff.array(), 0, result));
        try {
          if ( (readSize = readSize + result) < readChan.size()) { // 已讀取位元組數少於檔案總位元組數,繼續讀取
            buff.clear(); // 將 buff 的 position 移動到起始位置,使其變為可寫狀態
            readChan.read(buff, readSize, null, this); // 遞迴,繼續讀取,注意改變讀取位置,Handler 直接使用 this。
          } else {
            semaphore.release();
          }
        } catch (IOException e) {
          e.printStackTrace();
        }
      }

傳遞上下文(attachment)方式

一般的呼叫邏輯。

Context context = new Context();          // 自定義類,存放上下文資訊,上下文資訊可根據需要設定
context.asyncFileChan = asyncFileChan;
context.buffer = ByteBuffer.allocate(4);
AsyncReadDataHandler callback = new AsyncReadDataHandler(); // 建立一個處理器物件
asyncFileChan.read(context.buffer, 0, context, callback); // 執行非同步讀取資料

AsyncReadDataHandler 和 Context 的實現。

/** 定義上下文類 */
class Context {
  AsynchronousFileChannel asyncFileChan;
  ByteBuffer buffer;
}

/** 回撥實現類 */
class AsyncReadDataHandler implements CompletionHandler<Integer, Context> {

  private long readSize = 0;

  private Semaphore semaphore = new Semaphore(0);

  @Override
  public void completed(Integer size, Context context) {
    System.out.print(new String(context.buffer.array(), 0, size));
    context.buffer.clear();
    try {
      if ( (readSize = readSize + context.buffer.limit()) < context.asyncFileChan.size()) {
        // 還有資料,繼續讀。資料放入到 context.buffer 中,從 readSize 位置開始讀,附件是 context,處理器是當前物件
        context.asyncFileChan.read(context.buffer, readSize, context, this);
      } else {
        semaphore.release();
      }
    } catch (IOException e) {
      e.printStackTrace();
      semaphore.release();
    }
  }

  @Override
  public void failed(Throwable cause, Context context) {
    cause.printStackTrace();
    semaphore.release();
  }

  // 等待結束
  public void waitForEnd() throws InterruptedException {
    semaphore.acquire();
  }
}

4. 非同步檔案通道

非同步檔案通道和檔案通道的大部分 API 相同,不同的是非同步檔案通道支援非同步讀取和寫入資料。這裡僅介紹這兩類非同步 API,其它 API 以及記憶體對映相關的內容可以參考Java NIO 檔案通道 FileChannel 用法

public class AsyncFileChannel {

  public static void main(String[] args) throws IOException, InterruptedException, ExecutionException {
    Path path = Paths.get("data.txt"); // 準備一些資料

    /* 非同步寫入資料 */
    byte[] data = "This is an example of AsynchronousFileChannel".getBytes(StandardCharsets.UTF_8);
    ByteBuffer buff = ByteBuffer.allocate(4); // 分配一個大小為 4 的位元組緩衝區
    AsynchronousFileChannel writeChan = AsynchronousFileChannel.open(path, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
    long position = 0; // 記錄寫入資料在檔案中的起始位置
    for (int i = 0; i<data.length; i+=buff.capacity()) {
      buff.put(data, i, Math.min(buff.capacity(), data.length - i)); // 將資料放入緩衝區
      buff.flip(); // 將緩衝區變為讀模式
      int len;     // 記錄成功寫入的位元組長度
      while (buff.hasRemaining()) {
        Future<Integer> future = writeChan.write(buff, position); // 非同步寫入資料,並立即返回
        len = future.get(); // 阻塞等待非同步操作完成,效率低
        position += len;    // 更新 position 位置
      }
      buff.clear(); // 清空緩衝區,將緩衝區變為寫模式
    }
    writeChan.force(false);
    writeChan.close();

    /* 非同步讀取資料 */
    Semaphore semaphore = new Semaphore(0);
    AsynchronousFileChannel readChan = AsynchronousFileChannel.open(path, StandardOpenOption.READ); // 開啟一個非同步檔案通道
    readChan.read(buff, 0, null, new CompletionHandler<>() { // 從位置 0 開始讀取資料,資料讀取到緩衝區 buff 中
      long readSize = 0; // 已經讀取的位元組數
      @Override
      public void completed(Integer result, Object attachment) {
        // 列印讀取到的資料
        System.out.println(Thread.currentThread() + new String(buff.array(), 0, result));
        try {
          if ( (readSize = readSize + result) < readChan.size()) { // 已讀取位元組數少於檔案總位元組數,繼續讀取
            buff.clear(); // 將 buff 的 position 移動到起始位置,使其變為可寫狀態
            readChan.read(buff, readSize, null, this); // 遞迴,繼續讀取,注意改變讀取位置,Handler 直接使用 this。
          } else {
            semaphore.release();
          }
        } catch (IOException e) {
          e.printStackTrace();
        }
      }

      @Override
      public void failed(Throwable exc, Object attachment) {
        exc.printStackTrace();
      }
    });

    // 主執行緒等待檔案資料讀取結束。
    semaphore.acquire();
  }

}

5. 非同步套接字通道與非同步套接字監聽通道

面向通道的 Socket 通訊需要有客戶端和服務端的參與,涉及到監聽套接字連線的通道和收發資料的套接字通道。服務端通過套接字監聽通道接收客戶端的連線,併產生一個套接字通道與客戶端通訊,而客戶端需要主動建立套接字通道去連線服務端。在 Java 實現的同步阻塞 I/O 和多路複用 I/O 中,套接字通道和套接字監聽通道分別是 SocketChannel 和 ServerSocketChannel,而 AIO 中的通道則分別是 AsynchronousSocketChannel 和 AsynchronousServerSocketChannel。

5.1 TCP 服務端 —— 非同步套接字監聽通道

非同步套接字監聽通道 AsynchronousServerSocketChannel 的非同步操作是非同步監聽連線,呼叫 accept 方法之後會立即返回,非同步操作的結果是一個非同步套接字通道 AsynchronousSocketChannel;連線建立成功之後,服務端即可與客戶端進行通訊。

非同步套接字監聽通道一次性只能夠接受一個連線,一個連線接受成功之後再接收下一個,連續接收連線會丟擲 AcceptPendingException。例如,下面兩段程式碼將會丟擲異常。

serverSocketChannel.accept(null, handler); // 附件為空,傳入一個 CompletionHandler 實現類的物件。
serverSocketChannel.accept(null, handler);

或者

future = serverSocketChannel.accept();
future = serverSocketChannel.accept();

正確的使用方式是

serverSocketChannel.accept(null, new CompletionHandler<>() { // 非同步建立連線
      @Override
      public void completed(AsynchronousSocketChannel socketChannel, Object attachment) { // 成功建立連線
        serverSocketChannel.accept(null, this);  // 接收下一個
        ...... 其它邏輯
      }
}

或者

future = serverSocketChannel.accept();
future.get(); // 阻塞,等待前一個連線完成
future = serverSocketChannel.accept();

下面是一個 TCP 服務端非同步套接字監聽通道的一段完整示例程式碼。服務端接收來自於客戶端的連線,連線成功之後繼續等待下一個;然後以非同步的方式接收客戶端發來的資料並列印出來。這裡可能會有一個疑問,“接收下一個連線” 處對 accept 方法的呼叫算遞迴嗎?長時間執行會不會造成 Stack Overflow?嚴格來講,這不算是遞迴,也不會造成棧溢位錯誤,因為外層的 accept 方法會立即返回,釋放虛擬機器棧的空間,棧的深度不會超過虛擬機器允許的最大深度。

public class AsyncServerSocketChannel {

  public static void main(String[] args) throws IOException, InterruptedException {
    AsynchronousServerSocketChannel serverSocketChannel = AsynchronousServerSocketChannel.open();
    serverSocketChannel.bind(new InetSocketAddress("127.0.0.1", 9090));
    serverSocketChannel.accept(null, new CompletionHandler<>() { // 非同步建立連線
      @Override
      public void completed(AsynchronousSocketChannel socketChannel, Object attachment) { // 成功建立連線
        serverSocketChannel.accept(null, this);           // 接收下一個連線

        ByteBuffer buf = ByteBuffer.allocate(8); // 分配一個 8 位元組的緩衝區
        socketChannel.read(buf, null, new CompletionHandler<>() { // 非同步讀取資料
          @Override
          public void completed(Integer len, Object attachment) {           // 成功讀取到資料
            if (-1 != len) { // 客戶端未關閉通道
              System.out.print(new String(buf.array(), 0, len));
              buf.clear();    // 清除緩衝區,為下一次寫入資料做準備
              socketChannel.read(buf, null, this);        // 繼續讀取下一批資料
            } else {
              try {
                socketChannel.close(); // 關閉通道
              } catch (IOException e) {
                e.printStackTrace();
              }
              System.out.println();
            }
          }

          @Override
          public void failed(Throwable exc, Object attachment) {
            exc.printStackTrace();
          }
        });
      }

      @Override
      public void failed(Throwable exc, Object attachment) {
        exc.printStackTrace();
      }
    });

    new Semaphore(0).acquire(); // 阻塞主執行緒
  }

}

5.2 TCP 客戶端 —— 非同步套接字通道

在上面的非同步套接字監聽通道的例子中其實已經包含了非同步套接字通道讀取資料的方式,下面給出的例子是往非同步套接字通道寫入資料(即向 TCP 服務端傳送資料)的例子。

回撥操作方式。

public class AsyncSocketChannel {

  public static void main(String[] args) throws IOException, InterruptedException {
    AsynchronousSocketChannel socketChannel = AsynchronousSocketChannel.open(); // 開啟一個非同步的 Socket 通道
    InetSocketAddress serverAddress = new InetSocketAddress("127.0.0.1", 9090); // 服務端地址
    Semaphore semaphore = new Semaphore(0); // 定義一個訊號量,用來確保主執行緒等待 Socket 將資料完成再退出
    socketChannel.connect(serverAddress, null, new CompletionHandler<>() {
      @Override
      public void completed(Void result, Object attachment) { // 成功建立連線之後觸發
        String msg = "Hello, this is a TCP Client.";
        ByteBuffer data = ByteBuffer.wrap(msg.getBytes(StandardCharsets.UTF_8));  // 將要傳送的資料放到緩衝區中
        socketChannel.write(data, null, new CompletionHandler<>() {     // 往通道中寫(發)資料給服務端

          @Override
          public void completed(Integer result, Object attachment) {  // 成功寫完一批資料後觸發
            if (data.hasRemaining()) { // 緩衝區還有資料
              socketChannel.write(data, null, this); // 繼續(寫)發給服務端
            } else { // 緩衝區資料已經全部傳送給了客戶端
              try {
                socketChannel.shutdownOutput();   // 關閉輸出,服務端呼叫 read 時收到返回值 -1
                socketChannel.close();            // 關閉通道
                semaphore.release();              // 釋放訊號量許可,讓主執行緒可以繼續往下走
              } catch (IOException e) {
                e.printStackTrace();
              }
            }
          }

          @Override
          public void failed(Throwable exc, Object attachment) {
            exc.printStackTrace();
          }
        });

      }

      @Override
      public void failed(Throwable exc, Object attachment) {
        exc.printStackTrace();
      }
    });

    semaphore.acquire(); // 等到非同步執行緒工作完成
  }
}

Future 操作方式。

/**
 * 非同步 Socket,返回 Future。
 */
class AsyncSocketChannel2 {
  public static void main(String[] args) throws IOException, ExecutionException, InterruptedException {
    AsynchronousSocketChannel socketChannel = AsynchronousSocketChannel.open();
    InetSocketAddress serverAddress = new InetSocketAddress("127.0.0.1", 9090);
    Future<Void> connect = socketChannel.connect(serverAddress); // 連線到服務端
    connect.get();  // 阻塞,等待連線建立成功

    byte[] data = "AsyncSocketChannel with Future.".getBytes(StandardCharsets.UTF_8);
    ByteBuffer buf = ByteBuffer.allocate(4);
    for (int i = 0; i < data.length; i += buf.capacity()) {
      buf.put(data, i, Math.min(buf.capacity(), data.length - i));
      buf.flip();                   // 使緩衝區變為可讀狀態
      while (buf.hasRemaining()) {  // 緩衝區中還有資料(緩衝區的資料不一定能夠一次性就被髮送出去)
        Future<Integer> future = socketChannel.write(buf); // 非阻塞傳送資料
        future.get(); // 阻塞等待資料傳送成功
      }
      buf.clear();    // 清空緩衝區,變為可寫狀態
    }
    socketChannel.shutdownOutput();
    socketChannel.close();
  }
}

6. 小結

Java AIO 的操作模式和一般的非同步程式碼編寫模式類似,都支援返回 Future 的操作和回撥操作;但這並不是 AIO 的核心,基於其它 I/O 模型(如:同步阻塞I/O模型)也可以提供類似的非同步操作 API。AIO 的厲害之處在於它呼叫了作業系統核心提供的非同步 I/O 介面,提高了 I/O 的效率。

無論是訪問檔案還是網路,AIO 的操作步驟和一般基於通道的 I/O 操作步驟類似,包括開啟通道,關閉通道,接收連線,讀取(接收)資料,寫入(傳送)資料。這些步驟當中,讀/寫資料以及接收連線是非同步的,其它步驟都是同步。這一點與一些 API 全盤非同步的框架(如 Vert.X)不同。

7. 參考

[1] I/O Multiplexing
[2] Java NIO 緩衝區 Buffer
[3] Java NIO 檔案通道 FileChannel 用法
[4] Java NIO 通道 Channel

相關文章