客户端的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的写缓冲区中有空闲的空间时(大部分时候都有),该操作就绪。客户端调用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_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事件
是在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
来写ByteBufferSelectonKey
方式注册: 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
处理的代码,
while (bb.hasRemaining()) {
int len = socketChannel.write(bb);
if (len < 0) {
throw new EOFException();
}
}
上面这样写在大多数的情况下都没有问题,但是在客户端的网络环境很糟糕的情况下,就有问题了。因为如果客户端的网络或者是中间交换机的问题,使得网络传输的效率很低,这时候会出现服务器已经准备好的返回结果无法通过TCP/IP层传输到客户端。这时候在执行上面这段程序的时候就会出现以下情况。
以上的结果肯定不是我们想要的,以上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
事件一次可能读取的数据不完整,此时可以将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;
}*/
}
}
}
}
read事件
,并且在read
的时候抛出异常来表示关闭。如果是服务端关闭,则客户端在write
的时候会抛出异常。这个关闭事件会不断的发生,即使从准备好的集合移除也没有用,必须关闭channel
或者调用key的cancel()
.因为SelectionKey
代表的是Selector
与Channel
之间的联系,所以在Channel
关闭之后,对于Selector
来说,这个Channel
永远都会发出关闭这个事件,表示已经关闭,直到从该Selector
移除去