Java 异步 I/O

时间:2024-03-14 21:41: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