Selector源码深入分析之Window实现(上篇)

在上一篇文章中,已经详细介绍了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源码分析和关闭选择器的功能将在下篇中详细分析。传送门

Selector源码深入分析之Window实现(下篇)

欢迎指出本文有误的地方。

转载于:https://my.oschina.net/7001/blog/1590939

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值