Java实战之从同步阻塞IO到NIO

同步阻塞IO

单线程BIO

最原始的网络编程思路就是服务器用一个while循环,不断监听端口是否有新的套接字连接,如果有,那么就调用一个处理函数处理。

while(true){ 
  socket = accept(); 
  handle(socket) 
} 

这种方法的最大问题是无法并发,效率太低。如果当前的请求没有处理完,那么后面的请求只能被阻塞,服务器的吞吐量太低。

多线程BIO

针对上面的问题,很自然想到了使用多线程处理IO,也就是很经典的connection per thread,每一个连接用一个线程处理。

while(true){ 
  socket = accept(); 
  new thread(socket); 
} 

tomcat服务器的早期版本确实是这样实现的。多线程的方式确实一定程度上极大地提高了服务器的吞吐量,因为之前的请求在read阻塞以后,不会影响到后续的请求,因为他们在不同的线程中。这也是为什么通常会讲“一个线程只能对应一个socket”的原因。
那么,线程中创建多个socket不行吗?语法上确实可以,但是实际上没有用,每一个socket都是阻塞的,这就遇到同单线程IO一样的问题。所以在一个线程里只能处理一个socket,就算accept了多个也没用,前一个socket被阻塞了,后面的是无法被执行到的。

Java BIO示例

下面基于同步阻塞式IO创建一个时间服务TimeServer。

TimeServer服务端

public class TimeServer {
  private static int port = 8080;

  public static void main(String[] args) throws IOException {
    ServerSocket serverSocket = null;
    try {
      serverSocket = new ServerSocket(port);
      System.out.println("server starts in port: " + port);

      Socket socket = null;
      while (true) {
        // 监听来自客户端的连接,主线程阻塞在accept操作上
        socket = serverSocket.accept();
        // 创建一个新的线程处理socket链路
        new Thread(new TimeServerHandler(socket)).start();
      }
    } finally {
      if (serverSocket != null) {
        serverSocket.close();
      }
    }
  }
}

TimeServer Handler代码

public class TimeServerHandler implements Runnable {
  private Socket socket;

  public TimeServerHandler(Socket socket) {
    this.socket = socket;
  }

  @Override
  public void run() {
    BufferedReader in = null;
    PrintWriter out = null;

    try {
      in = new BufferedReader(new InputStreamReader(this.socket.getInputStream()));
      out = new PrintWriter(this.socket.getOutputStream(), true);

      String currTime = null;
      String body = null;

      while (true) {
        body = in.readLine();
        if (body == null) {
          break;
        }

        System.out.println("time server receive: " + body);
        currTime = new Date(System.currentTimeMillis()).toString();
        out.println(currTime);
      }
    } catch (Exception e) {
      // ignore
    } finally {
      if (in != null) {
        try {
          in.close();
        } catch (IOException e1) {
          e1.printStackTrace();
        }
      }
      if (out != null) {
        out.close();
      }
      if (this.socket != null) {
        try {
          this.socket.close();
        } catch (IOException e2) {
          e2.printStackTrace();
        }
      }
      this.socket = null;
    }
  }
}

TimeClient客户端

public class TimeClient {
  public static void main(String[] args) {
    int port = 8080;

    Socket socket = null;
    BufferedReader in = null;
    PrintWriter out = null;

    try {
      socket = new Socket("127.0.0.1", port);
      in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
      out = new PrintWriter(socket.getOutputStream(), true);

      out.println("query time");
      System.out.println("send query time request");
      String rep = in.readLine();
      System.out.println("curr time is " + rep);
    } catch (Exception e) {
      // ignore
    } finally {
      if (in != null) {
        try {
          in.close();
        } catch (IOException e1) {
          e1.printStackTrace();
        }
      }
      if (out != null) {
        out.close();
      }
      if (socket != null) {
        try {
          socket.close();
        } catch (IOException e2) {
          e2.printStackTrace();
        }
      }
    }
  }
}

运行结果

# client
send query time request
curr time is Sat Aug 11 13:55:47 CST 2018

# server
server starts in port: 8080
time server receive: query time

从上面的Demo我们可以发现,多线程BIO主要问题在于每一个新的Client请求时,Server必须创建一个新的线程来处理。一个线程只能处理一个客户端连接。系统中创建线程是需要比较多的系统资源的。如果同时有成千上万个Client并发连接,连接数太高,系统无法承受;而且,线程的反复创建-销毁也需要代价。

线程池BIO

线程池本身可以缓解线程创建-销毁的代价。下面对Server端代码进行简单改造,用线程池来处理连接。

Server端代码

public class TimeServer {
  private static int port = 8080;

  public static void main(String[] args) throws IOException {
    ServerSocket serverSocket = null;
    try {
      serverSocket = new ServerSocket(port);
      System.out.println("server starts in port: " + port);

      Socket socket = null;
      // 创建一个线程池处理socket链路
      TimeServerHandlerPool pool = new TimeServerHandlerPool(10, 1000);
      while (true) {
        // 监听来自客户端的连接,主线程阻塞在accept操作上
        socket = serverSocket.accept();
        pool.execute(new TimeServerHandler(socket));
      }
    } finally {
      if (serverSocket != null) {
        serverSocket.close();
      }
    }
  }
}

TimeServerHandlerPool

public class TimeServerHandlerPool {
  private ExecutorService executorService;

  public TimeServerHandlerPool(int poolSize, int queueSize) {
    executorService = new ThreadPoolExecutor(
        8,
        poolSize, 120,
        TimeUnit.SECONDS,
        new ArrayBlockingQueue(queueSize)
    );
  }

  public void execute(Runnable task) {
    executorService.execute(task);
  }
}

当收到客户端连接时,Server把请求的Socket封装成一个Task,交给线程池去处理,从而避免了每个请求都创建一个新的线程。

不过底层通信机制依然还是BIO,根本的问题就是线程的粒度太大。每一个线程把一次交互的事情全部做了,包括读取和返回,甚至连接,表面上似乎连接不在线程里,但是如果线程和队列不够,有了新的连接,也无法得到处理。

Java NIO

多线程BIO模型无法满足高性能、高并发的接入场景。因为其底层通信机制依然采用同步阻塞模型,无法从根本上解决问题。那么Java NIO是如何从根本上解决这类问题的呢?
上面的方案,线程Task里可以看成要做三件事,连接,读取和写入。线程同步的粒度太大了,限制了吞吐量。应该把一次连接的操作分为更细的粒度或者过程,这些更细的粒度是更小的线程。整个线程池的数目会翻倍,但是线程更简单,任务更加单一。Reactor模式则体现了这一改进思路。

Reactor模式

在Reactor中,这些被拆分的小线程或者子过程对应的是handler,每一种handler会出处理一种event。有一个全局的管理者selector,我们需要把channel注册感兴趣的事件,那么这个selector就会不断在channel上检测是否有该类型的事件发生。如果没有,那么主线程就会被阻塞,否则就会调用相应的事件处理函数即handler来处理。
典型的事件有连接,读取和写入,当然我们就需要为这些事件分别提供处理器,每一个处理器可以采用线程的方式实现。一个连接来了,显示被读取线程或者handler处理了,然后再执行写入,那么之前的读取就可以被后面的请求复用,吞吐量就提高了。

Reactor模式简介

Java实战之从同步阻塞IO到NIO_第1张图片

  • Handle:即操作系统中的句柄,是对资源在操作系统层面上的一种抽象,它可以是打开的文件、一个连接(Socket)、Timer等。
    由于Reactor模式一般使用在网络编程中,因而这里一般指Socket Handle,即一个网络连接(Connection,在Java NIO中的Channel)。这个Channel注册到Synchronous Event Demultiplexer中,以监听Handle中发生的事件,对ServerSocketChannnel可以是CONNECT事件,对SocketChannel可以是READ、WRITE、CLOSE事件等。
  • Synchronous Event Demultiplexer:阻塞等待一系列的Handle中的事件到来,如果阻塞等待返回,即表示在返回的Handle中可以不阻塞的执行返回的事件类型。这个模块一般使用操作系统的select来实现。在Java NIO中用Selector来封装,当Selector.select()返回时,可以调用Selector的selectedKeys()方法获取Set,一个SelectionKey表达一个有事件发生的Channel以及该Channel上的事件类型。上图的“Synchronous Event Demultiplexer —notifies–> Handle”的流程如果是对的,那内部实现应该是select()方法在事件到来后会先设置Handle的状态,然后返回。
  • Initiation Dispatcher:用于管理Event Handler,即EventHandler的容器,用以注册、移除EventHandler等;另外,它还作为Reactor模式的入口调用Synchronous Event Demultiplexer的select方法以阻塞等待事件返回,当阻塞等待返回时,根据事件发生的Handle将其分发给对应的Event Handler处理,即回调EventHandler中的handle_event()方法。
  • Event Handler:定义事件处理方法:handle_event(),以供InitiationDispatcher回调使用。
  • Concrete Event Handler:事件EventHandler接口,实现特定事件处理逻辑。

Reactor模式优点

  1. 响应快,不必为单个同步时间所阻塞,虽然Reactor本身依然是同步的;
  2. 编程相对简单,可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销;
  3. 可扩展性,可以方便的通过增加Reactor实例个数来充分利用CPU资源;
  4. 可复用性,reactor框架本身与具体事件处理逻辑无关,具有很高的复用性。

Reactor模式缺点

  1. 相比传统的简单模型,Reactor增加了一定的复杂性,因而有一定的门槛,并且不易于调试。
  2. Reactor模式需要底层的Synchronous Event Demultiplexer支持,比如Java中的Selector支持,操作系统的select系统调用支持。
  3. Reactor模式在IO读写数据时还是在同一个线程中实现的,即使使用多个Reactor机制的情况下,那些共享一个Reactor的Channel如果出现一个长时间的数据读写,会影响这个Reactor中其他Channel的相应时间,比如在大文件传输时,IO操作就会影响其他Client的相应时间,因而对这种操作,使用传统的Thread-PerConnection或许是一个更好的选择,或则此时使用Proactor模式。

Java NIO原理

Reactor模式是javaNIO非堵塞技术的实现原理,与Socket类和ServerSocket类相对应,NIO也提供了SocketChannelServerSocketChannel两种不同的套接字通道实现。这两种新增的通道都支持阻塞和非阻塞两种模式。阻塞模式使用非常简单,但是性能和可靠性都不好,非阻塞模式则正好相反。一般来说,低负载、低并发的应用程序可以选择同步阻塞I/O以降低编程复杂度;对于高负载、高并发的网络应用,需要使用NIO的非阻塞模式进行开发。

ByteBuffer

在NIO库中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的;在写入数据时,写入到缓冲区中。任何时候访问NIO中的数据,都是通过缓冲区进行操作。

缓冲区实质上是一个数组。通常它是一个字节数组(ByteBuffer),也可以使用其他种类的数组。但是一个缓冲区不仅仅是一个数组,缓冲区提供了对数据的结构化访问以及维护读写位置(limit)等信息。最常用的是ByteBuffer

Channel

Channel是一个通道,它就像自来水管一样,网络数据通过Channel读取和写入。通道与流的不同之处在于通道是双向的,流只是在一个方向上移动(一个流必须是InputStream或者Output-Stream的子类),而通道可以用于读、写或者二者同时进行。因为Channel是全双工的,所以它可以比流更好地映射底层操作系统的API。

Selector

Selector是Java NIO的基础,它会不断地轮询注册在其上的Channel,如果某个Channel上面发生读或者写事件,这个Channel就处于就绪状态,会被Selector轮询出来,然后通过Selection-Key可以获取就绪Channel的集合,进行后续的I/O操作。一个多路复用器Selector可以同时轮询多个Channel,由于JDK使用了epoll()代替传统的select实现。

Java NIO示例

NIO TimeServer服务端

public class NIOTimeServer implements Runnable {
  private Selector selector;
  private ServerSocketChannel serverChannel;
  private volatile boolean stop;

  public NIOTimeServer(int port) {
    try {
      // 初始化资源
      selector = Selector.open();
      serverChannel = ServerSocketChannel.open();
      serverChannel.configureBlocking(false);
      serverChannel.socket().bind(new InetSocketAddress(port), 1024);
      serverChannel.register(selector, SelectionKey.OP_ACCEPT);
      System.out.println("nio time server starts in port: " + port);
    } catch (IOException e) {
      // ignore
    }
  }

  public void stop() {
    this.stop = true;
  }

  @Override
  public void run() {
    while (!stop) {
      try {
        selector.select(1000);
        Set selectionKeySet = selector.selectedKeys();
        Iterator it = selectionKeySet.iterator();
        SelectionKey key = null;

        while (it.hasNext()) {
          key = it.next();
          it.remove();
          try {
            handleInput(key);
          } catch (Exception e) {
            if (key != null) {
              key.cancel();
              if (key.channel() != null) {
                key.channel().close();
              }
            }
          }
        }
      } catch (Exception e) {
        // ignore
      }
    }

    if (selector != null) {
      try {
        selector.close();
      } catch (Exception e) {
        // ignore
      }
    }
  }

  private void handleInput(SelectionKey key) throws IOException {
    if (key.isValid()) {
      // 处理新接入的请求
      if (key.isAcceptable()) {
        ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
        SocketChannel socketChannel = ssc.accept();
        socketChannel.configureBlocking(false);
        socketChannel.register(selector, SelectionKey.OP_READ);
      }

      if (key.isReadable()) {
        SocketChannel socketChannel = (SocketChannel) key.channel();
        ByteBuffer readBuffer = ByteBuffer.allocate(1024);
        int readBytes = socketChannel.read(readBuffer);
        if (readBytes > 0) {
          readBuffer.flip();
          byte[] bytes = new byte[readBuffer.remaining()];
          readBuffer.get(bytes);
          String body = new String(bytes, "UTF-8");
          System.out.println("nio time server receive: " + body);

          String currTime = new Date(System.currentTimeMillis()).toString();
          doWrite(socketChannel, currTime);
        } else if (readBytes < 0) {
          // 关闭对端链路
          key.cancel();
          socketChannel.close();
        } else {
          // 读到0字节 忽略
        }
      }
    }
  }

  public void doWrite(SocketChannel socketChannel, String response) throws IOException {
    if (response != null && response.trim().length() > 0) {
      byte[] bytes = response.getBytes();
      ByteBuffer byteBuffer = ByteBuffer.allocate(bytes.length);
      byteBuffer.put(bytes);
      byteBuffer.flip();
      socketChannel.write(byteBuffer);
    }
  }

  public static void main(String[] args) throws IOException {
    int port = 8080;
    System.out.println("server starts in port: " + port);
    // 创建一个新的NIO time server 处理socket链路
    NIOTimeServer timeServer = new NIOTimeServer(port);
    new Thread(timeServer, "NIO-NIOTimeServer").start();
  }
}

NIO TimeClient客户端

public class NIOTimeClient implements Runnable {
  private String host;
  private int port;
  private Selector selector;
  private SocketChannel socketChannel;
  private volatile boolean stop;


  public NIOTimeClient(String host, int port) {
    this.host = (host == null) ? "127.0.0.1" : host;
    this.port = port;

    try {
      selector = Selector.open();
      socketChannel = SocketChannel.open();
      socketChannel.configureBlocking(false);
    } catch (IOException e) {
      // ignore
    }
  }

  @Override
  public void run() {
    // do connect
    try {
      doConnect();
    } catch (IOException e) {
      // ignore
    }

    while (!stop) {
      try {
        selector.select(1000);
        Set selectionKeys = selector.selectedKeys();
        Iterator iterator = selectionKeys.iterator();
        while (iterator.hasNext()) {
          SelectionKey key = iterator.next();
          iterator.remove();
          try {
            handleKey(key);
          } catch (Exception e) {
            // ignore
          }
        }
      } catch (Exception e) {
        // ignore
      }

    }

    if (selector != null) {
      try {
        selector.close();
      } catch (IOException e) {
        // ignore
      }
    }
  }

  private void handleKey(SelectionKey key) throws IOException {
    if (key.isValid()) {
      SocketChannel socketChannel = (SocketChannel) key.channel();
      if (key.isConnectable()) {
        if (socketChannel.finishConnect()) {
          System.out.println("client finish connect");
          socketChannel.register(selector, SelectionKey.OP_READ);
          doWrite(socketChannel, "query time");
        } else {
          System.out.println("client connect failed, exit");
          System.exit(1);
        }
      }
      if (key.isReadable()) {
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        int readBytes = socketChannel.read(byteBuffer);
        if (readBytes > 0) {
          byteBuffer.flip();
          byte[] bytes = new byte[byteBuffer.remaining()];
          byteBuffer.get(bytes);

          String currTime = new String(bytes, "UTF-8");
          System.out.println("curr time is: " + currTime);
        } else if (readBytes < 0) {
          key.cancel();
          socketChannel.close();
        } else {
          // ignore
        }
      }
    }
  }

  public void doWrite(SocketChannel socketChannel, String request) throws IOException {
    if (request != null && request.trim().length() > 0) {
      byte[] bytes = request.getBytes();
      ByteBuffer byteBuffer = ByteBuffer.allocate(bytes.length);
      byteBuffer.put(bytes);
      byteBuffer.flip();
      socketChannel.write(byteBuffer);
      if (!byteBuffer.hasRemaining()) {
        System.out.println("client send req succeed!");
      }
    }
  }

  private void doConnect() throws IOException {
    if (socketChannel.connect(new InetSocketAddress(host, port))) {
      socketChannel.register(selector, SelectionKey.OP_READ);
      doWrite(socketChannel, "query time");
    } else {
      socketChannel.register(selector, SelectionKey.OP_CONNECT);
    }
  }

  public static void main(String[] args) throws Exception {
    new Thread(new NIOTimeClient("127.0.0.01", 8080), "NIO-NIOTimeServer").start();
  }
}

NIO的优点

  1. 客户端发起的连接操作是异步的,可以通过在多路复用器注册OP_CONNECT等待后续结果,不需要像之前的客户端那样被同步阻塞。
  2. SocketChannel的读写操作都是异步的,如果没有可读写的数据它不会同步等待,直接返回,这样I/O通信线程就可以处理其他的链路,不需要同步等待这个链路可用。
  3. 线程模型的优化:由于JDK的Selector在Linux等主流操作系统上通过epoll实现,它没有连接句柄数的限制(只受限于操作系统的最大句柄数或者对单个进程的句柄限制)。一个Selector线程可以处理成千上万个连接。

参考资料

  • Netty权威指南
  • 高性能IO之Reactor模式

你可能感兴趣的:(Java基础)