在上一篇文章中,已经详细介绍了Selector的核心功能和实现原理,有兴趣的读者可移步Java NIO Selector实现原理。本文主要结合源码分析Window环境下Selector的核心功能和实现原理。
选择器成员简介
首先我们先介绍下WindowsSelectorImpl中的变量,下文会经常用到:
//poll数组和channel数组的初始容量
private final int INIT_CAP = 8;
//select操作时,每个线程处理的最大FD数量。为INIT_CAP乘以2的幂
private final static int MAX_SELECTABLE_FDS = 1024;
//由这个选择器服务的SelectableChannel的列表
private SelectionKeyImpl[] channelArray = new SelectionKeyImpl[INIT_CAP];
//存放所有FD的包装器,主要用于poll操作
private PollArrayWrapper pollWrapper;
//注册到当前选择器上总的通道数量,初始化为1是因为实例化选择器时加入了wakeupSourceFd
private int totalChannels = 1;
//选择操作所需要的辅助线程数量。每增加一组MAX_SELECTABLE_FDS - 1个通道,就需要一个线程。
private int threadsCount = 0;
//辅助线程列表
private final List<SelectThread> threads = new ArrayList();
//创建一个Pipe实例,用于实现唤醒选择器的功能
private final Pipe wakeupPipe ;
//管道的read端FD,用于实现唤醒选择器的功能
private final int wakeupSourceFd;
//管道的write端FD,用于实现唤醒选择器的功能
private final int wakeupSinkFd;
//关闭锁,通常在注册、注销,关闭,修改选择键的interestOps时都存在竞态条件,主要保护channelArray、pollWrapper等
private Object closeLock = new Object();
//FD为键,SelectionKeyImpl为value的内部map,方便通过FD查找SelectionKeyImpl
private final FdMap fdMap = new FdMap();
//内部类SubSelector中封装了发起poll调用和处理poll调用结果的细节。由主线程调用
private final SubSelector subSelector = new SubSelector();
//选择器每次选择的超时参数
private long timeout;
//中断锁,用于保护唤醒选择器使用的相关竞态资源,如interruptTriggered
private final Object interruptLock = new Object();
//是否触发中断,唤醒选择器的重要标志,由interruptLock保护
private volatile boolean interruptTriggered = false;
//启动锁,当使用多线程处理选择器上Channel的就绪事件时,用于协调这些线程向内核发起系统调用
//辅助线程会在该锁上等待
private final WindowsSelectorImpl.StartLock startLock = new WindowsSelectorImpl.StartLock();
//完成锁,当使用多线程处理选择器上Channel的就绪事件时,用于协调这些线程从系统调用中返回
//主线程会在该锁上等待
private final WindowsSelectorImpl.FinishLock finishLock = new WindowsSelectorImpl.FinishLock();
//updateSelectedKeys调用计数器
//SubSelector.fdsMap中的每个条目都有一个的updateCount值。调用processFDSet时,当我们增加numKeysUpdated,
//会同步将updateCount设置为当前值。 这用于避免多次计算同一个选择键更新多次numKeysUpdated。
//同一个选择键可能出现在readfds和writefds中。
private long updateCount = 0L;
向Selector注册通道
通常情况下,我们会使用如下方式将通道注册到选择器:
ServerSocketChannel serverSocketChannel=ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
selector=Selector.open();
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
ServerSocketChannel的register方法最终将调用AbstractSelector的register方法,看实现:
protected abstract SelectionKey register(AbstractSelectableChannel ch,
int ops, Object att);
从选择器中register方法接收的参数可知,选择器只接受AbstractSelectableChannel类型的通道注册。我们继续看SelectorImpl中提供的默认实现:
protected final SelectionKey register(AbstractSelectableChannel ch,
int ops,
Object attachment)
{
if (!(ch instanceof SelChImpl))
throw new IllegalSelectorException();
SelectionKeyImpl k = new SelectionKeyImpl((SelChImpl)ch, this);
k.attach(attachment);
synchronized (publicKeys) {
implRegister(k);
}
k.interestOps(ops);
return k;
}
register的整个过程如下:
- 判断传入的通道类型是不是SelChImpl类型的通道,是则继续,不是则抛出异常;
- 使用通道实例和当前选择器实例构造一个SelectionKeyImpl对象;
- 将客户端想要暂存的“附件”保存在SelectionKeyImpl 对象中;
- 对publicKeys加锁,调用implRegister完成进一步的注册工作,稍后详细介绍;
- 最后将客户端希望选择器监听Channel的事件组合设置到interestOps中,返回选择键对象。
注册实现
下面我们看implRegister的源码实现:
protected void implRegister(SelectionKeyImpl ski) {
synchronized (closeLock) {
if (pollWrapper == null)
throw new ClosedSelectorException();
growIfNeeded();
channelArray[totalChannels] = ski;
ski.setIndex(totalChannels);
fdMap.put(ski);
keys.add(ski);
pollWrapper.addEntry(totalChannels, ski);
totalChannels++;
}
}
- implRegister首先会加closeLock锁;
- 如果pollWrapper为空,说明选择器已经关闭了,抛出ClosedSelectorException;
- 调用growIfNeeded()判断channelArray和pollWrapper是否需要扩容,稍后详细介绍;
- 然后完成一系列的赋值操作,将选择键放在channelArray数组尾部,将选择键的index更新为totalChannels,将选择键加入fdMap和keys集合中,还需要将选择键关联通道的FD存储到pollWrapper中,totalChannels用于计算FD在pollWrapper中的内存位置;
- 最后将totalChannels加1,totalChannels既代表了当前注册到选择器的通道总数,同时totalChannels-1还是channelArray数组中最后一个元素的索引。
扩容
growIfNeeded实现如下:
private void growIfNeeded() {
if (channelArray.length == totalChannels) {
int newSize = totalChannels * 2;
SelectionKeyImpl temp[] = new SelectionKeyImpl[newSize];
System.arraycopy(channelArray, 1, temp, 1, totalChannels - 1);
channelArray = temp;
pollWrapper.grow(newSize);
}
if (totalChannels % MAX_SELECTABLE_FDS == 0) {
pollWrapper.addWakeupSocket(wakeupSourceFd, totalChannels);
totalChannels++;
threadsCount++;
}
}
默认情况下,channelArray数组的长度是8,所以当totalChannels为8时,就需要进行扩容。默认扩容为当前channelArray数组的2倍,然后进行数组复制,并修改channelArray的引用指向扩容后的数组;最后还需要对pollWrapper进行扩容。实例化WindowsSelectorImpl对象时,channelArray和pollWrapper长度都初始化为8,因此channelArray和pollWrapper会同时进行扩容。
最后还需要注意一点,当totalChannels对1024取模的值为0,则需要在pollWrapper中加入用于实现唤醒选择器功能的wakeupSourceFd,同时将totalChannels和threadsCount加1。这是为了实现多线程执行系统调用poll,利用现代操作系统多核的功能,高效地实现大批量Channel上就绪事件的检查。在介绍选择器的选择操作时将会详细展开,这里不再赘述。
关于PollArrayWrapper,它内部管理着一段连续的内存空间,每8个字节(一个int)中存放着一个通道的FD。对普通的socket来说,8个字节中只存放了FD,但对于用于唤醒的socket,后4个字节还代表了感兴趣的事件EventOps。当我们调用select向内核空间发起系统调用poll时,需要将PollArrayWrapper的内存地址传入,这样内核就知道选择器需要它监听哪些文件描述符的事件。更多PollArrayWrapper的细节不再展开,有兴趣可阅读其源码。
Selection操作
Selector中最重要的就是selection操作,它负责向内核发起系统调用,以确定选择器上注册的每个通道所关心的事件是否就绪,从而更新selectedKeys集合。上层应用代码通过遍历selectedKeys,可以找到已经就绪的通道,从而处理各种I/O事件。
Selector提供了3种类型的selection操作,看源码:
public int select(long timeout)
throws IOException
{
if (timeout < 0)
throw new IllegalArgumentException("Negative timeout");
return lockAndDoSelect((timeout == 0) ? -1 : timeout);
}
public int select() throws IOException {
return select(0);
}
public int selectNow() throws IOException {
return lockAndDoSelect(0);
}
3种类型的实现默认都是调用lockAndDoSelect(long timeout),只是各自设置的超时时间不同。
select():超时时间为-1L,即永不会超时。选择器会一直阻塞到至少一个channel中有感兴趣的事件发生;
selectNow():超时时间为0L,调用该方法,选择器不会被阻塞,无论是否有channel发生就绪事件,都会立即返回;
select(long timeout):超时时间由用户自定义,但不能小于0L。当选择器上没有channel发生就绪事件,则会等待指定的时间后返回。当然也可设置为0L降级为select()调用。
加锁
private int lockAndDoSelect(long timeout) throws IOException {
synchronized (this) {
if (!isOpen())
throw new ClosedSelectorException();
synchronized (publicKeys) {
synchronized (publicSelectedKeys) {
return doSelect(timeout);
}
}
}
}
lockAndDoSelect的主要作用是对一系列竞态资源加锁。首先对当前Selector对象加锁,然后检查selector是否处于关闭状态,已关闭的selector不允许执行选择操作,因此程序抛出ClosedSelectorException。若选择器未关闭,则依次对publicKeys和publicSelectedKeys加锁。选择器对象是线程安全的,但它内部的键集合不是。publicKeys和publicSelectedKeys是选择器内部的私有的keys集合和selectedKeys集合的直接引用。虽然publicKeys本身是只读的,但对keys集合的修改都会直接反映到publicKeys。因此Windows实现中Selector内部采用这种加锁顺序来保护内部的键集合,防止多线程并发下出现问题。
问题:为什么需要先对this加锁,如果是因为close也采用了同样的加锁顺序,那么都修改为依次对publicKeys和publicSelectedKeys加锁,这样会有问题吗?
这个问题我们可以反向进行论证。假设选择器的select和close都不对this加锁,而是采用依次对publicKeys和publicSelectedKeys加锁的方案。
线程A执行select,线程B执行close,两个线程竞争publicKeys上的锁,以下面的时序执行:
假设1:线程A先拿到publicKeys上的锁,那么线程A可以正常完成一次选择操作,线程A释放锁之后,线程B正常获取锁,执行close操作,这样不会有副作用。
假设2:如果线程B先拿到publicKeys上的锁,此时线程A有两个执行可能:
- 情况1:线程A在竞争锁之前正常检测到选择器已经处于关闭状态,那么就不会发生锁竞争。
- 情况2:线程A调用isOpen()检测到选择器仍处于打开状态,然后向下执行申请publicKeys上的锁,如果在此期间线程A因为某种原因竞争锁失败,而线程B成功对publicKeys加锁。当 线程B完成之后释放锁,选择器相关的资源已经被释放。此时线程A成功获取锁,并无法知道选择器已经关闭,它将继续申请publicKeys和publicSelectedKeys上的锁,然后doSelect期间再次检测到选择器已关闭,即选择器做了无效的操作,造成资源浪费。
当首先对this加锁时,线程A在获取this锁之后,首先会检测当前选择器是否关闭。即使线程A之前发生了close操作,程序将立即抛出ClosedSelectorException,不会进行后面的操作。
思考:我们不对this加锁,修改lockAndDoSelect代码,将isOpen操作移到获取publicKeys上的锁之后,这样会有问题吗?
个人愚见,this锁只有select和close存在竞争,而publicKeys上锁的竞争将增加register操作。这样的加锁方式或许是为了避免publicKeys上锁的竞争太过激烈。欢迎读者留言加以指正。
执行Selection操作
lockAndDoSelect最终调用WindowsSelectorImpl中的doSelect(long timeout),下面看源码:
protected int doSelect(long timeout) throws IOException {
if (channelArray == null)
throw new ClosedSelectorException();
this.timeout = timeout;
processDeregisterQueue();
if (interruptTriggered) {
resetWakeupSocket();
return 0;
}
// 调整辅助线程数量,创建需要的辅助线程,并开始等待startLock
adjustThreadsCount();
finishLock.reset();
// 唤醒等待startLock的辅助线程,开始向内核发起poll。空闲线程将在这里退出
startLock.startThreads();
// 主线程做poll
try {
begin();
try {
subSelector.poll();
} catch (IOException e) {
finishLock.setException(e); // 保存异常信息
}
// 主线程返回,需要等待其他辅助线程
if (threads.size() > 0)
finishLock.waitForHelperThreads();
} finally {
end();
}
//检查poll过程中是否发生异常
finishLock.checkForException();
processDeregisterQueue();
int updated = updateSelectedKeys();
resetWakeupSocket();
return updated;
}
doSelect中最核心的操作是调用poll()向内核发起一个系统调用进行查询,然后更新selectedKeys集合,使得用户可以迭代selectedKeys集合处理各种通道事件。为了更好的理解doSelect,我们先介绍下当Selector管理大量Channel时,如何高效完成所有Channel上就绪事件的检查?
答案是使用多线程,WindowsSelectorImpl将注册在当前Selector上总的Channel数量totalChannels按1024分为一组,每组由1个辅助线程负责,每个辅助线程在选择期间只负责监听自己所管理的1024个Channel上的就绪事件。当不满足1024个Channel时,只由主线程处理;当超过1024个Channel时,最后一个分组可能不满足1024个Channel,同样由一个单独的辅助线程来处理。
既然使用多线程,那么问题来了:
Q1: 当主线程发起一个select调用时,如何保证所有线程同时向内核发起一个系统调用?
Q2:当只有其中某个Channel发生就绪事件时,必然只会有一个线程从poll阻塞中返回,如何让其他线程也从阻塞中返回?
Q3:多线程执行顺序存在不确定性,如何协调所有线程同时从内核调用中返回,并组合所有线程的结果?
Q4:当调用wakeup()执行唤醒操作时,如何唤醒所有阻塞在poll调用的线程?
现在我们结合源码来一个个解答上面的问题。
首先Q1,选择器使用了启动锁startLock来保证所有线程同时向内核发起一个系统调用。如何实现呢?
在将Channel注册到选择器上时,选择器对通道进行分组,如果totalChannels % 1024为0,表示需要增加一个辅助线程,即threadsCount+1。选择器doSelect期间,首先会调用adjustThreadsCount()动态调整线程数量,看实现:
private void adjustThreadsCount() {
if (threadsCount > threads.size()) {
for (int i = threads.size(); i < threadsCount; i++) {
SelectThread newThread = new SelectThread(i);
threads.add(newThread);
newThread.setDaemon(true);
newThread.start();
}
} else if (threadsCount < threads.size()) {
for (int i = threads.size() - 1 ; i >= threadsCount; i--)
threads.remove(i).makeZombie();
}
}
threadsCount 代表当前需要的辅助线程数量,this.threads中则保存了上一次select操作需要的辅助线程。this.threadsCount > this.threads.size(),说明自上一次调用select以来,选择器上又新注册了通道。那么需要增加辅助线程,将新增的线程加入threads数组,然后设置线程为守护线程并立即启动。
this.threadsCount < this.threads.size(),说明自上一次调用select以来,有通道已经从选择器上注销。这时候需要从threads数组中移除多余的辅助线程。
当启动新的辅助线程时,实际该线程并不会立即向内核发起系统调用,看SelectThread的run()实现:
public void run() {
while (true) {
// 等待启动poll。如果当前线程多余,则会退出
if (startLock.waitForStart(this))
return;
try {
subSelector.poll(index);
} catch (IOException e) {
finishLock.setException(e);
}
//通知主线程,当前线程已经完成poll。同时如果是第一个完成,则等他其他线程完成
finishLock.threadFinished();
}
}
SelectThread首先会调用启动锁startLock的waitForStart方法判断是否能调用poll方法。看源码:
private synchronized boolean waitForStart(SelectThread thread) {
while (true) {
while (runsCounter == thread.lastRun) {
try {
startLock.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
if (thread.isZombie()) { // 线程多余,退出run
return true;
} else {
thread.lastRun = runsCounter;
return false;
}
}
}
当满足条件this.runsCounter == thread.lastRun时,辅助线程会进入等待启动状态。什么时候会满足条件呢?让我们先看看启动锁startLock的startThreads()源码:
private synchronized void startThreads() {
++this.runsCounter;
this.notifyAll();
}
startThreads()会将runsCounter加1,而waitForStart则会将辅助线程的lastRun更新为runsCounter。也就是说,每一次doSelect完成之后,辅助线程调用startLock的waitForStart()方法条件this.runsCounter == thread.lastRun总是成立,所以辅助线程在完成一次doSelect之后,就会进入等待状态。只有在下一次doSelect调用时,主线程调用startThreads(),将runsCounter加1,同时调用notifyAll()唤醒所有处于等待状态的辅助线程,此时等待条件将不成立,所有的辅助线程都会参与到CPU调度中,准备向内核发起poll调用。由于waitForStart和startThreads()都是同步方法,保证了更新runsCounter的原子性和可见性。所以一旦调用了startThreads(),则会更新runsCounter和唤醒等待在waitForStart的线程。
WindowsSelectorImpl通过使用startLock来实现了协调所有线程同时向内核发起一个系统调用,高效完成所有Channel上就绪事件的检查。
Q2和第Q4可以一起回答,原理是一样的。
我们都知道,实例化WindowsSelectorImpl时,选择器会将wakeupSourceFd加入pollWrapper,这正是用于实现唤醒功能。基于此原理,当我们将Channel注册到选择器上时,如果满足需要增加辅助线程的条件,选择器会再次将wakeupSourceFd加入pollWrapper。这样进行分组之后,每个线程监听的Fd列表第一个都为wakeupSourceFd。在调用wakeup()执行唤醒操作时,所有线程都能监听到wakeupSourceFd上有就绪事件发生,这就实现了唤醒所有阻塞在poll调用的线程。
若就绪事件不是来自wakeupSourceFd。当其他某个Channel上发生就绪事件时,相应的线程将会从poll阻塞中返回,然后分两种情况:
- 如果从阻塞中返回的线程是主线程,则会调用finishLock.waitForHelperThreads();
- 如果从阻塞中返回的线程是辅助线程,则会调用finishLock.threadFinished();
这两个方法首先都会判断条件this.threadsToFinish ==this.threads.size(),成立则会调用wakeup(),这样就实现了让其他线程也从阻塞中返回。每次select期间,只有当threadFinished()已经被调用过一次,条件才会不成立,因此当只有其中某个Channel发生就绪事件时,选择器总是能让其他没有发生就绪事件的线程从poll阻塞中返回。
最后,Q3的答案是选择器使用了FinishLock来协调所有线程从内核调用中返回。看源码:
private void reset() {
threadsToFinish = threads.size();
}
//poll()完成之后,每个辅助线程都会调用
private synchronized void threadFinished() {
if (threadsToFinish == threads.size()) { // 第一个完成poll()的辅助线程负责唤醒其他线程
wakeup();
}
threadsToFinish--;
if (threadsToFinish == 0) // 所有辅助线程都已经完成poll().
notify(); // 通知主线程
}
//主线程完成poll()之后调用,等待辅助线程完成
private synchronized void waitForHelperThreads() {
if (threadsToFinish == threads.size()) {
// 没有辅助线程完成poll(),唤醒它们
wakeup();
}
while (threadsToFinish != 0) {
try {
finishLock.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
每次在发起系统调用之前,都首先会调用finishLock的reset()重置threadsToFinish 为当前辅助线程的数量。当第一个线程从系统调用poll中返回时,由该线程负责唤醒其他正在阻塞等待的线程。任何一个辅助线程从系统调用poll中返回,都会调用threadFinished(),将threadsToFinish减1。当threadsToFinish 为0时,调用notify()唤醒处于等待中的线程。那么通常谁会处于等待状态呢?答案是主线程,当主线程从系统调用poll中返回时,会调用waitForHelperThreads(),如果此时threadsToFinish不为0,说明还有辅助线程没有从系统调用poll中返回,主线程将进入等待状态。
WindowsSelectorImpl通过使用finishLock来实现了协调所有线程同时从内核调用中返回,向客户端屏蔽了多线程执行系统调用poll的细节,让每次select调用都像只由主线程完成一样。
接下来我们再整体介绍下doSelect(long timeout)完成的功能。首先会检查channelArray,为空说明选择器已关闭,抛出ClosedSelectorException。channelArray不为空则完成以下工作:
- 使用传入的参数更新选择器的超时参数timeout,执行poll期间会用;
- 调用processDeregisterQueue()处理cancelledKeys集合中的选择键,从keys集合中移除所有存在于cancelledKeys集合中的SelectionKey对象,并将注销其通道,同时清空cancelledKeys;
- 如果interruptTriggered为true,说明wakeup()已经被调用,但调用wakeup()时,选择器没有执行选择操作,所以需要调用resetWakeupSocket()读取调用wakeup()期间在sink端写入的数据,并将interruptTriggered设置为false,然后立即返回;如果interruptTriggered为false,说明从上一次选择操作以来,没有调用过wakeup(),程序继续向下执行;
- 调用adjustThreadsCount()动态调整辅助线程的数量;
- 调用完成锁finishLock的reset()方法重置threadsToFinish为调整后的辅助线程数量;
- 调用启动锁startLock的startThreads()方法更新runsCounter,并通知其他辅助线程;
- 调用begin()方法,借助AbstractInterruptibleChannel,把Interruptible回调接口注册到当前线程上。当线程中断时,Thread.interrupt()触发回调wakeup方法,使线程从系统调用中返回,但并不会抛出中断异常。该方法需要在finally块中执行end()方法清除中断器。begin()和end()配合实现了中断选择器的功能;
- 调用subSelector的poll方法向内核发起系统调用,如果该方法抛出IOException,需要将异常记录在finishLock中,待系统调用完成后检查doSelect期间是否发生异常;
- 主线程从poll中返回后,判断threads.size() 是否大于0,大于0说明此次选择操作有辅助线程配合,则调用finishLock的waitForHelperThreads()方法等待其他辅助线程全部从poll中返回;
- 调用finishLock的checkForException()方法所有线程在poll期间是否发生IOException,如果有,拼装异常,将finishLock的变量exception置为null,同时抛出IOException,否则什么也不做;
- 如果所有线程在poll期间没有发生IOException,再次调用processDeregisterQueue()处理cancelledKeys集合中的选择键,目的是为了清除poll期间被取消的选择键。因为即使选择键对应的通道有就绪事件发生,客户端也不会再关注;
- 调用updateSelectedKeys()更新selectedKeys集合,客户端通过遍历该集合,然后根据每个Channel上发生的就绪事件执行相应的I/O操作;
- 最后调用resetWakeupSocket()检查是否调用过wakeup(),如果线程在执行poll期间调用过wakeup(),则需要读取调用wakeup()期间在sink端写入的数据,并将interruptTriggered设置为false。这里调用resetWakeupSocket()的目的是清除poll期间调用wakeup()的痕迹,否则下一次调用doSelect将会立即返回。
以上就是doSelect的完整过程,至此我们对doSelect的整体功能和实现原理有了基本的认识。下面我们将细化其中的步骤,深入分析没有涉及的内容,以便详细了解其中的细节。如processDeregisterQueue()、poll()、updateSelectedKeys()等。
处理注销队列
processDeregisterQueue()主要负责处理cancelledKeys集合中的选择键,从keys集合中移除所有存在于cancelledKeys集合中的SelectionKey对象,并将注销其通道,同时清空cancelledKeys集合。看实现:
void processDeregisterQueue() throws IOException {
// 先决条件: 在this, keys, and selectedKeys上加锁
Set<SelectionKey> cks = cancelledKeys();
synchronized (cks) {
if (!cks.isEmpty()) {
Iterator<SelectionKey> i = cks.iterator();
while (i.hasNext()) {
SelectionKeyImpl ski = (SelectionKeyImpl)i.next();
try {
implDereg(ski);
} catch (SocketException se) {
throw new IOException("Error deregistering key", se);
} finally {
i.remove();
}
}
}
}
}
首先对cancelledKeys加锁,防止其他线程在此期间调用cancel()方法向cancelledKeys集合中添加选择键。如果cancelledKeys集合非空,迭代cancelledKeys集合,调用implDereg()进行注销,并从cancelledKeys中移除选择键。下面看implDereg()的实现:
protected void implDereg(SelectionKeyImpl ski) throws IOException{
int i = ski.getIndex();
assert (i >= 0);
synchronized (closeLock) {
if (i != totalChannels - 1) {
SelectionKeyImpl endChannel = channelArray[totalChannels-1];
channelArray[i] = endChannel;
endChannel.setIndex(i);
pollWrapper.replaceEntry(pollWrapper, totalChannels - 1,
pollWrapper, i);
}
ski.setIndex(-1);
}
channelArray[totalChannels - 1] = null;
totalChannels--;
if ( totalChannels != 1 && totalChannels % MAX_SELECTABLE_FDS == 1) {
totalChannels--;
threadsCount--;
}
fdMap.remove(ski);
keys.remove(ski);
selectedKeys.remove(ski);
deregister(ski);
SelectableChannel selch = ski.channel();
if (!selch.isOpen() && !selch.isRegistered())
((SelChImpl)selch).kill();
}
注销操作首先加closeLock锁更新选择键的索引为-1。如果选择键的索引不是选择键数组channelArray最后一个元素。需要将最后一个元素endChannel放到待注销选择键的位置,并更新其索引为待注销选择键的索引,同时需要将pollWrapper中最后一个元素替换到待注销FD的位置。
退出closeLock锁之后,选择器会做如下操作:
- 将channelArray的最后一个元素引用置为空,并将通道总数量totalChannels减1;
- 如果totalChannels不为1且totalChannels % MAX_SELECTABLE_FDS为1,说明channelArray中该位置没有放置元素(加入唤醒通道时会跳过该位置),需要将totalChannels和辅助线程数量threadsCount减1;
- 从fdMap,keys和selectedKeys中移除当前选择键;
- 调用deregister从通道的键集合中注销该选择键;
- 如果选择键对应的通道已经关闭并且没有注册到其他选择器上,调用kill()关闭通道。
选择器在调用poll之前和之后都会清理已取消的选择键,为什么呢?
使用内部的cancelledKeys集合来延迟注销,是一种防止线程在取消键时阻塞,并防止与正在进行的选择操作冲突的优化。注销通道是一个潜在的代价很高的操作,这可能需要重新分配资源(请记住,键是与通道相关的,并且可能与它们相关的通道对象之间有复杂的交互)。清理已取消的键,并在选择操作之前和之后立即注销通道,可以消除它们可能正好在选择的过程中执行的潜在棘手问题。这是另一个兼顾健壮性的折中方案。
poll系统调用
内部类SubSelector封装了系统调用poll操作,并负责调用poll0()向系统内核发起查询。看源码:
private int poll() throws IOException{ // 主线程调用
return poll0(pollWrapper.pollArrayAddress,
Math.min(totalChannels, MAX_SELECTABLE_FDS),
readFds, writeFds, exceptFds, timeout);
}
private int poll(int index) throws IOException {
// 辅助线程调用
return poll0(pollWrapper.pollArrayAddress +
(pollArrayIndex * PollArrayWrapper.SIZE_POLLFD),
Math.min(MAX_SELECTABLE_FDS,
totalChannels - (index + 1) * MAX_SELECTABLE_FDS),
readFds, writeFds, exceptFds, timeout);
}
private native int poll0(long pollAddress, int numfds,
int[] readFds, int[] writeFds, int[] exceptFds, long timeout);
poll0()参数介绍:
- pollAddress:FD数组内存起始地址;
- numfds:待监听的FD数量;
- readFds:用于接收发生可读事件的FD;
- writeFds:用于接收发生可写事件的FD;
- exceptFds:用于接收发生异常的FD;
- timeout:超时等待时间。
由于每个线程最大会处理1024个通道(包含唤醒通道),因此readFds,writeFds,exceptFds数组的长度均为1025。其中readFds[0]为实际发生可读事件的FD数量,即poll完成之后readFds的实际长度,writeFds,exceptFds同理。关于poll的底层原理本文暂不深入探讨,后续我们再研究。
鉴于篇幅,updateSelectedKeys源码分析和关闭选择器的功能将在下篇中详细分析。传送门
欢迎指出本文有误的地方。