NIO非阻塞通信简介及基于TCP的非阻塞通信

NIO非阻塞通信简介及基于TCP的非阻塞通信

通信方式简述

阻塞式的通信

以基于TCP的通信方式来说明:创建ServerSocket(或ServerSocketChannel)、调用accept()方法获取客户端的Socket连接、通过Socket通信、关闭Socket连接。

对于阻塞式的通信,最影响效率的其实是SocketChannel.read()或通过Socket获得的输入流的InputStream.read()。当服务端调用这些输入方法时,线程就会进入阻塞状态,直到客户端发来数据才被唤醒。

如果用单线程的方式实现上述流程,资源的利用率极低。线程调用read()方法后,进入阻塞状态,如果客户端迟迟没有数据,服务端就会一直被阻塞,而没办法进行其他操作。

第一种解决方法就是使用多线程的方式,一个线程仅负责accept()接收请求,将接收到的请求分配给其他线程,这样,调用read()时,处理请求的线程不会被阻塞,这样就还能接收新的请求,提高了并发量与资源利用率。

第二种解决方法就是使用非阻塞的通信方式。

非阻塞式的通信

先来考虑一下为什么会有阻塞通信?和本地I/O一样,本地I/O也是需要阻塞,当通过系统调用I/O完成后,唤醒被阻塞的进程(线程)。问题就在于:什么时候I/O完成,对于阻塞式的I/O来说,当进程被唤醒时就说明I/O完成。

现在再来想一下本地I/O最低效的方式,循环的访问I/O完成的状态标志,当状态标志指示I/O完成后,说明I/O结束。这就是一个非阻塞的过程,当调度到这个进程(线程),CPU在循环的访问一个标志位来判断I/O是否完成

把这个思想迁移到网络通信中,现在有多个连接,怎样判断哪个连接有数据过来?给这些连接一个标志,用来表示是否有数据,服务端循环的访问每个连接的这个标志,就可以知道哪个连接的数据已经准备好了

问题貌似解决了,但是想一下,如果所有连接都没有数据,那CPU一直再做“无用功”,浪费资源。

为了避免这种资源浪费,可以使用一个选择器,这个选择器的作用就是把有数据的连接都挑出来,轮询的时候保证每个连接都是有数据需要处理的;如果所有连接都没有数据,选择器被阻塞,直到有某个连接的数据准备好。

关于非阻塞式的通信的基本思想,已经阐述完成,带着这些简单的理解,继续了解NIO提供的非阻塞通信。

Java提供的抽象

上面分析非阻塞I/O时,有两个很重要的点:被选择器选择的连接选择器

NIO提供了它们的抽象:java.nio.channels.SelectableChannelSelector

SelectableChannel

下面给出一段官方文档描述的翻译:
SelectableChannel可以通过Selector复用。
SelectableChannel的实例为了可以被Selector使用,必须使用register()方法注册到Selector的实例;该方法返回一个SelectionKey对象,这个对象代表了这个通道注册到一个选择器中。
SelectableChannel是线程安全的。

看完这些解释如果不懂,可以先接着往下看,看到后面就会理解。

Selector

再谈谈向Selector注册,在注册时,有两个参数,一个是Selector,另一个是一个int类型,它表示事件

在上面一直用“读”/“输入”举例子,如果要让Selector判断一个Selectable是否可读,注册时仅需要调用SelectableChannel.register(Selector, SelectionKey.OP_READ)

SelectionKey.OP_READ表示一个读事件,或者说让选择器判断通道是否可读。

非阻塞通信的使用-TCP

SelectableChannel是一个抽象类,在使用时,没办法直接实例化,查看一下它的子类,巧了,有ServerSocketChannelSocketChannel

看到这两个,就想到了它们可以进行阻塞的通信,那怎么进行非阻塞通信?

1. 创建ServerSocketChannel

这一步和阻塞式的通信一致。

severSocketChannel = ServerSocketChannel.open();

2. 设置使用非阻塞式通信

severSocketChannel.configureBlocking(false);

默认情况下,为阻塞通信,也就是参数为true的情况。

3. 设置使用非阻塞通信

这一步和阻塞式的通信一致。

severSocketChannel.bind(new InetSocketAddress(PORT));

4. 创建Selector

selector = Selector.open();

5. 将ServerSocketChannelOP_Accept事件注册到Selector

severSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

当有连接到来(accept队列中有连接请求),事件被触发,选择器选择时就会把这个通道“挑选”出来,之后就可以轮询所有“挑选”出来的通道,并处理这些通道出发的事件。

这里要注意,注册的通道必须是非阻塞的,否则会出错抛出异常。

在这里插入图片描述

6. 选择器选择已经触发了事件的通道:select()

通过使用Selector.select()方法,可以“挑选”出已经触发了事件的通道。

请看官方的解释

Selects a set of keys whose corresponding channels are ready for I/O operations.

选择出一组健,它们对应的通道已经准备好I/O操作

其实,因该改成选择出一组健,它们对应的通道的注册事件已经触发,因为可注册的事件除了I/O之外,还有accept

这里说一下可以注册的事件:

  • OP_ACCEPT:选择器检测到对应的ServerSocketChannel已经准备好接受(accept)另一个连接,则该事件被触发(被“选中”)。
  • OP_CONNECT:选择器检测到对应的SocketChannel准备完成连接序列,则该事件触发(被“选中”)。
  • OP_READ:选择器检测到对应的SocketChannel已经准备好读取数据,则该事件被触发(被“选中”)。
  • OP_WRITE:选择器检测到对应的SocketChannel已经准备好写数据,则改时间触发(被“选中”)。

7. 获得选择出来的集合:selectedKeys()

可以这样理解:select()方法会遍历已经注册的通道,如果通道对应的注册事件已经出发,则把这个通道放入一个selected集合,调用Selector.selectedKeys()即可获得该集合

selected集合中存放的不是一个通道,而是对它进行了封装,封装成了一个SelectionKey,通过这个封装好的类,不仅可以获得触发了时间的通道,还可以知道触发了什么事件,针对不同的事件,可以给出不同的处理。

8. 遍历selectedKey集合,处理事件

示例:Echo服务端

public class Server {
	private static final int PORT = 8888;
	private ServerSocketChannel severSocketChannel;
	private Selector selector;
	
	public Server() throws IOException {
		//创建serverSocketChannel
		this.severSocketChannel = ServerSocketChannel.open();
		//开启非阻塞方式
		this.severSocketChannel.configureBlocking(false);
		//绑定地址,开启监听
		this.severSocketChannel.bind(new InetSocketAddress(PORT));
		
		//创建selector
		this.selector = Selector.open();
		//注册accepte事件
		this.severSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
	}
	
	
	public void service() {
		//是否有事件触发
		try {
			while(selector.select() > 0) {
				//获得已经触发的事件
				Iterator<SelectionKey> selectionKeys = selector.selectedKeys().iterator();
				while(selectionKeys.hasNext()) {
					SelectionKey key = selectionKeys.next();
					//删除即将处理过的事件,因为使用多线程进行处理,避免多次处理
					selectionKeys.remove();
					if(key.isAcceptable()) {//可接受一个连接
						ServerSocketChannel serverSocketChannel = (ServerSocketChannel)key.channel();
						SocketChannel clientChannel = serverSocketChannel.accept();
						clientChannel.configureBlocking(false);
						clientChannel.register(this.selector, SelectionKey.OP_READ);
					}else if(key.isReadable()) {//可读
						ByteBuffer buffer = ByteBuffer.allocate(1024);
						SocketChannel clientChannel = (SocketChannel) key.channel();
						InetSocketAddress address = (InetSocketAddress)clientChannel.getLocalAddress();
						String clientIp = address.getHostString();
						int clientPort = address.getPort();
						try {
							clientChannel.read(buffer);
							buffer.flip();
							//服务端输出
							String content = new String(buffer.array(), 0, buffer.limit());
							System.out.println("收到信息 [" + clientIp + " : " + clientPort + "] " + content);
							//回送给客户端				
							clientChannel.write(buffer);
							//收到exit,退出
							if("exit".equals(content)) {
								System.out.println("与 [ " + clientIp + " : " + clientPort + "]的连接已断开");
								try {
									clientChannel.close();
								} catch (IOException e) {
									e.printStackTrace();
								}
							}					
						
							//准备下一次输入
							buffer.clear();	
						} catch (IOException e) {
							e.printStackTrace();
						}
					}
				}
			}
		} catch (IOException e) {
			e.printStackTrace();
		} finally {
			try {
				this.severSocketChannel.close();
				this.selector.close();
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}
	
	public static void main(String[] args) {
		try {
			(new Server()).service();
		} catch (IOException e) {
			e.printStackTrace();
		}
	}
}

关于非阻塞客户端的坑

  1. 连接建立的不确定性:如果使用一个非阻塞的通道连接服务端(connect()),connect()可能会在连接建立完成之前返回false。因此,connect()之后,需要使用finishConnect()检查连接是否建立成功。
  2. 在实现给Echo服务端发数据的客户端时,对于阻塞通信,只需要在发送数据之后在调用read()接收服务端的会送即可,阻塞通信可以保证从通道内可以读到数据;对于非阻塞通信,在发送数据之后不能直接读取,因为不能保证通道内的数据已经准备好了(服务端可能还没把数据通过通道传输过来),直接读取可能会出错,对于这种情况,必须向选择器注册一个读事件,可以通过选择器来判断通道什么时候可读。

关于Selector.select()的细节

Selector提供了三个select()方法:

  1. select():阻塞式,至少会选择出一个。当没有事件触发时,进入阻塞状态,当有事件被触发是,唤醒。
  2. selectNow():非阻塞,如果有事件触发,则选择并返回;没有事件触发,立即返回,不会阻塞等待。
  3. select(long timeout):阻塞式,与select()相同,但是加了一个定时器,阻塞时间超过timeout将会返回。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章