JAVA NIO 選擇器

爲什麼要使用選擇器

通道處於就緒狀態後,就可以在緩衝區之間傳送數據。可以採用非阻塞模式來檢查通道是否就緒,但非阻塞模式還會做別的任務,當有多個通道同時存在時,很難將檢查通道是否就緒與其他任務剝離開來,或者說是這樣做很複雜,即使完成了這樣的功能,但每檢查一次通道的就緒狀態,就至少有一次系統調用,代價十分昂貴。當你輪詢每個通道的就緒狀態時,剛被檢查的一個處於未就緒狀態的通道,突然處於就緒狀態,在下一次輪詢之前是不會被察覺的。操作系統擁有這種檢查就緒狀態並通知就緒的能力,因此要充分利用操作系統提供的服務。在JAVA中,Selector類提供了這種抽象,擁有詢問通道是否已經準備好執行每個I/0操作的能力,所以可以利用選擇器來很好地解決以上問題。

如何使用選擇器

使用選擇器時,需要將一個或多個可選擇的通道註冊到選擇器對象中,註冊後會返回一個選擇鍵,選擇器會記住這些通道以及這些通道感興趣的操作,還會追蹤對應的通道是否已經就緒。調用選擇器對象的select( )方法,當有通道就緒時,相關的鍵會被更新。可以獲取選擇鍵的集合,從而找到已經就緒的通道。

這裏提到的選擇器、選擇鍵與可選擇通道之間的關係如下圖所示


先看一段使用選擇器的代碼

ServerSocketChannel serverChannel = ServerSocketChannel.open;
serverChannel.configureBlocking(false);
serverChannel.socket().bind(new InetSocketAddress(1234));
Selector selector = Selector.open();
serverChannel.register(selector, SelectionKey.OP_ACCEPT);

while (true) {
	selector.select();
	Iterator<SelectionKey> itor = selector.selectedKeys().iterator();
	while (itor.hasNext()) {
		SelectionKey key = itor.next();
		itor.remove();
		if (key.isAcceptable()) {
			ServerSocketChannel server = (ServerSocketChannel) key.channel();
			SocketChannel channel = server.accept();
			channel.configureBlocking(false);
			channel.write(ByteBuffer.wrap("hello".getBytes()));
			channel.register(selector, SelectionKey.OP_READ);
		} else if (key.isReadable()) {
			//read();
		}
	}
}
以上代碼向選擇器(selector)註冊了兩個可選擇通道serverChannel和channel,其中serverChannel對accept感興趣,channel對read感興趣。當select()方法返回後,輪詢選擇鍵,找到準備就緒的通道,在這裏是serverChannel的accept處於就緒狀態,證明有連接到來,於是接受連接,得到一個通道並向這個通道寫入"hello",同時對這個通道的read感興趣,所以也將其註冊到選擇器上,當連接的另一端有數據到來時,key.isReadable()返回true,可以讀取數據。

打開通道

從Selector源碼中可以看到,open方法是交給selectorProvider處理的

public static Selector open() throws IOException {
	return SelectorProvider.provider().openSelector();
    }
selectorProvider不同的操作系統有不同的實現,這裏以windows爲例
//WindowsSelectorProvider.java
public AbstractSelector openSelector() throws IOException {
	return new WindowsSelectorImpl(this);
}

//WindowsSelectorImpl.java
WindowsSelectorImpl(SelectorProvider sp) throws IOException {
    super(sp);
    pollWrapper = new PollArrayWrapper(INIT_CAP);
    wakeupPipe = Pipe.open();
    wakeupSourceFd = ((SelChImpl)wakeupPipe.source()).getFDVal();

    // Disable the Nagle algorithm so that the wakeup is more immediate
    SinkChannelImpl sink = (SinkChannelImpl)wakeupPipe.sink();
    (sink.sc).socket().setTcpNoDelay(true);
    wakeupSinkFd = ((SelChImpl)sink).getFDVal();

    pollWrapper.addWakeupSocket(wakeupSourceFd, 0);
}

//PollArrayWrapper.java
void addWakeupSocket(int fdVal, int index) {
	putDescriptor(index, fdVal);
	putEventOps(index, POLLIN);
}
這裏創建了一個管道pipe,並對pipe的source端的POLLIN事件感興趣,addWakeupSocket方法將source的POLLIN事件標識爲感興趣的,當sink端有數據寫入時,source對應的文件描述描wakeupSourceFd就會處於就緒狀態。(事實上windows就是通過向管道中寫數據來喚醒阻塞的選擇器的)從以上代碼可以看出:通道的打開實際上是構造了一個SelectorImpl對象。

註冊通道

有了選擇器後就可以註冊通道了,註冊通道的方法定義在SelectableChannel類中
public final SelectionKey register(Selector sel,int ops)
                            throws ClosedChannelException
第二個參數ops指示選擇鍵的interest集,可以是OP_READ、OP_WRITE、OP_ACCEPT等,分別表示對讀、寫、連接到來感興趣。
註冊通道的核心方法是implRegister,仍然以windows爲例
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++;
    }
}
先看看growIfNeeded方法
private void growIfNeeded() {
      if (channelArray.length == totalChannels) {
          int newSize = totalChannels * 2; // Make a larger array
          SelectionKeyImpl temp[] = new SelectionKeyImpl[newSize];
          System.arraycopy(channelArray, 1, temp, 1, totalChannels - 1);
          channelArray = temp;
          pollWrapper.grow(newSize);
      }
      if (totalChannels % MAX_SELECTABLE_FDS == 0) { // more threads needed
          pollWrapper.addWakeupSocket(wakeupSourceFd, totalChannels);
          totalChannels++;
          threadsCount++;
      }
  }
做了兩件事:
1、調整channelArray數組大小;
2、增加進行select的線程數,每達到MAX_SELECTABLE_FDS=1024個描述符,就增加一個線程,windows上select系統調用有最大文件描述符限制,一次只能輪詢1024個文件描述符,如果多於1024個,需要多線程進行輪詢。
implRegister方法設置選擇鍵在數組中的位置,並將其加入已註冊的鍵的集合(keys)中,fdMap是文件描述符到選擇鍵的映射。

選擇過程

有三種形式的select方法:select()、select(timeout)、selectNow(),最終都調用了doSelect()方法
protected int doSelect(long timeout) throws IOException {
  if (channelArray == null)
      throw new ClosedSelectorException();
  this.timeout = timeout; // set selector timeout
  processDeregisterQueue();
  if (interruptTriggered) {
      resetWakeupSocket();
      return 0;
  }
  // Calculate number of helper threads needed for poll. If necessary
  // threads are created here and start waiting on startLock
  adjustThreadsCount();
  finishLock.reset(); // reset finishLock
  // Wakeup helper threads, waiting on startLock, so they start polling.
  // Redundant threads will exit here after wakeup.
  startLock.startThreads();
  // do polling in the main thread. Main thread is responsible for
  // first MAX_SELECTABLE_FDS entries in pollArray.
  try {
      begin();
      try {
          subSelector.poll();
      } catch (IOException e) {
          finishLock.setException(e); // Save this exception
      }
      // Main thread is out of poll(). Wakeup others and wait for them
      if (threads.size() > 0)
          finishLock.waitForHelperThreads();
    } finally {
        end();
    }
  // Done with poll(). Set wakeupSocket to nonsignaled  for the next run.
  finishLock.checkForException();
  processDeregisterQueue();
  int updated = updateSelectedKeys();
  // Done with poll(). Set wakeupSocket to nonsignaled  for the next run.
  resetWakeupSocket();
  return updated;
}
processDeregisterQueue方法主要是對已取消的鍵集合進行處理,通過調用cancel()方法將選擇鍵加入已取消的鍵集合中,這個鍵並不會立即註銷,而是在下一次select操作時進行註銷,註銷操作在implDereg完成
protected void implDereg(SelectionKeyImpl ski) throws IOException{
    int i = ski.getIndex();
    assert (i >= 0);
    if (i != totalChannels - 1) {
        // Copy end one over it
        SelectionKeyImpl endChannel = channelArray[totalChannels-1];
        channelArray[i] = endChannel;
        endChannel.setIndex(i);
        pollWrapper.replaceEntry(pollWrapper, totalChannels - 1,
                                                            pollWrapper, i);
    }
    channelArray[totalChannels - 1] = null;
    totalChannels--;
    ski.setIndex(-1);
    if ( totalChannels != 1 && totalChannels % MAX_SELECTABLE_FDS == 1) {
        totalChannels--;
        threadsCount--; // The last thread has become redundant.
    }
    fdMap.remove(ski); // Remove the key from fdMap, keys and selectedKeys
    keys.remove(ski);
    selectedKeys.remove(ski);
    deregister(ski);
    SelectableChannel selch = ski.channel();
    if (!selch.isOpen() && !selch.isRegistered())
        ((SelChImpl)selch).kill();
}

從channelArray中移除對應的通道,調整通道數和線程數,從map和keys中移除選擇鍵,移除通道上的選擇鍵並關閉通道

adjustThreadsCount這個方法前面提到過,與windows下select有文件描述符限制有關,需要多線程select

    private void adjustThreadsCount() {
        if (threadsCount > threads.size()) {
            // More threads needed. Start more threads.
            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()) {
            // Some threads become redundant. Remove them from the threads List.
            for (int i = threads.size() - 1 ; i >= threadsCount; i--)
                threads.remove(i).makeZombie();
        }
    }
當前線程如果比threadsCount小就新建,如果比threadsCount大就移除,比較容易理解,來看看線程的run方法

public void run() {
    while (true) { // poll loop
        // wait for the start of poll. If this thread has become
        // redundant, then exit.
        if (startLock.waitForStart(this))
            return;
        // call poll()
        try {
            subSelector.poll(index);
        } catch (IOException e) {
            // Save this exception and let other threads finish.
            finishLock.setException(e);
        }
        // notify main thread, that this thread has finished, and
        // wakeup others, if this thread is the first to finish.
        finishLock.threadFinished();
    }
}

private synchronized boolean waitForStart(SelectThread thread) {
  while (true) {
      while (runsCounter == thread.lastRun) {
          try {
              startLock.wait();
          } catch (InterruptedException e) {
              Thread.currentThread().interrupt();
          }
      }
      if (thread.isZombie()) { // redundant thread
          return true; // will cause run() to exit.
      } else {
          thread.lastRun = runsCounter; // update lastRun
          return false; //   will cause run() to poll.
      }
  }
}

可以看到這些helper線程創建好後,都阻塞在startLock.wait()上面,待主線程(doSelect方法)調用startLock.startThreads()後,waitForStart方法將返回false

// Triggers threads, waiting on this lock to start polling.
private synchronized void startThreads() {
    runsCounter++; // next run
    notifyAll(); // wake up threads.
}
緊接着調用subSelector.poll(index)輪詢各個文件描述符,同時主線程也在進行輪詢,意思是所有線程(主線程和helper線程)都在輪詢文件描述符。

如果在這期間,有文件描述符準備就緒,poll方法就會返回,不管是主線程返回還是helper線程返回,其他線程都會被喚醒

private synchronized void waitForHelperThreads() {
    if (threadsToFinish == threads.size()) {
        // no helper threads finished yet. Wakeup them up.
        wakeup();
    }
    while (threadsToFinish != 0) {
        try {
            finishLock.wait();
        } catch (InterruptedException e) {
            // Interrupted - set interrupted state.
            Thread.currentThread().interrupt();
        }
    }
}
如果是主線程poll返回,會調用waitForHelperThreads方法喚醒helper線程,如果是其中一個helper線程返回,會調用threadFinished方法喚醒其他helper線程和主線程

 private synchronized void threadFinished() {
            if (threadsToFinish == threads.size()) { // finished poll() first
                // if finished first, wakeup others
                wakeup();
            }
            threadsToFinish--;
            if (threadsToFinish == 0) // all helper threads finished poll().
                notify();             // notify the main thread
        }
因爲整個輪詢的過程中可能有其他鍵註冊失敗,或者調用了cancel方法,這裏再次調用processDeregisterQueue()方法清理一下

現在把注意力放到updateSelectedKeys方法上,這個方法完成了選擇鍵的更新,來看具體實現

    private int updateSelectedKeys() {
        updateCount++;
        int numKeysUpdated = 0;
        numKeysUpdated += subSelector.processSelectedKeys(updateCount);
        for (SelectThread t: threads) {
            numKeysUpdated += t.subSelector.processSelectedKeys(updateCount);
        }
        return numKeysUpdated;
    }
對主線程和各個helper線程都調用了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方法分別對讀選擇鍵集、寫選擇鍵集,異常選擇鍵集調用了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);
              // If me is null, the key was deregistered in the previous
              // processDeregisterQueue.
              if (me == null)
                  continue;
              SelectionKeyImpl sk = me.ski;

              // The descriptor may be in the exceptfds set because there is
              // OOB data queued to the socket. If there is OOB data then it
              // is discarded and the key is not added to the selected set.
              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;
      }
  }
以上方法完成了更新選擇鍵,步驟如下:

1、忽略wakeupSourceFd,這個文件描述符用於喚醒用的,與用戶具體操作無關,所以忽略;

2、過濾fdMap中不存在的文件描述符,因爲已被註銷;

3、忽略oob data(搜了一下:out of band data指帶外數據,有時也稱爲加速數據, 是指連接雙方中的一方發生重要事情,想要迅速地通知對方 ),這也不是用戶關心的;

4、如果通道的鍵還沒有處於已選擇的鍵的集合中,那麼鍵的ready集合將被清空,然後表示操作系統發現的當前通道已經準備好的操作的比特掩碼將被設置;

5、如果鍵在已選擇的鍵的集合中。鍵的ready集合將被表示操作系統發現的當前已經準備好的操作的比特掩碼更新。

來看下具體的更新ready集的方法translateAndUpdateReadyOps,不同的通道有不同的實現,以socketChannel爲例

    public boolean translateAndUpdateReadyOps(int ops, SelectionKeyImpl sk) {
        return translateReadyOps(ops, sk.nioReadyOps(), 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) {
            // This should only happen if this channel is pre-closed while a
            // selection operation is in progress
            // ## Throw an error if this channel has not been pre-closed
            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;
    }
總之,最終是通過調用sk.nioReadyOps(newOps)來設置新的ready集的。

把目光轉向selectkey類的幾個方法

public final boolean isAcceptable() {
	return (readyOps() & OP_ACCEPT) != 0;
}
public final boolean isConnectable() {
	return (readyOps() & OP_CONNECT) != 0;
}
public final boolean isWritable() {
	return (readyOps() & OP_WRITE) != 0;
}
public final boolean isReadable() {
	return (readyOps() & OP_READ) != 0;
}
可以看出一個通道是否可讀、可寫、可連接就是通過對ready集進行與操作來判斷的。

總結一下doSelect:處理已取消的鍵集,通過本地方法poll輪詢文件描述符,poll方法返回後更新已選擇鍵的ready集。

喚醒

如果線程正阻塞在select方法上,調用wakeup方法會使阻塞的選擇操作立即返回

    public Selector wakeup() {
        synchronized (interruptLock) {
            if (!interruptTriggered) {
                setWakeupSocket();
                interruptTriggered = true;
            }
        }
        return this;
    }
//WindowsSelectorImpl.java
private void setWakeupSocket() {
    setWakeupSocket0(wakeupSinkFd);
}
private native void setWakeupSocket0(int wakeupSinkFd);
    

//WindowsSelectorImpl.c    
JNIEXPORT void JNICALL
Java_sun_nio_ch_WindowsSelectorImpl_setWakeupSocket0(JNIEnv *env, jclass this,
                                                jint scoutFd)
{
    /* Write one byte into the pipe */
    send(scoutFd, (char*)&POLLIN, 1, 0);
}
向pipe的sink端寫入了一個字節,source文件描述符就會處於就緒狀態,poll方法會返回,從而導致select方法返回。

這裏有必要提一下打開通道pipe.open的實現細節,先看看windows的實現

public static Pipe open() throws IOException {
	return SelectorProvider.provider().openPipe();
}

public Pipe openPipe() throws IOException {
  return new PipeImpl(this);
}

PipeImpl(final SelectorProvider sp) throws IOException {
	try {
	   AccessController.doPrivileged(new Initializer(sp));
	} catch (PrivilegedActionException x) {
	   throw (IOException)x.getCause();
	}
}
創建了一個PipeImpl對象, AccessController.doPrivileged調用後緊接着會執行initializer的run方法

public Void run() throws IOException {
	ServerSocketChannel ssc = null;
	SocketChannel sc1 = null;
	SocketChannel sc2 = null;

	try {
		// loopback address
		InetAddress lb = InetAddress.getByName("127.0.0.1");
		assert (lb.isLoopbackAddress());

		// bind ServerSocketChannel to a port on the loopback address
		ssc = ServerSocketChannel.open();
		ssc.socket().bind(new InetSocketAddress(lb, 0));

		// Establish connection (assumes connections are eagerly
		// accepted)
		InetSocketAddress sa = new InetSocketAddress(lb, ssc.socket().getLocalPort());
		sc1 = SocketChannel.open(sa);

		ByteBuffer bb = ByteBuffer.allocate(8);
		long secret = rnd.nextLong();
		bb.putLong(secret).flip();
		sc1.write(bb);

		// Get a connection and verify it is legitimate
		for (;;) {
			sc2 = ssc.accept();
			bb.clear();
			sc2.read(bb);
			bb.rewind();
			if (bb.getLong() == secret)
				break;
			sc2.close();
		}

		// Create source and sink channels
		source = new SourceChannelImpl(sp, sc1);
		sink = new SinkChannelImpl(sp, sc2);
	} catch (IOException e) {
		
	}
}
該方法創建了兩個通道sc1和sc2,這兩個通道都綁定了本地ip,然後sc1向sc2寫入了一個隨機長整型的數,這兩個通道分別做爲管道的source與sink端。這相當於利用了回送地址(loopback address)自己向自己寫數據,來達到通知的目的。
看看sun solaris的實現

PipeImpl(SelectorProvider sp) {
    int[] fdes = new int[2];
    IOUtil.initPipe(fdes, true);
    FileDescriptor sourcefd = new FileDescriptor();
    IOUtil.setfdVal(sourcefd, fdes[0]);
    source = new SourceChannelImpl(sp, sourcefd);
    FileDescriptor sinkfd = new FileDescriptor();
    IOUtil.setfdVal(sinkfd, fdes[1]);
    sink = new SinkChannelImpl(sp, sinkfd);
}


JNIEXPORT void JNICALL
Java_sun_nio_ch_IOUtil_initPipe(JNIEnv *env, jobject this,
                                    jintArray intArray, jboolean block)
{
    int fd[2];
    jint *ptr = 0;

    if (pipe(fd) < 0) {
        JNU_ThrowIOExceptionWithLastError(env, "Pipe failed");
        return;
    }
    if (block == JNI_FALSE) {
        if ((configureBlocking(fd[0], JNI_FALSE) < 0)
            || (configureBlocking(fd[1], JNI_FALSE) < 0)) {
            JNU_ThrowIOExceptionWithLastError(env, "Configure blocking failed");
        }
    }
    ptr = (*env)->GetPrimitiveArrayCritical(env, intArray, 0);
    ptr[0] = fd[0];
    ptr[1] = fd[1];
    (*env)->ReleasePrimitiveArrayCritical(env, intArray, ptr, 0);
}
可見solaris上採用系統調用pipe來完成管道的創建,相當於直接用了系統的管道,而windows上用的是loopback,同樣是爲了達到通知的目的,windows與與solaris採用了不同的方案。至於windows爲什麼不採用管道來實現,留個疑問??

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章