单线程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。
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();}}}}
TimeServerHandler
public class TimeServerHandler implements Runnable {private Socket socket;public TimeServerHandler(Socket socket) {this.socket socket;}Overridepublic 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();}}}}}
# clientsend query time requestcurr time is Sat Aug 11 13:55:47 CST 2018# serverserver starts in port: 8080time 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模式缺点
相比传统的简单模型Reactor增加了一定的复杂性因而有一定的门槛并且不易于调试。 Reactor模式需要底层的Synchronous Event Demultiplexer支持比如Java中的Selector支持操作系统的select系统调用支持。 Reactor模式在IO读写数据时还是在同一个线程中实现的即使使用多个Reactor机制的情况下那些共享一个Reactor的Channel如果出现一个长时间的数据读写会影响这个Reactor中其他Channel的相应时间比如在大文件传输时IO操作就会影响其他Client的相应时间因而对这种操作使用传统的Thread-PerConnection或许是一个更好的选择或则此时使用Proactor模式。
Java NIO原理Reactor模式是javaNIO非堵塞技术的实现原理与Socket类和ServerSocket类相对应NIO也提供了SocketChannel和ServerSocketChannel两种不同的套接字通道实现。这两种新增的通道都支持阻塞和非阻塞两种模式。阻塞模式使用非常简单但是性能和可靠性都不好非阻塞模式则正好相反。一般来说低负载、低并发的应用程序可以选择同步阻塞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实现。