基于Reactor的网络通信模型的相关研究
名词解释
还是先从最简单的两个内容说起:同步和异步、阻塞和非阻塞。
这两个概念在网络上可以说是千人千面,每人都能说出来个自己的理解,然后在评论区开始各种撕,在这里就简单说一下我自己的理解,如果我说的不对,那我有罪,我先说了。
同步与异步
同步和异步描述的是通信双方通信方式的差异。如果调用方发起调用后被调用方都直接返回,而在真正完成后,再通过回调函数等模式告知调用者结果,这种方式就是异步通信。反之,如果调用方发起请求后被调用方在处理完毕后再带着结果返回,这种方式称之为同步通信。
阻塞和非阻塞
阻塞和非阻塞描述的是调用者等待调用结果时的状态。如果等待时调用者处于挂起或者空转状态,而无法处理其他内容,则称这个过程是阻塞的。反之,等待时调用者仍然可以处理其他业务逻辑则为非阻塞。
小总结
可以看到的是,同步/异步和阻塞/非阻塞代表了不同内容,但是二者关注的是同一个通信过程的两个特性。一个可以明白的道理是:同步的过程中是可能存在等待的过程的,异步的过程中是不存在等待的过程的,所以根据调用方等待过程是否等待,可以将通信的过程分为三种:
-
同步阻塞:使用同步的方式通信,且在等待过程中阻塞调用方
-
同步非阻塞:使用同步的方式通信,但是等待过程中不阻塞调用方
-
异步非阻塞:使用异步通信的方式通信,因为不存在等待的过程,所以只能是非阻塞的
举个不恰当的小🌰:排队去买饭,有以下几个情景
-
排队去买饭,付了钱,后面排队去,没带手机,旁边的人也不认识,默默等了两分钟,等到了窗口前,阿姨颤抖的手给你盛了一份饭。这个过程中,结果在完全弄好了后(也就是饭),被你拿到了,这是同步的。这个等待的过程你什么别的事也没做成,这是阻塞的。所以这个过程是同步阻塞的。
-
排队去买饭,付了钱,我不排队,我找个座玩游戏,玩一会去柜台看看我的饭出来没。在这个过程中,结果也是在完全弄好了以后,被你拿到的,这是同步的。等待的过程中,你在做别的事,这是非阻塞的。所以这个过程是同步非阻塞的。
-
排队去买饭,付了钱,阿姨给你一个号码牌,说饭好了叫号,然后你就去玩游戏了,等饭好了,阿姨叫号,你听到了,把饭端回去吃。这个过程中,调用过程直接返回了(就是那个号码牌),这就是异步的,因为本身就没有等待的过程哦,当然你要说我可以在窗口前面等,那我也没办法哦,我认为这里就只能是非阻塞的。所以这个过程是异步非阻塞的。
网络通信模型
问题背景和初代解决方案
先提一下背景吧,我和Socket开始结缘是大三的时候,帮室友做一个物联网相关的应用项目,当时我用Java搭建了一个网站用来展示采集到的下位机的信息,并且通过Java程序将上位机的指令发送到下位机,实现相应的控制,下面是当时的网络结构图
在这里就第一次系统的用到了Socket通信,在这里Java程序充当了SockerServer,ESP8266(一个网络模块)充当SocketClient,那么我是怎么写的呢,我们来看下代码。
主启动类的代码
/**
* @author mingke
* @function 启动ServerSocket,处理客户端的请求
* @date 2021/11/8
* @desc IO阻塞+多线程,利用代码实现了BIO的网络模型,每个客户端都会分配一个线程来处理请求
*/
public class NormalSocketServer {
public static long cidNum = 10;
static int serverPort = 2021;
public static void main(String[] args) {
ServerSocket server = null;
try {
server = new ServerSocket(serverPort);
}catch (Exception e) {
e.printStackTrace();
}
System.out.println("Server Socket has been start with port: "+serverPort);
Socket socket = null;
while(true) {
try {
//循环监听,处理客户端请求
socket = server.accept();
System.out.println("IP为:"+socket.getInetAddress().getHostAddress()+" 的Socket Client连接到服务器");
}catch (Exception e) {
e.printStackTrace();
}
//创建新的线程来处理客户端的请求
NormalSocketHandler socketHandler = new NormalSocketHandler(socket);
//启动线程
socketHandler.start();
}
}
}
处理函数的代码
/**
* @author mingke
* @function 读写请求的处理器
* @date 2021/11/8
*/
public class NormalSocketHandler extends Thread {
//内部维护的Socket对象
private NormalSocketClient socketClient;
public NormalSocketHandler(Socket socket) {
try {
socketClient = new NormalSocketClient(socket);
}catch (Exception e) {
e.printStackTrace();
}
//设置全局的客户端id,方便查验
socketClient.setCid(NormalSocketServer.cidNum++);
//将全局id发送给Socket客户端
try {
socket.getOutputStream().write(Integer.valueOf(String.valueOf(socketClient.getCid())));
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public void run() {
super.run();
//循环监听客户端的信息
while((socketClient.getSocket()!=null)&&(socketClient.getSocket().isConnected())) {
try {
int bufferLength = socketClient.getInputStream().available();
//如果存在数据,就进行解析
if(bufferLength > 0) {
System.out.println(Thread.currentThread().getName()+"线程缓冲区大小:"+bufferLength);
byte bytes[] = new byte[bufferLength];
if(socketClient.getInputStream().read(bytes) != -1) {
//数据读取成功
System.out.println("数据内容");
for(int i = 0; i < bufferLength; i++) {
if(i == bufferLength-1) {
System.out.print(bytes[i] + "\n");
}else {
System.out.print(bytes[i] + " ");
}
}
}else {
//数据读取失败
System.out.println("数据读取失败");
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
//如果客户端断开,则释放目前所用的资源
try {
socketClient.getInputStream().close();
socketClient.getOutputStream().close();
}catch (Exception e) {
e.printStackTrace();
}
System.out.println(socketClient.getCid()+"号客户端断开连接,资源释放完成");
}
}
实体类的代码
/**
* @author mingke
* @function TODO
* @date 2021/11/8
*/
public class NormalSocketClient {
private long cid; //全局统一的ID
private Socket socket; //Socket
private InputStream inputStream; //输入缓冲区
private OutputStream outputStream; //输出缓冲区
// getter setter....
//自定义构造器
public NormalSocketClient(Socket socket) throws IOException {
this.socket = socket;
inputStream = socket.getInputStream();
outputStream = socket.getOutputStream();
}
}
最后实现的效果就是,SocketServer会循环监听指定的服务端口,对于每一个连接到服务器的客户端,会分配一个线程处理相关的读写操作,也就是下面的逻辑
不妨将这个过程和第一章节里的那两个概念组合起来分析,这个过程中,每一个子线程持有一个输入缓冲区,子线程获取输入缓冲区的数据时,是数据准备好了以后才拿到的(当然要有数据发过来才算数),这个过程的通信方式是同步的,而这个过程中,我们看这段代码
//循环监听客户端的信息
while((socketClient.getSocket()!=null)&&(socketClient.getSocket().isConnected())) {
try {
int bufferLength = socketClient.getInputStream().available();
//如果存在数据,就进行解析
if(bufferLength > 0) {
//doHandler...
}
} catch (IOException e) {
e.printStackTrace();
}
}
在线程的run方法里面写死循环的作用还是很容易看出的,这里的程序在输入缓冲区数据没准备好时,一直处于空转状态,也就是阻塞的情况,没办法做别的事情。所以这个情况时同步阻塞的。
在讨论这些问题的时候,我看其他的程序员同志们总会提到BIO、NIO、AIO,好像不提这些就不算是懂Socket,那我就入乡随俗,目前我写的这个程序就是BIO的模式,也就是服务子线程处于同步阻塞的工作模式下。
BIO网络通信模型
服务端创建一个SocketServer,每一个客户端连接到服务器时,创建Socket并且分配一个线程进行业务逻辑处理,在连接完成后,客户端与Socket进行通信,可以实现读写的操作。当然我们可以称这种工作模式为Acceptor工作模式,在客户端较多的时候,可能需要很多线程进行处理,不过引入线程池的时候,能够缓解这个问题。
NIO网络通信模型
操作系统的开发者肯定会先于我们想到BIO存在的问题,而Java的很多设计概念又都是沿袭操作系统底层的设计,所以Java提供了一套解决方案,内置到了java.nio.*包下,它的基本思路就是,客户端连接到服务器以后,不在单独分配线程进行处理,而是借助select进行处理。那什么是select呢,可以这样简单的理解,OS在某块内存中存储了一个“key数组”,每一个接入服务器的客户端都会在数组中占有一个位置,示意图如下
服务端需要做的就是安排一个线程,或者是主线程去循环检测这个数组中是否有被标记的key,如果有,则取出被标记的key,因为key和客户端是一一对应的,所以,根据key得到和客户端进行交互的“通道”,完成数据通信。
再结合第一章节里面的概念进行分析,整个调用过程中结果是在数据完全准备好的之后再去读取的,所以过程是同步的,而在等待过程中,程序轮训状态,在某些客户端没有返回数据也就是等待的时候,服务端可以处理其他那些有响应的客户端的响应,所以是非阻塞,所以NIO的过程是同步非阻塞。
那么理论上完了,就要看一下代码是如何实现的了,首先介绍NIO里面两个新成员,通道和缓冲区
SocketChannel
Channel就类似于原先BIO中的Socket的概念,因为客户端连接到服务器以后,总归还是要进行数据传输的,NIO中使用Channel来进行数据传递,一个针对Socket连接的面向流的selectable通道。一个Socket通道在执行了类方法open以后就会被创建,一个新创建的Socket通道被打开了,但是并没有连接上。如果尝试对一个没有连接的Socket连接进行IO操作将会导致一个异常–NotYetConnectedException。但是实际上,我们往往使用Socket服务端通道的accept得到一个可用的、已经连接成功的Socket通道。一个Socket通道会在执行了connect方法后完成连接;一旦连接完成,通道将会被保留,直到连接关闭,可以使用isConnected方法判断当前的Socket通道是否连接。
Socket通道支持非阻塞连接:可以创建一个套接字通道,并且可以通过 connect 方法启动建立到远程套接字的链接的过程,以便稍后通过 finishConnect 方法完成。 可以通过isConnectionPending函数来确定连接操作是否正在进行。
Socket通道支持异步终止,类似于 Channel 类中指定的异步关闭操作。 如果Socket连接的输入端因为一些原因被关闭,那么在套接字通道上的读取操作理论上将被阻塞,而实际上读取操作将完成而不读取任何字节并返回 -1。
如果Socket连接的输出端因为一些原因被关闭,那么在Socket通道上进行写入操作理论上将被阻塞,而实际上写入操作将会触发一个异常–AsynchronousCloseException。
可以使用setOptions方法来设置通道的相关参数,Socket通道有以下几个典型的参数可以进行配置
-
SO_SNDBUF: 发送缓冲区的大小
-
SO_RCVBUF: 接收缓冲区的大小
-
SO_KEEPALIVE: 是否保持连接存活
-
SO_REUSEADDR: 进行复用的服务地址
-
SO_LINGER: 如果存在数据,则在关闭时逗留(仅当配置为阻塞模式时)
-
TCP_NODELAY: 禁用Nagle算法