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.SelectableChannel
和Selector
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
是一个抽象类,在使用时,没办法直接实例化,查看一下它的子类,巧了,有ServerSocketChannel
和SocketChannel
看到这两个,就想到了它们可以进行阻塞的通信,那怎么进行非阻塞通信?
1. 创建ServerSocketChannel
这一步和阻塞式的通信一致。
severSocketChannel = ServerSocketChannel.open();
2. 设置使用非阻塞式通信
severSocketChannel.configureBlocking(false);
默认情况下,为阻塞通信,也就是参数为true的情况。
3. 设置使用非阻塞通信
这一步和阻塞式的通信一致。
severSocketChannel.bind(new InetSocketAddress(PORT));
4. 创建Selector
selector = Selector.open();
5. 将ServerSocketChannel
的OP_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();
}
}
}
关于非阻塞客户端的坑
- 连接建立的不确定性:如果使用一个非阻塞的通道连接服务端(
connect()
),connect()
可能会在连接建立完成之前返回false。因此,connect()
之后,需要使用finishConnect()
检查连接是否建立成功。 - 在实现给Echo服务端发数据的客户端时,对于阻塞通信,只需要在发送数据之后在调用
read()
接收服务端的会送即可,阻塞通信可以保证从通道内可以读到数据;对于非阻塞通信,在发送数据之后不能直接读取,因为不能保证通道内的数据已经准备好了(服务端可能还没把数据通过通道传输过来),直接读取可能会出错,对于这种情况,必须向选择器注册一个读事件,可以通过选择器来判断通道什么时候可读。
关于Selector.select()
的细节
Selector提供了三个select()方法:
- select():阻塞式,至少会选择出一个。当没有事件触发时,进入阻塞状态,当有事件被触发是,唤醒。
- selectNow():非阻塞,如果有事件触发,则选择并返回;没有事件触发,立即返回,不会阻塞等待。
- select(long timeout):阻塞式,与select()相同,但是加了一个定时器,阻塞时间超过timeout将会返回。