从IO 到BIO/NIO/AIO 浅析

 

背景

最近在阅读rocketMQ的源码中,涉及到多服务之间通信,而rocketMQ的通信中用了Netty框架,Netty框架又是基于NIO实现的。虽然NIO的日常中提到的很多,但是一直处于朦胧的状态,本着知其然而知其所以然的态度,在这里对IO进行一波梳理。

IO

对于IO的概念,这里不多作介绍。这个小节从java的Socket和ServiceSocker为例,以demo的形式的对日常的通信IO的工作原理作一个简单的认识。
Socket定义:套接字(socket)是一个抽象层,应用程序可以通过它发送或接收数据,可对其进行像对文件一样的打开、读写和关闭等操作。套接字允许应用程序将I/O插入到网络中,并与网络中的其他应用程序进行通信。网络套接字是IP地址与端口的组合。
可以理解为两台机器或进程间进行网络通信的端点,这个端点包含IP地址和端口号。
Socket和ServerSocket区别就如其名字一样,简单地说ServerSocket作用在服务端,用以监听客户端的请求。Socket作用在客户端和服务端,用以发送接收消息。但是就像上面说的,它们都要包含一个IP地址和端口号。
我个人不太喜欢枯燥的概念,我们直接看一个demo。
首先,创建一个普通的java项目,然后创建两个类,Server和Client。分别模拟服务端和客户端。
代码和注释都比较详细。

  

然后打开两个命令终端,通过javac编译后,一个运行Server代表服务器,一个运行Client代表客户端。运行结果如下:
上面的代码能够完成一个简单的服务端-客户端的简单通信,但是存在以下几个问题:
  1. 一个服务端只能同时为一个客户端服务,几乎没有并发。
  2. 如果一个客户端持续占用这个服务,则服务端会一直不能为其他客户端服务。在占用服务的客户端没有与服务端交互的情况下,服务端资源会被浪费。
  3. 客户端读写在一个线程,即读写不能同时进行。
 

BIO

在前面一小节中运用Socket和ServerSocket简单的实现了网络通信。这节中,利用BIO编程模型对上面的代码进行改造升级,以实现同时多对多通信,类似于微信群聊。
所谓BIO,就是Block IO,阻塞式的IO。这个阻塞主要发生在:ServerSocket接收请求时(accept()方法)、InputStream、OutputStream(输入输出流的读和写)都是阻塞的。这个可以在下面代码的调试中发现,比如在客户端接收服务器消息的输入流处打上断点,除非服务器发来消息,不然断点是一直停在这个地方的。也就是说这个线程在这时间是被阻塞的
如图:当一个客户端请求进来时,接收器会为这个客户端分配一个工作线程,这个工作线程专职处理客户端的操作。在上一小节中,服务器接收到客户端请求后就跑去专门服务这个客户端了,所以当其他请求进来时,是处理不到的。
看到这个图,很容易就会想到线程池,BIO是一个相对简单的模型,实现它的关键之处也在于线程池。
便于理解清楚,在上代码之前,先大概说清楚每个类的作用。。更详细的说明,这里同样写在代码的注释当中。
服务端
  1. ChatServer:这个类的作用就像图中的Acceptor。它有两个比较关键的全局变量,一个就是存储在线用户信息的Map,一个就是线程池。这个类会监听端口,接收客户端的请求,然后为客户端分配工作线程。还会提供一些常用的工具方法给每个工作线程调用,比如:发送消息、添加在线用户等。
  2. ChatHandler:这个类就是工作线程的类。在这里它的工作很简单:把接收到的消息转发给其他客户端,当然还有一些小功能,比如添加\移除在线用户。
上代码
客户端
  1. 相较于服务器,客户端的改动较小,主要是把等待用户输入信息这个功能分到其他线程做,不然这个功能会一直阻塞主线程,导致无法接收其他客户端的消息。
  2. ChatClient:客户端启动类,也就是主线程,会通过Socket和服务器连接。也提供了两个工具方法:发送消息和接收消息。
  3. UserInputHandler:专门负责等待用户输入信息的线程,一旦有信息键入,就马上发送给服务器。
老规矩,直接上代码。
运行测试
  1. 首先打开一个Server终端,两个Client终端
  2. 在Client1 中发送 hi,my name is lilei,可以发现在Client2 和 Server 均收到了该消息。
  3. 在Client2 中发送 hi,my name is hanmeimeii,可以发现在Client1 和 Server 均收到了该消息。

NIO

在上一小节中,我们使用BIO编程模型简单的实现了一个多对多通信(群聊)的功能。但是其最大的问题在解释BIO时就已经说了:ServerSocket接收请求时(accept()方法)、InputStream、OutputStream(输入输出流的读和写)都是阻塞的。还有一个问题就是线程池,线程多了,服务器性能耗不起。线程少了,在群聊这种场景下,让用户等待连接肯定不可取。今天要说到的NIO编程模型就很好的解决了这几个问题。有两个主要的替换地方:
  1. 用Channel代替Stream。
  2. 2.使用Selector监控多条Channel,起到类似线程池的作用,但是它只需一条线程。
既然要用NIO编程模型,那就要说说它的三个主要核心:Selector、Channel、Buffer。它们的关系是:一个Selector管理多个Channel,一个Channel可以往Buffer中写入和读取数据。Buffer名叫缓冲区,底层其实是一个数组,会提供一些方法往数组写入读取数据。
Buffer
Buffer 其实底层就是一个byte数据,其主要作用就是提供一系列api对这个byte数据进行读写操作,日常开发中Buffer使用较多,这里就不做讲解。
Channel
Channel(通道)主要用于传输数据,然后从Buffer中写入或读取。它们两个结合起来虽然和流有些相似,但主要有以下几点区别:
  1. 流是单向的,可以发现Stream的输入流和输出流是独立的,它们只能输入或输出。而通道既可以读也可以写。
  2. 通道本身不能存放数据,只能借助Buffer。
  3. Channel支持异步。
Channel有如下三个常用的类:FileChannel、SocketChannel、ServerSocketChannel。从名字也可以看出区别,第一个是对文件数据的读写,后面两个则是针对Socket和ServerSocket,这里我们只是用后面两个。更详细的用法可以看:https://www.cnblogs.com/snailclimb/p/9086335.html,下面的代码中也会用到,会有详细的注释。
Selector
多个Channel可以注册到Selector,就可以直接通过一个Selector管理多个通道。Channel在不同的时间或者不同的事件下有不同的状态,Selector会通过轮询来达到监视的效果,如果查到Channel的状态正好是我们注册时声明的所要监视的状态,我们就可以查出这些通道,然后做相应的处理。这些状态如下:
  1. 客户端的SocketChannel和服务器端建立连接,SocketChannel状态就是Connect
  2. 服务器端的ServerSocketChannel接收了客户端的请求,ServerSocketChannel状态就是Accept
  3. 当SocketChannel有数据可读,那么它们的状态就是Read
  4. 当我们需要向Channel中写数据时,那么它们的状态就是Write
同样,为了方便理解,我们先说各个类的大概功能。
  1. 相比较BIO的代码,NIO的代码还少了一个类,那就是服务器端的工作线程类。没了线程池,自然也不需要一个单独的线程去服务客户端。客户端还是需要一个单独的线程去等待用户输入,因为用户随时都可能输入信息,这个没法预见,只能阻塞式的等待。
  2. ChatServer:服务器端的唯一的类,作用就是通过Selector监听Read和Accept事件,并针对这些事件的类型,进行不同的处理,如连接、转发。
  3. ChatClient:客户端,通过Selector监听Read和Connect事件。Read事件就是获取服务器转发的消息然后显示出来;Connect事件就是和服务器建立连接,建立成功后就可以发送消息。
  4. UserInputHandler:专门等待用户输入的线程,和BIO没区别。
这里的运行结果与BIO结果类似,这里便不错运行测试。感兴趣的小伙伴自行测试一下即可。

AIO

AIO,异步IO。异步的精髓就是注册+回掉。
上一小节说到的NIO编程模型比较主流,开编提到的Netty就是基于NIO编程模型的。这一小节说的是AIO编程模型,是异步非阻塞的。虽然同样实现的是多对多通信(群聊),但是实现逻辑上稍微要比NIO和BIO复杂一点。不过理好整体脉络,会好理解一些。首先还是讲讲概念:
BIO和NIO的区别是阻塞和非阻塞,而AIO代表的是异步IO。在此之前只提到了阻塞和非阻塞,没有提到异步还是同步。可以用我在知乎上看到的一句话表示:【在处理 IO 的时候,阻塞和非阻塞都是同步 IO,只有使用了特殊的 API 才是异步 IO】。这些“特殊的API”下面会讲到。在说AIO之前,先总结一下阻塞、非阻塞、异步、同步的概念。
阻塞和非阻塞,描述的是结果的请求
阻塞:在得到结果之前就一直呆在那,啥也不干,此时线程挂起,就如其名,线程被阻塞了。
非阻塞:如果没得到结果就返回,等一会再去请求,直到得到结果为止。
异步和同步,描述的是结果的发出,当调用方的请求进来。
同步:在没获取到结果前就不返回给调用方,如果调用方是阻塞的,那么调用方就会一直等着。如果调用方是非阻塞的,调用方就会先回去,等一会再来问问得到结果没。
异步:调用方一来,会直接返回,等执行完实际的逻辑后在通过回调函数把结果返回给调用方。
 
AIO中的异步操作
在AIO编程模型中,常用的API,如connect、accept、read、write都是支持异步操作的。当调用这些方法时,可以携带一个CompletionHandler参数,它会提供一些回调函数。这些回调函数包括:1.当这些操作成功时你需要怎么做;2.如果这些操作失败了你要这么做。关于这个CompletionHandler参数,你只需要写一个类实现CompletionHandler口,并实现里面两个方法就行了。
而 connect、accept、read、write 这四个方法的同步在前面的代码中,我们已经熟悉过了。在AIO中这四个方法需要传入CompletionHandler参数从而实现异步呢。下面分别举例这四个方法的使用。
先说说Socket和ServerSocket,在NIO中,它们变成了通道,配合缓冲区,从而实现了非阻塞。而在AIO中它们变成了异步通道。也就是AsynchronousServerSocketChannel和AsynchronousSocketChannel,下面例子中对象名分别是serverSocket和socket.
  1. accept:serverSocket.accept(attachment,handler)。handler就是实现了CompletionHandler接口并实现两个回调函数的类,它具体怎么写可以看下面的实战代码。attachment为handler里面可能需要用到的辅助数据,如果没有就填null。
  2. read:socket.read(buffer,attachment,handler)。buffer是缓冲区,用以存放读取到的信息。后面两个参数和accept一样。
  3. write:socket.write(buffer,attachment,handler)。和read参数一样。
  4. connect:socket.connect(address,attachment,handler)。address为服务器的IP和端口,后面两个参数与前几个一样。
Future
既然说到了异步操作,除了使用实现CompletionHandler接口的方式,不得不想到Future。客户端逻辑较为简单,如果使用CompletionHandler的话代码反而更复杂,所以下面的实战客户端代码就会使用Future的方式。简单来说,Future表示的是异步操作未来的结果,怎么理解未来。比如,客户端调用read方法获取服务器发来得消息:
Future<Integer> readResult=clientChannel.read(buffer)
Integer是read()的返回类型,此时变量readResult实际上并不一定有数据,而是表示read()方法未来的结果,这时候readResult有两个方法,isDone():返回boolean,查看程序是否完成处理,如果返回true,有结果了,这时候可以通过get()获取结果。如果你不事先判断isDone()直接调用get()也行,只不过它是阻塞的。如果你不想阻塞,想在这期间做点什么,就用isDone()。
还有一个问题:这些handler的方法是在哪个线程执行的?serverSocket.accept这个方法肯定是在主线程里面调用的,而传入的这些回调方法其实是在其他线程执行的。在AIO中,会有一个AsynchronousChannelGroup,它和AsynchronousServerSocketChannel是绑定在一起的,它会为这些异步通道提供系统资源,线程就算其中一种系统资源,所以为了方便理解,我们暂时可以把他看作一个线程池,它会为这些handler分配线程,而不是在主线程中去执行。
上面零碎的概念讲的比较多,仅是为了大家方便理解。下面简单梳理一下大概的工作流程,重点是服务端,客户端相对比较简单。
  1. 跟NIO一样,先要创建好通道,只不过AIO是异步通道。然后创建好AsyncChannelGroup,可以选择自定义线程池。最后把AsyncServerSocket和AsyncChannelGroup绑定在一起,这样处于同一个AsyncChannelGroup里的通道就可以共享系统资源。
  2. 创建好handler类,并实现接口和里面两个回调方法。(如图:客户端1对应的handler,里面的回调方法会实现读取消息和转发消息的功能;serverSocket的handler里的回调方法会实现accept功能。)
  3. 准备工作完成,当客户端1连接请求进来,客户端会马上回去,ServerSocket的异步方法会在连接成功后把客户端的SocketChannel存进在线用户列表,并利用客户端1的handler开始异步监听客户端1发送的消息。
  4. 当客户端1发送消息时,如果上一步中的handler成功监听到,就会回调成功后的回调方法,这个方法里会把这个消息转发给其他客户端。转发完成后,接着利用handler监听客户端1发送的消息。
代码部分同NIO一样,也只有三个类。
  1. ChatServer:功能基本上和上面讲的工作流程差不多,还会有一些工具方法,都比较简单,就不多说了,如:转发消息,客户端下线后从在线列表移除客户端等。
  2. ChatClient:基本和前两章的BIO、NIO没什么区别,一个线程监听用户输入信息并发送,主线程异步的读取服务器信息。
  3. UserInputHandler:监听用户输入信息的线程。
测试结果:

 GitHub 代码:

 https://github.com/xueyunqings/study_io

 

参考文档

  1. Java NIO 之 Channel(通道)
  2. Java NIO之Selector(选择器)
  3. 手动搭建I/O网络通信框架3:NIO编程模型,升级改造聊天室

 

posted on 2021-06-27 21:07  薛大明白  阅读(89)  评论(0编辑  收藏  举报

导航