声明:本文是《Netty 权威指南》的样章,感谢博文视点授权并发编程网站发布样章,禁止以任何形式转载此文。
网络编程的基本模型是Client/Server模型,也就是两个进程之间进行相互通信,其中服务端提供位置信息(绑定的IP地址和监听端口),客户端通过连接操作向服务端监听的地址发起连接请求,通过三次握手建立连接,如果连接建立成功,双方就可以通过网络套接字(Socket)进行通信。
在基于传统同步阻塞模型开发中,ServerSocket负责绑定IP地址,启动监听端口,Socket负责发起连接操作,连接成功之后,双方通过输入和输出流进行同步阻塞式通信。
下面,我们就以经典的时间服务器(TimeServer)为例,通过代码分析来回顾和熟悉下BIO编程。
2.1.1.BIO通信模型图
首先,我们通过下面的通信模型图来熟悉下BIO的服务端通信模型:采用BIO通信模型的服务端,通常由一个独立的Acceptor线程负责监听客户端的连接,它接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理,处理完成之后,通过输出流返回应答给客户端,线程销毁。这就是典型的一请求一应答通信模型。
同步阻塞IO服务端通信模型(一客户端一线程)
该模型最大的问题就是缺乏弹性伸缩能力,当客户端并发访问量增加后,服务端的线程个数和客户端并发访问数呈1:1的正比关系,由于线程是JAVA虚拟机非常宝贵的系统资源,当线程数膨胀之后,系统的性能将急剧下降,随着并发访问量的继续增大,系统会发生线程堆栈溢出、创建新线程失败等问题,并最终导致进程宕机或者僵死,不能对外提供服务。
下面的两个小节,我们会分别对服务端和客户端进行源码分析,寻找同步阻塞IO的弊端。
2.1.1.同步阻塞式IO创建的TimeServer源码分析
同步阻塞IO的TimeServer:
01 | public class TimeServer { |
07 | public static void main(String[] args) throws IOException { |
09 | if (args != null && args.length > 0 ) { |
12 | port = Integer.valueOf(args[ 0 ]); |
13 | } catch (NumberFormatException e) { |
18 | ServerSocket server = null ; |
20 | server = new ServerSocket(port); |
21 | System.out.println( "The time server is start in port : " + port); |
24 | socket = server.accept(); |
25 | new Thread( new TimeServerHandler(socket)).start(); |
29 | System.out.println( "The time server close" ); |
imeServer根据传入的参数设置监听端口,如果没有入参,使用默认值8080,20行通过构造函数创建ServerSocket,如果端口合法且没有被占用,服务端监听成功。23-26行通过一个无限循环来监听客户端的连接,如果没有客户端接入,则主线程阻塞在ServerSocket的accept操作上。启动TimeServer,通过JvisualVM打印线程堆栈,我们可以发现主程序确实阻塞在accept操作上,如下图所示:
主程序线程堆栈
当有新的客户端接入的时候,执行代码25行,以Socket为参数构造TimeServerHandler对象,TimeServerHandler是一个Runnable,使用它为构造函数的参数创建一个新的客户端线程处理这条Socket链路。下面我们继续分析TimeServerHandler的代码。
同步阻塞IO的TimeServerHandler:
01 | public class TimeServerHandler implements Runnable { |
03 | private Socket socket; |
05 | public TimeServerHandler(Socket socket) { |
16 | BufferedReader in = null ; |
17 | PrintWriter out = null ; |
19 | in = new BufferedReader( new InputStreamReader( |
20 | this .socket.getInputStream())); |
21 | out = new PrintWriter( this .socket.getOutputStream(), true ); |
22 | String currentTime = null ; |
28 | System.out.println( "The time server receive order : " + body); |
29 | currentTime = "QUERY TIME ORDER" .equalsIgnoreCase(body) ? new java.util.Date( |
30 | System.currentTimeMillis()).toString() : "BAD ORDER" ; |
31 | out.println(currentTime); |
34 | } catch (Exception e) { |
38 | } catch (IOException e1) { |
46 | if ( this .socket != null ) { |
49 | } catch (IOException e1) { |
25行通过BufferedReader读取一行,如果已经读到了输入流的尾部,则返回值为null,退出循环。如果读到了非空值,则对内容进行判断,如果请求消息为查询时间的指令”QUERY TIME ORDER”则获取当前最新的系统时间,通过PrintWriter的println函数发送给客户端,最后退出循环。代码35-52行释放输入流、输出流、和Socket套接字句柄资源,最后线程自动销毁并被虚拟机回收。
在下一个小结,我们将介绍同步阻塞IO的客户端代码,然后分别运行服务端和客户端,查看下程序的运行结果。
2.1.1.同步阻塞式IO创建的TimeClient源码分析
客户端通过Socket创建,发送查询时间服务器的”QUERY TIME ORDER”指令,然后读取服务端的响应并将结果打印出来,随后关闭连接,释放资源,程序退出执行。
同步阻塞IO的TimeClient:
01 | public class TimeClient { |
06 | public static void main(String[] args) { |
08 | if (args != null && args.length > 0 ) { |
10 | port = Integer.valueOf(args[ 0 ]); |
11 | } catch (NumberFormatException e) { |
16 | BufferedReader in = null ; |
17 | PrintWriter out = null ; |
19 | socket = new Socket( "127.0.0.1" , port); |
20 | in = new BufferedReader( new InputStreamReader( |
21 | socket.getInputStream())); |
22 | out = new PrintWriter(socket.getOutputStream(), true ); |
23 | out.println( "QUERY TIME ORDER" ); |
24 | System.out.println( "Send order 2 server succeed." ); |
25 | String resp = in.readLine(); |
26 | System.out.println( "Now is : " + resp); |
27 | } catch (Exception e) { |
38 | } catch (IOException e) { |
46 | } catch (IOException e) { |
第23行客户端通过PrintWriter向服务端发送”QUERY TIME ORDER”指令,然后通过BufferedReader的readLine读取响应并打印。
分别执行服务端和客户端,执行结果如下:
服务端执行结果如下:
同步阻塞IO时间服务器服务端运行结果
客户端执行结果如下:
同步阻塞IO时间服务器客户端运行结果
到此为止,同步阻塞式IO开发的时间服务器程序已经讲解完毕,我们发现,BIO主要的问题在于每当有一个新的客户端请求接入时,服务端必须创建一个新的线程处理新接入的客户端链路,一个线程只能处理一个客户端连接。在高性能服务器应用领域,往往需要面向成千上万个客户端的并发连接,这种模型显然无法满足高性能、高并发接入的场景。
为了改进一线程一连接模型,后来又演进出了一种通过线程池或者消息队列实现1个或者多个线程处理N个客户端的模型,由于它的底层通信机制依然使用同步阻塞IO,所以被称为 “伪异步”,下面章节我们就对伪异步代码进行分析,看看伪异步是否能够满足我们对高性能、高并发接入的诉求。