1

准备知识

同步、异步、阻塞、非阻塞

同步和异步说的是服务端消息的通知机制,阻塞和非阻塞说的是客户端线程的状态。
已客户端一次网络请求为例做简单说明:

  • 同步
    同步是指一次请求没有得到结果之前就不返回。

  • 异步
    请求不会立刻得到最终结果,服务器处理完成再异步通知客户端。

  • 阻塞
    请求结果返回之前,当前线程被挂起。在此期间不能做任何其他的事情。

  • 非阻塞
    请求立即返回,后续由客户端时不时的询问服务器结果或者服务器异步回调。

同步IO、异步IO、阻塞IO、非阻塞IO

通常来说,IO操作包括:对硬盘的读写、对socket的读写以及外设的读写。
已一个IO读取过程为例做简要说明(如图):

  1. DMA把数据读取到内核空间的缓冲区(读就绪)

  2. 内核将数据拷贝到用户空间。

io%E5%8E%9F%E7%90%86.png

内核空间是用户代码无法控制的,所以用户空间在读取之前,首先会判断是否已经读就绪。

  • 同步IO
    当用户发出IO请求操作之后,内核会去查看要读取的数据是否就绪,如果数据没有就绪,就一直等待。需要通过用户线程或者内核不断地去轮询数据是否就绪,当数据就绪时,再将数据从内核拷贝到用户空间。

  • 异步IO
    只有IO请求操作的发出是由用户线程来进行的,IO操作的两个阶段都是由内核自动完成,然后发送通知告知用户线程IO操作已经完成。也就是说在异步IO中,不会对用户线程产生任何阻塞。

  • 阻塞IO
    当用户线程发起一个IO请求操作(以读请求操作为例),内核查看要读取的数据还没就绪,当前线程被挂起,阻塞等待结果返回。

  • 非阻塞IO
    如果数据没有就绪,则会返回一个标志信息告知用户线程当前要读的数据没有就绪。当前线程在拿到此次请求结果的过程中,可以做其它事情。

JAVA中的BIO、NIO、AIO

  • BIO
    同步阻塞,传统io方式。
    适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中。

  • NIO
    同步非阻塞,jdk4开始支持。
    适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器。

  • AIO
    异步非阻塞,jdk7开始支持。
    适用于连接数目多且连接比较长(重操作)的架构。

形象的理解NIO和AIO:
如果把内核比作快递,NIO就是你要自己时不时到官网查下快递是否已经到了你所在城市,然后自己去取快递;AIO就是快递员送货上门了。

Linux下五种IO模型

  • 阻塞I/O(blocking I/O)

  • 非阻塞I/O (nonblocking I/O)

  • I/O复用(select 和poll) (I/O multiplexing)

  • 信号驱动I/O (signal driven I/O (SIGIO))

  • 异步I/O (asynchronous I/O (the POSIX aio_functions))

IO复用模型(IO多路复用)

简言之,就是通过单个线程(进程)来管理多IO流。如图:

io%E5%A4%9A%E8%B7%AF%E5%A4%8D%E7%94%A8.png

IO多路复用避免阻塞在IO上,原本为多进程或多线程来接收多个连接的消息变为单进程或单线程保存多个socket的状态后轮询处理。只有当某个socket读写就绪后,才真正调用实际的IO读写操作。这样可以避免线程切换带来的开销。

实现IO多路复用需要函数来支持,就是你说的linux下的select、poll、epoll以及win下 iocp和BSD的kqueue。这几个函数也会使进程阻塞,但是和阻塞I/O所不同的是,它可以同时阻塞多个I/O操作。而且可以同时对多个读操作,多个写操作的I/O准备状态进行检测。

IO多路复用为何比非阻塞IO模型的效率高是因为在非阻塞IO中,不断地询问socket状态是通过用户线程去进行的,而在IO多路复用中,轮询每个socket状态是内核在进行的,这个效率要比用户线程要高的多。

io%E5%A4%8D%E7%94%A8%E6%A8%A1%E5%9E%8B.png

五种IO模型以及select、poll、epoll的详细介绍推荐大家看这篇文章
socket阻塞与非阻塞,同步与异步、I/O模型

理解Reactor和Proactor模式

在Reactor模式中,会先对每个client注册感兴趣的事件,然后有一个线程专门去轮询每个client是否有事件发生,当有事件发生时(读写就绪),便顺序处理每个事件,当所有事件处理完之后,便再转去继续轮询,如图所示:

reactor%E6%A8%A1%E5%BC%8F.png

从这里可以看出,多路复用IO就是采用Reactor模式。注意,上面的图中展示的是顺序处理每个事件,当然为了提高事件处理速度,可以通过多线程或者线程池的方式来处理事件。
在Proactor模式中,当检测到有事件发生时,会新起一个异步操作,然后交由内核线程去处理,当内核线程完成IO操作之后,发送一个通知告知操作已完成,可以得知,异步IO模型采用的就是Proactor模式。

这部分摘选自:Java NIO:浅析I/O模型

Java NIO介绍

Channels and Buffers(通道和缓冲区)
标准的IO基于字节流和字符流进行操作的,而NIO是基于通道(Channel)和缓冲区(Buffer)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。

Non-blocking IO(非阻塞IO)
Java NIO可以让你非阻塞的使用IO,例如:当线程从通道读取数据到缓冲区时,线程还是可以进行其他事情。当数据被写入到缓冲区时,线程可以继续处理它。从缓冲区写入通道也类似。

Selectors(选择器)
选择器用于监听多个通道的事件(比如:连接打开,数据到达)。因此,单个的线程可以监听多个数据通道。

NIO与IO区别

  IO                 NIO
面向流         面向缓冲
阻塞IO          非阻塞IO
  无                选择器

Channel

Java NIO的通道类似流,但又有些不同:

  • 既可以从通道中读取数据,又可以写数据到通道。但流的读写通常是单向的。

  • 通道可以异步地读写。

  • 通道中的数据总是要先读到一个Buffer,或者总是要从一个Buffer中写入。

Channel的实现

  • FileChannel (从文件中读写数据)

  • DatagramChannel (通过UDP读写网络中的数据)

  • SocketChannel (通过TCP读写网络中的数据)

  • ServerSocketChannel (可以监听新进来的TCP连接,像Web服务器那样)

Buffer

Java NIO中的Buffer用于和NIO通道进行交互。如你所知,数据是从通道读入缓冲区,从缓冲区写入到通道中的。

Buffer的基本用法

使用Buffer读写数据一般遵循以下四个步骤:

  1. 分配指定大小的buffer空间

  2. 写入数据到Buffer

  3. 调用flip()方法

  4. 从Buffer中读取数据

  5. 调用clear()方法或者compact()方法

当向buffer写入数据时,buffer会记录下写了多少数据。一旦要读取数据,需要通过flip()方法将Buffer从写模式切换到读模式。在读模式下,可以读取之前写入到buffer的所有数据。

一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用clear()或compact()方法。clear()方法会清空整个缓冲区。compact()方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。
注:Buffer中的数据并未清除,只是这些标记告诉我们可以从哪里开始往Buffer里写数据。

Buffer的类型

  • ByteBuffer

  • MappedByteBuffer

  • CharBuffer

  • DoubleBuffer

  • FloatBuffer

  • IntBuffer

  • LongBuffer

  • ShortBuffer

Selector

Selector(选择器)是Java NIO中能够检测一到多个通道,并能够知晓通道是否为诸如读写事件做好准备的组件。这样,一个单独的线程可以管理多个channel,从而管理多个网络连接。

nio.png

为什么使用Selector?

仅用单个线程来处理多个Channels的好处是,只需要更少的线程来处理通道。事实上,可以只用一个线程处理所有的通道。对于操作系统来说,线程之间上下文切换的开销很大,而且每个线程都要占用系统的一些资源(如内存)。因此,使用的线程越少越好。

但是,需要记住,现代的操作系统和CPU在多任务方面表现的越来越好,所以多线程的开销随着时间的推移,变得越来越小了。实际上,如果一个CPU有多个内核,不使用多任务可能是在浪费CPU能力。不管怎么说,关于那种设计的讨论应该放在另一篇不同的文章中。在这里,只要知道使用Selector能够处理多个通道就足够了。

NIO如何实现非阻塞?

服务器上所有Channel需要向Selector注册,而Selector则负责监视这些Socket的IO状态(观察者),当其中任意一个或者多个Channel具有可用的IO操作时,该Selector的select()方法将会返回大于0的整数,该整数值就表示该Selector上有多少个Channel具有可用的IO操作,并提供了selectedKeys()方法来返回这些Channel对应的SelectionKey集合(一个SelectionKey对应一个就绪的通道)。正是通过Selector,使得服务器端只需要不断地调用Selector实例的select()方法即可知道当前所有Channel是否有需要处理的IO操作。
注:java NIO就是多路复用IO,jdk7之后底层是epoll模型。

一个简单的demo

/**
 * NioServer
 * Date: 6/27/2016
 * Time: 8:06 PM
 *
 * @author xiaodong.fan
 */
public class NioServer {

  public static void main(String[] args) throws Exception {
    // 1、初始化一个ServerSocketChannel
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1", 9999);
    serverSocketChannel.configureBlocking(false);// 设置为非阻塞模式,后续的accept()方法会立刻返回
    serverSocketChannel.socket().bind(inetSocketAddress, 1024);// 监听本地9999端口的请求,第二个参数限制可以建立的最大连接数
    Selector selector = Selector.open();
    /**
     * 将通道注册到一个选择器上(非阻塞模式与选择器搭配会工作的更好)
     * 注意register()方法的第二个参数。这是一个“interest集合”,意思是在通过Selector监听Channel时对什么事件感兴趣。
     * 可以监听四种不同类型的事件:OP_CONNECT,OP_ACCEPT,OP_READ,OP_WRITE
     * 如果你对不止一种事件感兴趣,那么可以用“位或”操作符将常量连接起来:SelectionKey.OP_READ | SelectionKey.OP_WRITE
     */
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

    // 2、监听连接请求并处理
    while (true) {
      int connects = selector.select(2000);// 每次最多阻塞2秒

      if (connects == 0) {
        System.out.println("没有请求...");
        continue;
      } else {
        System.out.println("请求来了...");
      }

      // 获取监听到有连接请求的channel对应的selectionKey
      Set<SelectionKey> selectedKeys = selector.selectedKeys();
      // 遍历selectionKey来访问就绪的通道
      Iterator<SelectionKey> selectedKeyIterator = selectedKeys.iterator();
      while (selectedKeyIterator.hasNext()) {
        SelectionKey selectionKey = selectedKeyIterator.next();
        if (selectionKey.isValid()) {

          if (selectionKey.isAcceptable()) {// 接收就绪
            ServerSocketChannel channel = (ServerSocketChannel) selectionKey.channel();
            // 返回一个包含新进来的连接SocketChannel,因为前面设置的非阻塞模式,这里会立即返回。
            SocketChannel socketChannel = channel.accept();
            if (socketChannel == null) {
              return;
            }
            socketChannel.configureBlocking(false);
            socketChannel.register(selector, SelectionKey.OP_READ);
            System.out.println("连接建立完成");
            doWrite(socketChannel, "connection is established");// 连接建立完成,给客户端发消息

          } else if (selectionKey.isReadable()) {// 读就绪

            SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
            ByteBuffer readBuffer = ByteBuffer.allocate(10);
            while ((socketChannel.read(readBuffer)) > 0) {// // 读取客户端发送来的消息
              readBuffer.flip();
              byte[] bytes = new byte[readBuffer.remaining()];
              readBuffer.get(bytes);
              String body = new String(bytes, "utf-8");
              doWrite(socketChannel, body);// 将客户端发送的内容原封不动的发回去
              readBuffer.clear();
            }
            socketChannel.close();//读取数据完毕后关闭连接,如果不关闭一直处于连接状态。

          }
        }

        selectedKeyIterator.remove(); // 注意每次必须手动remove(),下次该通道变成就绪时,Selector会再次将其放入已选择键集中
      }
    }
  }

  private static void doWrite(SocketChannel socketChannel, String response) throws IOException {
    if (StringUtils.isNotBlank(response)) {
      byte[] bytes = response.getBytes();
      ByteBuffer writeBuffer = ByteBuffer.allocate(1024);
      writeBuffer.put(bytes);
      writeBuffer.flip();
      // 发送消息到客户端
      socketChannel.write(writeBuffer);
      writeBuffer.clear();
    }

  }

}

参考文章

Java NIO:浅析I/O模型
socket阻塞与非阻塞,同步与异步、I/O模型
Java BIO、NIO、AIO 学习
Java NIO:NIO概述
Java NIO 系列教程
Java网络编程——使用NIO实现非阻塞Socket通信


sunnyxd
529 声望30 粉丝