NIO各种事件
客户端的
SocketChannel
支持OP_CONNECT
,OP_READ
,OP_WRITE
三个操作。服务端ServerSocketChannel
只支持OP_ACCEPT
操作,在服务端由ServerSocketChannel
的accept()
方法产生的SocketChannel
只支持OP_READ
,OP_WRITE
操作。client/Server SocketChannel/ServerSocketChannel OP_ACCEPT OP_CONNECT OP_WRITE OP_READ client SocketChannel Y Y Y server ServerSocketChannel Y server SocketChannel Y Y 就绪条件
OP_ACCEPT
就绪条件:当收到一个客户端的连接请求时,该操作就绪。这是ServerSocketChannel
上唯一有效的操作。OP_CONNECT
就绪条件:只有客户端SocketChannel
会注册该操作,当客户端调用SocketChannel.connect()
时,该操作会就绪。OP_READ
就绪条件:该操作对客户端和服务端的SocketChannel
都有效,当OS的读缓冲区中有数据可读时,该操作就绪。OP_WRITE
就绪条件:该操作对客户端和服务端的SocketChannel
都有效,当OS的写缓冲区中有空闲的空间时(大部分时候都有),该操作就绪。
OP_CONNECT
客户端调用
connect()
并注册OP_CONNECT
事件后,连接操作就会就绪,但是连接就绪不代表连接成功。OP_CONNECT
底层本质上是Write
SocketChannel channel = SocketChannel.open(); channel.configureBlocking(false); channel.connect(addr); channel.register(selector, SelectionKey.OP_CONNECT); //判断连接就绪,通过`finishConnect()`判断 if (key.isValid() && key.isConnectable()) { SocketChannel ch = (SocketChannel) key.channel(); if (ch.finishConnect()) { // Connect successfully // key.interestOps(SelectionKey.OP_READ); } else { // Connect failed } }
OP_ACCEPT
OP_ACCEPT
的处理与OP_CONNECT
基本一样,服务端监听,并注册OP_ACCEPT
事件后,就已准备好接受客户端的连接了ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.configureBlocking(false); ssc.socket().bind(new InetSocketAddress(port)); channel.register(selector, SelectionKey.OP_ACCEPT); if (key.isValid() && key.isAcceptable()) { ServerSocketChannel ssc = (ServerSocketChannel) key.channel(); SocketChannel ch = ssc.accept(); if (ch != null) { ch.configureBlocking(false); SelectionKey sk = ch.register(selector, SelectionKey.OP_READ); } }
OP_WRITE
OP_WRITE
事件相对特殊,一般情况,不应该注册OP_WRITE事件
,OP_WRITE
的就绪条件为操作系统内核缓冲区有空闲空间(OP_WRITE事件
是在Socket
发送缓冲区中的可用字节数大于或等于其低水位标记SO_SNDLOWAT
时发生),而写缓冲区绝大部分事件都是有空闲空间的,所以当你注册写事件后,写操作一直就是就绪的,这样会导致Selector
处理线程会占用整个CPU的资源。所以最佳实践是当你确实有数据写入时再注册OP_WRITE事件
,并且在写完以后马上取消注册。- 从上面分析可以看出,
OP_WRITE
事件并不是表示在调用channel
的write()
方法之后就会发生这个事件。实际上完全可以不注册OP_WRITE
事件,直接调用SocketChannel.write(ByteBuffer)
是可以把数据直接写到缓冲区并发送的,但是这种直接写的方式Selector
不会选择到isWriteable()
分支,而且必须通过while(ByteBuffer.hasRemain())
来检查写的状态. 注册OP_WRITE
是比较好的做法,注册方式有两种
- 直接注册到
SocketChannel
:SocketChannel.register(selector, SelectionKey.OP_WRITE)
,这种方式直接用SocketChannel
来写ByteBuffer SelectonKey
方式注册:SelectionKey.interestOps(SelectionKey.interestOps() | SelectionKey.OP_WRITE)
- 直接注册到
代码
将数据写入到缓冲区中,并注册写事件 public void write(byte[] data) throws IOException { writeBuffer.put(data); key.interestOps(SelectionKey.OP_WRITE); } //写操作就绪,将之前写入缓冲区的数据写入到Channel,并取消注册 channel.write(writeBuffer); key.interestOps(key.interestOps() & ~SelectionKey.OP_WRITE); 另外一种方式:我们在注册的时候添加attachment,业务方法只需要操作attachment,往attachment里面写,然后在Selector.isWriteable()分支里面取出写好的Attachment,然后调用SocketChannel.Write(ByteBuffer) 方法来把数据写到Channel。
OP_WRITE的处理解决网速慢的连接
不对
OP_WRITE
处理的代码,while (bb.hasRemaining()) { int len = socketChannel.write(bb); if (len < 0) { throw new EOFException(); } }
上面这样写在大多数的情况下都没有问题,但是在客户端的网络环境很糟糕的情况下,就有问题了。因为如果客户端的网络或者是中间交换机的问题,使得网络传输的效率很低,这时候会出现服务器已经准备好的返回结果无法通过TCP/IP层传输到客户端。这时候在执行上面这段程序的时候就会出现以下情况。
- bb.hasRemaining()一直为”true”,因为服务器的返回结果已经准备好了。
- socketChannel.write(bb)的结果一直为0,因为由于网络原因数据一直传不过去。
- 因为是异步非阻塞的方式,socketChannel.write(bb)不会被阻塞,立刻被返回
- 在一段时间内,由于缓冲区一直满,这段代码会被无休止地快速执行着,消耗着大量的CPU的资源。事实上什么具体的任务也没有做,一直到网络允许当前的数据传送出去为止。
以上的结果肯定不是我们想要的,以上
OP_WRITE
代码需要进一步处理才可以达到目的,以下程序在网络不好的时候,将此Channel
的OP_WRITE
操作注册到Selector
上,这样,当网络恢复缓冲区有空间时(OP_WRITE
的就绪条件为底层缓冲区有空闲空间),Channel
可以继续将结果数据返回客户端,此时Selector
会通过SelectionKey
来通知应用程序,再去执行write
操作。这样就能节约大量的CPU资源,使得服务器能适应各种恶劣的网络环境。while (bb.hasRemaining()) { int len = socketChannel.write(bb); if (len < 0){ throw new EOFException(); } //返回0表示缓冲区满 if (len == 0) { selectionKey.interestOps( selectionKey.interestOps() | SelectionKey.OP_WRITE); mainSelector.wakeup(); break; } }
Grizzly中不是按照上面方式对
OP_WRITE
做处理.在Grizzly中,对请求结果的返回是在ProcessTask中处理的,经过SocketChannelOutputBuffer
的类,最终通过OutputWriter
类来完成返回结果的动作。在OutputWriter
中处理OP_WRITE
的代码如下:public static long flushChannel(SocketChannel socketChannel, ByteBuffer bb, long writeTimeout) throws IOException { SelectionKey key = null; Selector writeSelector = null; int attempts = 0; int bytesProduced = 0; try { while (bb.hasRemaining()) { int len = socketChannel.write(bb); attempts++; if (len < 0){ throw new EOFException(); } bytesProduced += len; //写阻塞了 if (len == 0) { if (writeSelector == null){ // 获取一个新的selector writeSelector = SelectorFactory.getSelector(); if (writeSelector == null){ // Continue using the main one continue; } } // 在新selector上注册写事件,而不是在主selector上注册 key = socketChannel.register(writeSelector, key.OP_WRITE); //利用writeSelector.select(timeout)来阻塞当前线程,等待可写事件发生,总共等待可写事件的时长是3*writeTimeout if (writeSelector.select(writeTimeout) == 0) { if (attempts > 2) throw new IOException("Client disconnected"); } else { attempts--; } } else { attempts = 0; } } } finally { if (key != null) { key.cancel(); key = null; } if (writeSelector != null) { // Cancel the key. writeSelector.selectNow(); SelectorFactory.returnSelector(writeSelector); } } return bytesProduced; }
- 当发现由于网络情况而导致的发送数据受阻(
len==0
)时,第一种方式的处理是将当前的Channel
注册到当前的Selector
中;而在Grizzly中,程序从SelectorFactory
中获得了一个临时的Selector
。在获得这个临时的Selector
之后,程序做了一个阻塞的操作:writeSelector.select(writeTimeout)
。这个阻塞操作会在一定时间内(writeTimeout)等待这个Channel
的发送状态。如果等待时间过长,便认为当前的客户端的连接异常中断了。 - Grizzly的处理方式事实上放弃了NIO中的非阻塞的优势,使用
writeSelector.select(writeTimeout)
做了个阻塞操作。虽然CPU的资源没有浪费,可是线程资源在阻塞的时间内,被这个请求所占有,不能释放给其他请求来使用。 - Grizzly作者的解释
- 使用
临时的Selector
的目的是减少线程间的切换。当前的Selector
一般用来处理OP_ACCEPT
,和OP_READ
的操作。使用临时的Selector
可减轻主Selector
的负担;而在注册的时候则需要进行线程切换,会引起不必要的系统调用。这种方式避免了线程之间的频繁切换,有利于系统的性能提高。 - 虽然
writeSelector.select(writeTimeout)
做了阻塞操作,但是这种情况只是少数极端的环境下才会发生。大多数的客户端是不会频繁出现这种现象的,因此在同一时刻被阻塞的线程不会很多。 - 利用这个阻塞操作来判断异常中断的客户连接。
- 经过压力实验证明这种实现的性能是非常好的。
- 使用
OP_READ
OP_READ
事件一次可能读取的数据不完整,此时可以将buffer
attach到SelectionKey
上,下次OP_READ
事件发生时再继续读取if (nextKey.isReadable()) { /** * 一次select只是通知载协议栈的读操作有数据可读,至于这次读到的数据是多少, * 是否是一次完整的交互数据,selector并不关心 */ this.read(nextKey); } private void read(SelectionKey key) throws IOException { System.out.println("执行read..."); SocketChannel sc = (SocketChannel) key.channel(); //读取echo数据 ByteBuffer echoBuffer = ByteBuffer.allocate(1024); while (true) { echoBuffer.clear(); /** * 1. SocketChannel的read()没有超时的设置http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4614802 * 1.1 解决办法: * 1.1.1 使用传统socket读 * 1.1.2 使用ReadableByteChannel这个类(不能解决读写双向的Channel阻塞问题) * InputStream is = sock.socket().getInputStream(); * ReadableByteChannel readCh = Channels.newChannel(is); * 2. 对于非阻塞的read * 1.1 read返回-1表示客户端的数据发送完毕,并且主动close socket,这种情况下,服务端程序需要关闭 * SocketChannel并且取消key,并且退出。如果服务端继续使用该SocketChannel,会抛出IO异常 * 1.2 read返回0 * * 当前SocketChannel没有数据可读,网络不好或者客户端确实没有发送数据 * * 客户端数据发送完毕 * * Bytebuffer的position等于limit了,即bytebuffer的remaining等于0 */ int result = sc.read(echoBuffer); if (result == -1) { System.err.println("客户端主动关闭"); sc.close(); //取消`SocketChannel`与Selector的注册关系 key.cancel(); return; } else if (result == 0) { System.err.println("没有数据等到下次select()调用"); break; } else { System.out.println("写数据到客户端"); echoBuffer.flip(); while (echoBuffer.hasRemaining()) { int len = sc.write(echoBuffer); if (len < 0) { throw new EOFException(); } /* if (len == 0) { //缓冲区满了 System.out.println("len==0"); key.interestOps(key.interestOps() | SelectionKey.OP_WRITE); selector.wakeup(); break; }*/ } } } }
特殊的close事件
- 当关闭客户端时,服务端会发生一个
read事件
,并且在read
的时候抛出异常来表示关闭。如果是服务端关闭,则客户端在write
的时候会抛出异常。这个关闭事件会不断的发生,即使从准备好的集合移除也没有用,必须关闭channel
或者调用key的cancel()
.因为SelectionKey
代表的是Selector
与Channel
之间的联系,所以在Channel
关闭之后,对于Selector
来说,这个Channel
永远都会发出关闭这个事件,表示已经关闭,直到从该Selector
移除去