消息中间件解析 | 如何正确理解软件应用系统中关于系统通信的那些事?

苍穹之边,浩瀚之挚,眰恦之美;悟心悟性,善始善终,惟善惟道! —— 朝槿《朝槿兮年说》

随着业务需求的发展和用户数量的激增,对于互联 应用系统或者服务应用程序则提出了新的挑战,也对从事系统研发的开发者有了更高的要求。作为一名IT从业研发人员,我们都知道的事,良好的用户体验是我们和应用系统间快速反馈,一直以来都是我们考量一个系统是否稳定和是否高效的设计目标,但是保证这个目标的关键之一,主要在于如何保证系统间的通信稳定和高效。从而映射出,如何正确理解软件应用系统中关于系统通信的那些事我们必须了解和理解的一项关键工作,接下来,我们就一起来总结和探讨一下。

基本概述

根据人与人的交流的构成要素,抽象成计算机系统服务中对应的概念(行之有效的概念往往是简单且趋同的),系统间通信主要考虑以下三个方面:通信格式,通信协议,通信模型。具体详情如下:

  1. 通信格式(Communication Format): 主要是指实现通信的消息格式(Message Format),是表达消息内容等基本表现形式。常用的消息格式有xml,json,TLV等。
  2. 通信协议(Communication Protocol): 主要是指实现通信的 络协议(Network Protocol)。常见的TCP/IP协议,UDP协议等。
  3. 通信模型(Communication Model): 主要是指实现通信的 络模型(Network Model)。常见的模型主要有阻塞式通信模型,非阻塞式通信模型,同步通信模型,异步通信模型。

接下来,我们来详细解析这些组成要素:

  1. 对于消息格式来说,是帮助我们识别消息和表达消息内容的基本方式:
    • XML:和语言无关,常用于对系统环境进行描述,如常见的maven仓库配置,或者spring配置等。
    • JSON:轻量级消息格式,和语言无关。携带同样的信息,占用容量比XML小。
    • Protocol Buffer:Google定义的消息格式,只提供了java,c++和python语言的实现。
    • TLV:比JSON更轻量级的数据格式,连JSON中的”{}”都没有了。它是通过字节的位运算来实现序列化和反序列化。
  2. 对于 络协议来说,是帮助我们实现消息传输和传递的表达方式:
    • 数据在 络七层模型中传递的时候,在 络层是”数据包”,在数据链路层被封装成”帧”(数字信 ),在物理层则是”比特”(电信 )。
    • 不同的协议都能实现通信功能,最适合本系统的通信协议才是最好的。
  3. 对于 络模型来说,主要是帮助我们理解和选择适合当前场景的应用框架:
    • 在计算机 路层面来说,常见 络模型主要有OSI 参考模型和TCP/IP 模型两种。
    • 除此之外,还有Linux 络I/O 模型和Java JDK中的I/O 模型

络协议

从计算机 络层面来说,常见 络模型主要有OSI 参考模型和TCP/IP 模型两种,主要表达如下:

OSI 参考模型:

  1. 应用层:O S I 参考模型的第 7 层( 最高层)。应用程序和 络之间的接口, 直接向用户提供服务。应用层协议有电子邮件、远程登录等协议。
  2. 表示层:O S I 参考模型的第 6 层。负责数据格式的互相转换, 如编码、数据格式转换和加密解密等。保证一个系统应用层发出的信息可被另一系统的应用层读出。
  3. 会话层:O S I 参考模型的第 5 层。主要是管理和协调不同主机上各种进程之间的通信(对话),即负责建立、管理和终止应用程序之间的会话。
  4. 传输层:O S I 参考模型的第 4 层。为上层协议提供通信主机间的可靠和透明的数据传输服务, 包括处理差错控制和流量控制等问题。只在通信主机上处理, 不需要在路由器上处理。
  5. 络层:O S I 参考模型的第 3 层。在 络上将数据传输到目的地址, 主要负责寻址和路由选择。
  6. 数据链路层:O S I 参考模型的第 2 层。负责物理层面上两个互连主机间的通信传输, 将由 0、 1 组成的比特流划分成数据帧传输给对端,即数据帧的生成与接收。通信传输实际上是通过物理的传输介质实现的。 数据链路层的作用就是在这些通过传输介质互连的设备之间进行数据处理。 络层与数据链路层都是基于目标地址将数据发送给接收端的,但是 络层负责将整个数据发送给最终目标地址, 而数据链路层则只负责送一个分段内的数据。
  7. 物理层:O S I 参考模型的第 1 层( 最底层)。负责逻辑信 ( 比特流) 与物理信 (电信 、光信 )之间的互相转换,通过传输介质为数据链路层提供物理连接。
TCP/IP 模型:

Linux的内核将所的外部设备看作一个文件来操作,对于一个文件的读写操作会调用内核提供的系统命令,返回一个文件描述符(fd,File Descriptor);同时,在面对一个Socket的读写时也会有相应的套接字描述符(socketfd,Socket File Descriptor),描述符是一个数字,它指向内核中的一个结构体,比如文件路径,数据区等。Linux 络I/O 模型是按照UNIX 络编程来定义的,主要有:

阻塞I/O模型(Blocking I/O ):

进程把一个套接字设置成非阻塞是在通知内核:当所有请求的I/O操作非得把本进程投入睡眠才能完成时,不要把本进程投入睡眠,而是返回一个错误。前三次调用recvfrom时没有数据可返回,因此内核转而立即返回一个EWOULDBLOCK错误。第四次调用recvfrom时已有一个数据 准备好,它被复制到应用进程缓冲区,于是recvfrom成功返回。接着处理数据。当一个应用进程像这样对一个非阻塞描述符循环调用recvfrom时,我们成为轮询,应用进程持续轮询内核,以查看某个操作是否就绪。这么做往往耗费大量CPU时间,不过这种模型偶尔也会遇到。

I/O复用模型(IO Multiplexing):

可以用信 ,让内核在描述符就绪时发送SIGIO信 通知我们。称为信 驱动式I/O。我们首先开启套接字的信 驱动式I/O功能,并通过sigaction系统调用安装一个信 处理函数。该系统调用将立即返回,我们的进程继续工作,也就是说它没有被阻塞。当数据 准备好读取时,内核就为该进程产生一个SIGIO信 。我们随后既可以在信 处理函数中调用recvfrom读取数据 ,并通知主循环数据已准备好待处理。也可以立即通知循环,让它读取数据 。无论如何处理SIGIO信 ,这种模型的优势在于等待数据 到达期间进程不被阻塞。主循环可以继续执行,只要等待来自信 处理函数的通知:既可以是数据已准备好被处理,也可以是数据 已准备好被读取。

异步I/O模型(Asynchronous IO ):

在Java语言中,应用程序发起 I/O 调用后,会经历两个阶段:

  • 内核等待 I/O 设备准备好数据;
  • 内核将数据从内核空间拷贝到用户空间。

其中,阻塞和非阻塞:

  • 阻塞调用会一直等待远程数据就绪再返回,即上面的阶段1会阻塞调用者,直到读取结束;
  • 而非阻塞无论在什么情况下都会立即返回,虽然非阻塞大部分时间不会被block,但是它仍要求进程不断地去主动询问kernel是否准备好数据,也需要进程主动地再次调用recvfrom来将数据拷贝到用户内存。

而我们常说的同步和异步主要如下:

  • 同步方法会一直阻塞进程,直到I/O操作结束,注意这里相当于上面的阶段1,阶段2都会阻塞调用者。其中BIO,NIO,IO多路复用,信 驱动IO,这四种IO都可以归类为同步IO;
  • 而异步方法不会阻塞调用者进程,即使是从内核空间的缓冲区将数据拷贝到进程中这一操作也不会阻塞进程,拷贝完毕后内核会通知进程数据拷贝结束。
BIO模型

Java 中的 NIO 于 JDK 1.4 中引入,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它支持面向缓冲的,基于通道的 I/O 操作方法。 对于高负载、高并发的( 络)情况下,应使用 NIO 。

当服务器进程发出read操作时,如果kernel中数据还没准备好,那么并不会阻塞服务器进程,而是立即返回error,用户进程判断结果是error,就知道数据还没准备好,此时用户进程可以去干其他的事情。一段时间后用户进程再次发read,一直轮询直到kernel中数据准备好,此时用户发起read操作,产生system call,kernel 马上将数据拷贝到用户内存,然后返回,进程就能使用到用户空间中的数据了。

BIO一个线程只能处理一个IO流事件,想处理下一个必须等到当前IO流事件处理完毕。而NIO其实也只能串行化的处理IO事件,只不过它可以在内核等待数据准备数据时做其他的工作,不像BIO要一直阻塞住。NIO它会一直轮询操作系统,不断询问内核是否准备完毕。但是,NIO这样又引入了新的问题,如果当某个时间段里没有任何客户端IO事件产生时,服务器进程还在不断轮询,占用着CPU资源。所以要解决该问题,避免不必要的轮询,而且当无IO事件时,最好阻塞住(线程阻塞住就会释放CPU资源了)。所以NIO引入了多路复用机制,可以构建多路复用的、同步非阻塞的IO程序。

AIO模型

Java 中的 NIO ,提供了 Selector(选择器)这个封装了操作系统IO多路复用能力的工具,通过Selector.select(),我们可以阻塞等待多个Channel(通道),知道任意一个Channel变得可读、可写,如此就能实现单线程管理多个Channels(客户端)。当所有Socket都空闲时,会把当前线程(选择器所处线程)阻塞掉,当有一个或多个Socket有I/O事件发生时,线程就从阻塞态醒来,并返回给服务端工作线程所有就绪的socket(文件描述符)。各个操作系统实现方案:

  • linux:select、poll、epoll
  • MacOS/FreeBSD:kqueue
  • Windows/Solaris:IOCP

IO多路复用题同非阻塞IO本质一样,只不过利用了新的select系统调用,由内核来负责本来是服务器进程该做的轮询操作。看似比非阻塞IO还多了一个系统调用的开销,不过因为可以支持多路复用IO,即一个进程监听多个socket,才算提高了效率。进程先是阻塞在select/poll上(进程是因为select/poll/epoll函数调用而阻塞,不是直接被IO阻塞的),再是阻塞在读写操作的第二阶段上(等待数据从内核空间拷贝到用户空间)。

IO多路复用的实现原理:利用select、poll、epoll可以同时监听多个socket的I/O事件的能力,而当有I/O事件产生时会被注册到Selector中。在所有socket空闲时,会把当前选择器进程阻塞掉,当有一个或多个流有I/O事件(或者说 一个或多个流有数据到达)时,选择器进程就从阻塞态中唤醒。通过select或poll轮询所负责的所有socket(epoll是只轮询那些真正产生了事件的socket),返回fd文件描述符集合给主线程串行执行事件。

??[特别注意]:

select和poll每次调用时都需要将fd_set(文件描述符集合)从用户空间拷贝到内核空间中,函数返回时又要拷贝回来(epoll使用mmap,避免了每次wait都要将数组进行拷贝)。

在实际开发过程中,基于消息进行系统间通信,我们一般会有四种方法实现:

基于TCP/IP+BIO实现:

在Java中可基于Socket、ServerSocket来实现TCP/IP+BIO的系统通信。

  • Socket主要用于实现建立连接即 络IO的操作
  • ServerSocket主要用于实现服务器端口的监听即Socket对象的获取

为了满足服务端可以同时接受多个请求,最简单的方法是生成多个Socket。但这样会产生两个问题:

  • 生成太对Socket会消耗过多资源
  • 频繁创建Socket会导致系统性能的不足

为了解决上面的问题,通常采用连接池的方式来维护Socket。一方面能限制Socket的个数;另一方面避免重复创建Socket带来的性能下降问题。这里有一个问题就是设置合适的相应超时时间。因为连接池中Socket个数是有限的,肯定会造成激烈的竞争和等待。

Server服务端:

Client客户端:

基于TCP/IP+NIO实现:

Java可以基于Clannel和Selector的相关类来实现TCP/IP+NIO方式的系统间通信。Channel有SocketClannel和ServerSocketChannel两种:

  • SocketClannel: 用于建立连接、监听事件及操作读写。
  • ServerSocketClannel: 用于监听端口即监听连接事件。
  • Selecter: 获取是否有要处理的事件。

Server服务端:

SocketChannel channel = SocketChannel.open();//设置为非阻塞模式channel.configureBlocking(false);//对于非阻塞模式,立即返回false,表示连接正在建立中channel.connect(SocketAdress);Selector selector = Selector.open();//向channel注册selector以及感兴趣的连接事件channel.regester(selector,SelectionKey.OP_CONNECT);//阻塞至有感兴趣的IO事件发生,或到达超时时间int nKeys = selector.select(超时时间【毫秒计】);//如果希望一直等待知道有感兴趣的事件发生//int nKeys = selector.select();//如果希望不阻塞直接返回当前是否有感兴趣的事件发生//int nKeys = selector.selectNow();//如果有感兴趣的事件SelectionKey sKey = null;if(nKeys>0){    Set keys = selector.selectedKeys();    for(SelectionKey key:keys){//对于发生连接的事件if(key.isConnectable()){    SocketChannel sc = (SocketChannel)key.channel();    sc.configureBlocking(false);    //注册感兴趣的IO读事件    sKey = sc.register(selector,SelectionKey.OP_READ);    //完成连接的建立    sc.finishConnect();}//有流可读取else if(key.isReadable()){    ByteBuffer buffer = ByteBuffer.allocate(1024);    SocketChannel sc = (SocketChannel) key.channel();    int readBytes = 0;    try{ int ret = 0; try{//读取目前可读取的值,此步为阻塞操作while((ret=sc.read(buffer))>0){ readBytes += ret;} } fanally{buffer.flip(); }     }     finally{  if(buffer!=null){ buffer.clear();  }     }}//可写入流else if(key.isWritable()){    //取消对OP_WRITE事件的注册    key.interestOps(key.interestOps() & (!SelectionKey.OP_WRITE));    SocketChannel sc = (SocketChannel) key.channel();    //此步为阻塞操作    int writtenedSize = sc.write(ByteBuffer);    //如未写入,则继续注册感兴趣的OP_WRITE事件    if(writtenedSize==0){ key.interestOps(key.interestOps()|Select

                                                        

声明:本站部分文章及图片源自用户投稿,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!

上一篇 2022年7月8日
下一篇 2022年7月8日

相关推荐