承接上一篇,本文主要结合源码分析Window环境下Selector选择操作从poll调用完成后更新selectedKeys,以及关闭选择器的功能原理。建议有兴趣的读者先阅读《上篇》中的内容。传送门Selector源码深入分析之Window实现(上篇)。
更新已选择队列
updateSelectedKeys负责处理发生就绪事件的FD,将这些FD对应的选择键加入selectedKeys集合。客户端通过遍历selectedKeys集合即可处理各种事件。看源码:
private int updateSelectedKeys() {
updateCount++;
int numKeysUpdated = 0;
numKeysUpdated += subSelector.processSelectedKeys(updateCount);
for (SelectThread t: threads) {
numKeysUpdated += t.subSelector.processSelectedKeys(updateCount);
}
return numKeysUpdated;
}
updateSelectedKeys首先将updateCount加1,后文会介绍updateCount的作用。updateSelectedKeys首先会调用processSelectedKeys处理主线程上的发生就绪事件的FD列表。然后迭代threads集合分别处理每个辅助线程上发生就绪事件的FD列表。看processSelectedKeys实现:
private int processSelectedKeys(long updateCount) {
int numKeysUpdated = 0;
numKeysUpdated += processFDSet(updateCount, readFds,
PollArrayWrapper.POLLIN,
false);
numKeysUpdated += processFDSet(updateCount, writeFds,
PollArrayWrapper.POLLCONN |
PollArrayWrapper.POLLOUT,
false);
numKeysUpdated += processFDSet(updateCount, exceptFds,
PollArrayWrapper.POLLIN |
PollArrayWrapper.POLLCONN |
PollArrayWrapper.POLLOUT,
true);
return numKeysUpdated;
}
processSelectedKeys实现很简单,分别处理readFds,writeFds,exceptFds三个数组中的FD,核心处理过程在processFDSet中实现。看源码:
private int processFDSet(long updateCount, int[] fds, int rOps,
boolean isExceptFds)
{
int numKeysUpdated = 0;
for (int i = 1; i <= fds[0]; i++) {
int desc = fds[i];
if (desc == wakeupSourceFd) {
synchronized (interruptLock) {
interruptTriggered = true;
}
continue;
}
MapEntry me = fdMap.get(desc);
// 如果me为null,说明选择键在之前processDeregisterQueue中被注销
if (me == null)
continue;
SelectionKeyImpl sk = me.ski;
// 由于存在OOB数据排队进入socket,所以FD可能存在于exceptfds
// 如果存在OOB数据,判断是否需要忽略。
if (isExceptFds &&
(sk.channel() instanceof SocketChannelImpl) &&
discardUrgentData(desc))
{
continue;
}
if (selectedKeys.contains(sk)) { // Key in selected set
if (me.clearedCount != updateCount) {
if (sk.channel.translateAndSetReadyOps(rOps, sk) &&
(me.updateCount != updateCount)) {
me.updateCount = updateCount;
numKeysUpdated++;
}
} else { // The readyOps have been set; now add
if (sk.channel.translateAndUpdateReadyOps(rOps, sk) &&
(me.updateCount != updateCount)) {
me.updateCount = updateCount;
numKeysUpdated++;
}
}
me.clearedCount = updateCount;
} else { // Key is not in selected set yet
if (me.clearedCount != updateCount) {
sk.channel.translateAndSetReadyOps(rOps, sk);
if ((sk.nioReadyOps() & sk.nioInterestOps()) != 0) {
selectedKeys.add(sk);
me.updateCount = updateCount;
numKeysUpdated++;
}
} else { // The readyOps have been set; now add
sk.channel.translateAndUpdateReadyOps(rOps, sk);
if ((sk.nioReadyOps() & sk.nioInterestOps()) != 0) {
selectedKeys.add(sk);
me.updateCount = updateCount;
numKeysUpdated++;
}
}
me.clearedCount = updateCount;
}
}
return numKeysUpdated;
}
processFDSet负责轮询FD数组,并处理每个FD。如果FD为wakeupSourceFd,只需将interruptTriggered置为true,否则,针对每个FD对应的选择键,做如下处理:
首先会有一个复杂的判断条件,条件不成立才继续执行,否则会跳过该选择键。下面我们分析判断条件:
isExceptFds &&
(sk.channel() instanceof SocketChannelImpl) &&
discardUrgentData(desc)
对于readFds和writeFds,isExceptFds都是false,因此判断条件总是不成立。关键在于exceptFds:
- 如果选择键对应的通道类型不是SocketChannelImpl,通常为ServerSocketChannelImpl,则判断条件不成立;
- 如果选择键对应的通道类型是SocketChannelImpl,调用discardUrgentData判断是否忽略客户端socket发送的OOB数据(带外数据)。如果不忽略,条件不成立。windows环境下,客户端socket通常使用sendUrgentData发送紧急数据(类似于心跳包)用于检测连接的有效性。
接下来是核心处理逻辑,首先第一种情况选择键已经存在于selectedKeys,表示之前的选择操作已经添加过该选择键。fdMap为每个选择键维护了两个状态:updateCount和clearedCount。clearedCount用于确定当前选择操作中readyOps是否已被重置,updateCount用于判断当前选择操作中是否已将键计数更新。他们与选择器状态updateCount保持如下关系:
me.updateCount <= me.clearedCount <= updateCount
由于选择键对应的FD可能同时存在于readFds,writeFds,exceptFds。意味着三次processFDSet调用都会处理同一个选择键。假设当前选择键不存在于selectedKeys,现在我们来分析这一过程:
- 第一次processFDSet调用:me.clearedCount != updateCount必然成立,执行translateAndSetReadyOps,判断(sk.nioReadyOps() & sk.nioInterestOps()) != 0,只要通道上发生感兴趣的事件集合的任意其一,条件就会成立,进而将选择键加入selectedKeys,更新me.updateCount为当前updateCount的值,选择键更新计数器numKeysUpdated加1。最后将me.clearedCount更新为当前updateCount的值;
- 第二次processFDSet调用:此时me.clearedCount == updateCount,将执行translateAndUpdateReadyOps,若返回True,numKeysUpdated是否被更新将取决于第一次调用me.updateCount是否被更新。如已经被更新,则numKeysUpdated不会被更新。
me.updateCount保证一次选择操作中numKeysUpdated至多被更新一次,me.clearedCount则保证了一次选择操作中translateAndSetReadyOps只被调用一次。
下面我们继续分析translateAndSetReadyOps源码,以SocketChannelImpl实现为例:
public boolean translateAndSetReadyOps(int ops, SelectionKeyImpl sk) {
return translateReadyOps(ops, 0, sk);
}
public boolean translateReadyOps(int ops, int initialOps,
SelectionKeyImpl sk) {
int intOps = sk.nioInterestOps(); // Do this just once, it synchronizes
int oldOps = sk.nioReadyOps();
int newOps = initialOps;
if ((ops & PollArrayWrapper.POLLNVAL) != 0) {
// 只有当选择操作正在进行时,此通道被预先关闭,才会发生这种情况。
// ##如果此通道尚未预先关闭,则会发生错误
return false;
}
if ((ops & (PollArrayWrapper.POLLERR
| PollArrayWrapper.POLLHUP)) != 0) {
newOps = intOps;
sk.nioReadyOps(newOps);
// No need to poll again in checkConnect,
// the error will be detected there
readyToConnect = true;
return (newOps & ~oldOps) != 0;
}
if (((ops & PollArrayWrapper.POLLIN) != 0) &&
((intOps & SelectionKey.OP_READ) != 0) &&
(state == ST_CONNECTED))
//通道上发生了可读事件且用户对可读事件感兴趣且通道状态为连接已经建立
newOps |= SelectionKey.OP_READ;
if (((ops & PollArrayWrapper.POLLCONN) != 0) &&
((intOps & SelectionKey.OP_CONNECT) != 0) &&
((state == ST_UNCONNECTED) || (state == ST_PENDING))) {
//通道上发生了可连接事件且用户对可连接事件感兴趣且通道状态为未连接或发起连接中
newOps |= SelectionKey.OP_CONNECT;
readyToConnect = true;
}
if (((ops & PollArrayWrapper.POLLOUT) != 0) &&
((intOps & SelectionKey.OP_WRITE) != 0) &&
(state == ST_CONNECTED))
//通道上发生了可写事件且用户对可写事件感兴趣且通道状态为连接已经建立
newOps |= SelectionKey.OP_WRITE;
sk.nioReadyOps(newOps);
return (newOps & ~oldOps) != 0;
}
initialOps为0,表示translateAndSetReadyOps会重置readyOps。根据通道上发生的事件、用户感兴趣的事件和当前通道的状态将事件集设置到选择键的readyOps中。(newOps & ~oldOps) != 0为true表示当前发生了之前选择期间未发生的事件。如果发生了之前选择期间发生的事件,相应的位做&运算结果为0。
接下来我们看translateAndUpdateReadyOps的源码:
public boolean translateAndUpdateReadyOps(int ops, SelectionKeyImpl sk) {
return translateReadyOps(ops, sk.nioReadyOps(), sk);
}
translateAndUpdateReadyOps最终也是调用translateReadyOps,只是使用使用了之前选择期操作的兴趣集作为initialOps。此时,新发生的事件会被添加到readyOps中,之前发生的事件不会被清除。
从processFDSet实现可知,如果通道的键还没有处于已选择的键的集合中,那么键的ready集合将被清空,然后poll操作发现的当前通道已经准备好的事件的比特掩码将被设置;否则,通道的键已经处于已选择的键的集合中,键的ready集合将被poll操作发现的当前已经准备好的事件的比特掩码更新。所有之前的已经不再是就绪状态的操作不会被清除。事实上,所有的比特位都不会被清理。新就绪的事件集是与之前的ready集合按位分离的,一旦键被放置于选择器的已选择的键的集合中,它的ready集合将是累积的。比特位只会被设置,不会被清理。
select操作返回的值是ready集合在processFDSet中被修改的键的数量,而不是已选择的键的集合中的通道的总数。返回值不是已准备好的通道的总数,而是从上一个select( )调用之后进入就绪状态的通道的数量。之前的调用中就绪的,并且在本次调用中仍然就绪的通道不会被计入,而那些在前一次调用中已经就绪但已经不再处于就绪状态的通道也不会被计入。这些通道可能仍然在已选择的键的集合中,但不会被计入返回值中。返回值可能是0。
关闭选择器
调用选择器close方法可关闭选择器,进入源码:
public final void close() throws IOException {
boolean open = selectorOpen.getAndSet(false);
if (!open)
return;
implCloseSelector();
}
selectorOpen是原子类型变量,并发时,只有一个线程能继续向下执行implCloseSelector。
public void implCloseSelector() throws IOException {
wakeup();
synchronized (this) {
synchronized (publicKeys) {
synchronized (publicSelectedKeys) {
implClose();
}
}
}
}
首先会调用wakeup唤醒选择器。这是有必要的,假如当前选择器正在做选择操作,如果不进行唤醒操作,由于select是阻塞的,这就可能会导致close被阻塞。回想一下select的加锁顺序,this->publicKeys->publicSelectedKeys,close采用了通用的加锁顺序。因此调用wakeup唤醒选择器,当选择器select调用释放了相关的锁资源,close才能顺序进行。下面看implClose实现:
protected void implClose() throws IOException {
synchronized (closeLock) {
if (channelArray != null) {
if (pollWrapper != null) {
// prevent further wakeup
synchronized (interruptLock) {
interruptTriggered = true;
}
wakeupPipe.sink().close();
wakeupPipe.source().close();
for(int i = 1; i < totalChannels; i++) { // Deregister channels
if (i % MAX_SELECTABLE_FDS != 0) { // skip wakeupEvent
deregister(channelArray[i]);
SelectableChannel selch = channelArray[i].channel();
if (!selch.isOpen() && !selch.isRegistered())
((SelChImpl)selch).kill();
}
}
pollWrapper.free();
pollWrapper = null;
selectedKeys = null;
channelArray = null;
// Make all remaining helper threads exit
for (SelectThread t: threads)
t.makeZombie();
startLock.startThreads();
}
}
}
}
close负责关闭选择器,并且释放相应的资源。首先加closeLock,如果channelArray和pollWrapper非空,首先关闭wakeupPipe的sink通道和source通道;接下来遍历totalChannels,调用deregister从通道的键集合中注销相应的选择键,若通道已经关闭并且没有注册到其他选择器上,调用kill()关闭通道;然后释放pollWrapper,并将pollWrapper,selectedKeys ,channelArray引用置为null;最后遍历threads集合,将所有辅助线程设为空闲,并调用startLock.startThreads()唤醒所有辅助线程,退出run方法。
后记
到此,选择器实现原理和源码分析已经完成。本文主要结合windows实现的源码来分析选择器的实现原理,后续有时间再介绍下Linux下epoll实现的选择器。相信有了本文的基础,对理解java nio和netty相关功能和原理会事半功倍。
欢迎指出本文有误的地方。