【Java】I/O - I/O 模型与服务端编程

Posted by 西维蜀黍 on 2019-03-04, Last Modified on 2022-12-10

背景

对于 Unix 中的五种 I/O 模型:

  • 阻塞 I/O(blocking I/O)
  • 非阻塞 I/O(non-blocking I/O)
  • I/O 多路复用(I/O multiplxing)
  • 信号驱动 I/O(signal driven I/O)
  • 异步 I/O(asynchronous I/O)

除信号驱动 I/O 外,Java 对其它四种 I/O 模型都有所支持。

在服务端编程不断进化的过程中,出现了以下模式:

  • 阻塞 I/O 模式(Blocking I/O)
  • 阻塞 I/O (Blocking I/O) + 多线程(multithreading)
  • 阻塞 I/O (Blocking I/O) + 线程池(Thread Pool)
  • 非阻塞 I/O 模式(Non-blocking I/O)
  • 异步 I/O 模式(Asynchronous I/O)

其中 Java 最早提供的 blocking I/O 即是阻塞 I/O,而 NIO 即是非阻塞 I/O (non-blocking I/O)。而通过 NIO 实现的 Reactor 模式即是 I/O 多路复用模型的实现,通过 AIO 实现的 Proactor 模式即是异步I/O模型的实现。

阻塞 I/O 模式(Blocking I/O)

使用阻塞 I/O 的服务器,一般使用 while(true) 循环,在一个线程中,逐个接受连接请求并读取数据,然后处理下一个请求。

代码实现如下所示:

public class IOServer {
  private static final Logger LOGGER = LoggerFactory.getLogger(IOServer.class);
  public static void main(String[] args) {
    ServerSocket serverSocket = null;
    try {
      serverSocket = new ServerSocket();
      serverSocket.bind(new InetSocketAddress(2345));
    } catch (IOException ex) {
      LOGGER.error("Listen failed", ex);
      return;
    }
    try{
      while(true) {
        Socket socket = serverSocket.accept();
        InputStream inputstream = socket.getInputStream();
        LOGGER.info("Received message {}", IOUtils.toString(inputstream));
        IOUtils.closeQuietly(inputstream);
      }
    } catch(IOException ex) {
      IOUtils.closeQuietly(serverSocket);
      LOGGER.error("Read message failed", ex);
    }
  }
}

阻塞 I/O (Blocking I/O) + 多线程(multithreading)

上例使用单线程逐个处理所有请求,缺点在于同一时间只能处理一个请求,而且在线程等待 I/O 而被阻塞的过程,不能充分利用 CPU 资源,最终能够对连接请求处理的吞吐率较低。

为此,我们使用多线程对阻塞 I/O 模型的改进,提出阻塞 I/O 模式 + 多线程模型,即为每个请求创建一个线程。一个连接建立成功后,创建一个单独的线程处理其 I/O 操作

这是一种在传统的网络服务设计中的经典模式,而另外一种是线程池(我们在下文会介绍)。

该模型最大的问题就是缺乏弹性伸缩能力,当客户端并发访问量增加后,服务端的线程个数和客户端并发访问数呈 1:1 的正比关系,由于线程是 Java 虚拟机非常宝贵的系统资源,当线程数膨胀之后,系统的性能将急剧下降,随着并发访问量的继续增大,系统会发生线程堆栈溢出、创建新线程 失败等问题,并最终导致进程宕机或者僵死,不能对外提供服务。

代码实现如下所示:

public class IOServerMultiThread {
  private static final Logger LOGGER = LoggerFactory.getLogger(IOServerMultiThread.class);
  public static void main(String[] args) {
  ServerSocket serverSocket = null;
    try {
      serverSocket = new ServerSocket();
      serverSocket.bind(new InetSocketAddress(2345));
    } catch (IOException ex) {
      LOGGER.error("Listen failed", ex);
      return;
    }
    try{
      while(true) {
        Socket socket = serverSocket.accept();
        new Thread( () -> {
          try{
            InputStream inputstream = socket.getInputStream();
            LOGGER.info("Received message {}", IOUtils.toString(inputstream));
            IOUtils.closeQuietly(inputstream);
          } catch (IOException ex) {
            LOGGER.error("Read message failed", ex);
          }
        }).start();
      }
    } catch(IOException ex) {
      IOUtils.closeQuietly(serverSocket);
      LOGGER.error("Accept connection failed", ex);
    }
  }
}

阻塞 I/O (Blocking I/O) + 线程池(Thread Pool)

在上面的”阻塞 I/O 模式 + 多线程“ 模型中,虽然实现起来简单。但是,当连接数量达到上限时,再有用户请求连接,直接会导致资源瓶颈,严重的可能会直接导致服务器崩溃。

同时,线程不断地重复创建和销毁会带来的大量的性能开销。

为此,我们可以采用**线程池(thread pool)**来进行优化,即,采用线程池和任务队列可以实现一种叫做伪异步的 I/O 通信框架。

当有新的客户端接入的时候,将客户端的 Socket 封装成一个 Task(该任务实现 java.lang.Runnable 接口)投递到后端的线程池中进行处理,当这个 Task 处理完成后会被自动放回线程池中。JDK 的线程池维护一个消息队列和 N 个活跃线程对消息队列中的任务进行处理。由于线程池 可以设置消息队列的大小和最大线程数,因此,它的资源占用是可控的,无论多少个客户端并发访问,都不会导致资源的耗尽和宕机。

总结:重用线程避免了频率地创建和销毁线程带来的开销。

但是,阻塞 I/O 模式 + 线程池从根本上解决同步 I/O 导致的通信线程阻塞问题。下面我们就简单分析下如果通信 对方返回应答时间过长,会引起的级联故障。

代码实现如下所示:

public class IOServerThreadPool {
  private static final Logger LOGGER = LoggerFactory.getLogger(IOServerThreadPool.class);
  public static void main(String[] args) {
    ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
    ServerSocket serverSocket = null;
    try {
      serverSocket = new ServerSocket();
      serverSocket.bind(new InetSocketAddress(2345));
    } catch (IOException ex) {
      LOGGER.error("Listen failed", ex);
      return;
    }
    try{
      while(true) {
        Socket socket = serverSocket.accept();
        executorService.submit(() -> {
          try{
            InputStream inputstream = socket.getInputStream();
            LOGGER.info("Received message {}", IOUtils.toString(new InputStreamReader(inputstream)));
          } catch (IOException ex) {
            LOGGER.error("Read message failed", ex);
          }
        });
      }
    } catch(IOException ex) {
      try {
        serverSocket.close();
      } catch (IOException e) {
      }
      LOGGER.error("Accept connection failed", ex);
    }
  }
}

潜在问题

在大量短连接的场景中性能会有提升,因为不用每次都创建和销毁线程,而是重用连接池中的线程。但在大量长连接的场景中,因为线程被连接长期占用,因而当有新连接请求到来时,可能没有可用的空闲线程来进行处理,最终导致较慢的服务响应时间(response time)。

虽然这种方法可以适用于小到中度规模的客户端的并发数,如果连接数超过 10000或更多,那么性能将很不理想。

非阻塞 I/O 模式(Non-blocking I/O)

背景

“阻塞I/O+线程池”网络模型虽然比”阻塞I/O+多线程”网络模型在性能方面有提升。但这两种模型都存在一个共同的问题:读和写操作都是同步阻塞的,面对大并发(持续大量连接同时请求)的场景,需要消耗大量的线程来维持连接。

因而,CPU 在大量的线程之间频繁切换,性能损耗很大。一旦单机的连接超过1万,甚至达到几万的时候,服务器的性能会急剧下降。

NIO 的 Selector 很好地解决了这个问题,用主线程(一个线程或者是 CPU 个数的线程)保持住所有的连接,管理和读取客户端连接的数据,将读取的数据交给后面的线程池处理,线程池处理完业务逻辑后,将结果交给主线程发送响应给客户端,少量的线程就可以处理大量连接的请求。

从IO到NIO

面向流 vs. 面向缓冲

Java IO 是面向流的,每次从流(InputStream/OutputStream)中读一个或多个字节,直到读取完所有字节,它们没有被缓存在任何地方。另外,它不能前后移动流中的数据,如需前后移动处理,需要先将其缓存至一个缓冲区。

Java NIO 面向缓冲,数据会被读取到一个缓冲区,需要时可以在缓冲区中前后移动处理,这增加了处理过程的灵活性。但与此同时,在处理缓冲区前,需要检查该缓冲区中是否包含有所需要处理的数据,并需要确保更多数据读入缓冲区时,不会覆盖缓冲区内尚未处理的数据。

阻塞 vs. 非阻塞

Java IO的各种流是阻塞的。当某个线程调用 read()write() 方法时,该线程被阻塞,直到有数据被读取到或者数据完全写入。阻塞期间该线程无法处理任何其它事情。

Java NIO为非阻塞模式。读写请求并不会阻塞当前线程,在数据可读/写前当前线程可以继续做其它事情,所以一个单独的线程可以管理多个输入和输出通道。

选择器(Selector)

Java NIO 的**选择器(Selector)**允许一个单独的线程同时监视多个通道,可以注册多个通道到同一个选择器上,然后使用一个单独的线程来“选择”已经就绪的通道。这种“选择”机制为一个单独线程管理多个通道提供了可能。

零拷贝(Zero-copy)

Java NIO中提供的FileChannel拥有transferTo和transferFrom两个方法,可直接把FileChannel中的数据拷贝到另外一个Channel,或者直接把另外一个Channel中的数据拷贝到FileChannel。

该接口常被用于高效的网络/文件的数据传输和大文件拷贝。在操作系统支持的情况下,通过该方法传输数据并不需要将源数据从内核态拷贝到用户态,再从用户态拷贝到目标通道的内核态,同时也避免了两次用户态和内核态间的上下文切换,也即使用了“零拷贝”,所以其性能一般高于 Java I/O中提供的方法。

使用FileChannel的零拷贝将本地文件内容传输到网络的示例代码如下所示。

public class NIOClient {

  public static void main(String[] args) throws IOException, InterruptedException {
    SocketChannel socketChannel = SocketChannel.open();
    InetSocketAddress address = new InetSocketAddress(1234);
    socketChannel.connect(address);

    RandomAccessFile file = new RandomAccessFile(
        NIOClient.class.getClassLoader().getResource("test.txt").getFile(), "rw");
    FileChannel channel = file.getChannel();
    channel.transferTo(0, channel.size(), socketChannel);
    channel.close();
    file.close();
    socketChannel.close();
  }
}

异步 I/O 模式(Asynchronous I/O)

Java SE 7 版本之后,引入了异步 I/O (NIO.2) 的支持,为构建高性能的网络应用提供了一个利器。

http://tutorials.jenkov.com/java-nio/nio-vs-io.html

https://examples.javacodegeeks.com/core-java/nio/java-nio-asynchronous-channels-tutorial/

Netty - JDK原生NIO程序的问题

JDK原生也有一套网络应用程序API,但是存在一系列问题,主要如下:

NIO的类库和API繁杂,使用麻烦,你需要熟练掌握Selector、ServerSocketChannel、SocketChannel、ByteBuffer等

需要具备其它的额外技能做铺垫,例如熟悉Java多线程编程,因为NIO编程涉及到Reactor模式,你必须对多线程和网路编程非常熟悉,才能编写出高质量的NIO程序

可靠性能力补齐,开发工作量和难度都非常大。例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常码流的处理等等,NIO编程的特点是功能开发相对容易,但是可靠性能力补齐工作量和难度都非常大

Reference