Java NIO Selector 的使用

detectiveHLH發表於2022-02-23

之前的文章已經把 Java 中 NIO 的 Buffer、Channel 講解完了,不太瞭解的可以先回過頭去看看。這篇文章我們就來聊聊 Selector —— 選擇器。

首先 Selector 是用來幹嘛的呢?不熟悉這個概念的話我們其實可以這麼理解:

selector
selector

把它當作 SQL 中的 select 語句,在 SQL 中無非就是篩選出符合條件的結果集合。而 NIO 中的 Selector 用途類似,只不過它選擇出來的是有就緒 IO 事件的 Channel

IO 事件代表了 Channel 對於不同的 IO 操作所處的不同的狀態,而不是對 Channel 進行 IO 操作。總共有 4 種 IO 事件的定義:

  • OP_READ 可讀
  • OP_WRITE 可寫
  • OP_CONNECT 連線
  • OP_ACCEPT 接收
IO 事件分類
IO 事件分類

比如 OP_READ,其就緒是指資料已經在核心態 Ready 了並且已經從核心態複製到了使用者態的緩衝區,然後我們的應用程式就可以去讀取資料了,這叫可讀

再比如 OP_CONNECT,當某個 Channel 已經完成了握手連線,則 Channel 就會處於 OP_CONNECT 的狀態。

對使用者態和核心態不瞭解的,可以去看看之前寫的 《使用者態和核心態的區別》

在之前講 BIO 模型的時候說過,使用者態在發起 read 系統呼叫之後會一直阻塞,直到資料在核心態 Ready 並且複製到使用者態的緩衝區內。如果只有一個使用者還好,隨便你阻塞多久。但要是這時有其他使用者發請求進來了,就會一直卡在這裡等待。這樣序列的處理會導致系統的效率極其低下。

針對這個問題,也是有解決方案的。那就是為每個使用者都分配一個執行緒(即 Connection Per Thread),乍一想這個思路可能沒問題,但使用執行緒需要消耗系統的資源,例如在 JVM 中一個執行緒會佔用較多的資源,非常昂貴。系統稍微併發多一些(例如上千),你的系統就會直接 OOM 了。而且,執行緒頻繁的建立、銷燬、切換也是一個比較耗時的操作。

而如果用 NIO,雖然不會阻塞了,但是會一直輪詢,讓 CPU 空轉,也是一個不環保的方式。

而如果用 Selector,只需要一個執行緒來監聽多個 Channel,而這個多個可以上千、上萬甚至更多。那這些 Channel 是怎麼跟 Selector 關聯上的呢?

答案是通過註冊,因為現在變成了 Selector 決定什麼時候處理 Channel 中的事件,而註冊操作則相當於將 Channel 的控制權轉交給了 Selector。一旦註冊上了,後續當 Channel 有就緒的 IO 事件,Selector 就會將它們選擇出來執行對應的操作。

說了這麼多,來看個例子吧,客戶端的程式碼相對簡單,後續再看,我們先看服務端的:

public static void main(String[] args) throws IOException {
  // 建立 selector, 管理多個 channel
  Selector selector = Selector.open();

  // 建立 ServerSocketChannel 並且繫結埠
  ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
  serverSocketChannel.configureBlocking(false);
  serverSocketChannel.bind(new InetSocketAddress(8080));

  // 將 channel 註冊到 selector 上
  SelectionKey serverSocketChannelKey = serverSocketChannel.register(selector, 0);
  // 由於總共有 4 種事件, 分別是 accept、connect、read 和 write,
  // 分別代表有連線請求時觸發、客戶端建立連線時觸發、可讀事件、可寫事件
  // 我們可以使用 interestOps 來表明只處理有連線請求的事件
  serverSocketChannelKey.interestOps(SelectionKey.OP_ACCEPT);

  System.out.printf("serverSocketChannel %s\n", serverSocketChannelKey);
  while (true) {
    // 沒有事件發生, 執行緒會阻塞; 有事件發生, 就會讓執行緒繼續執行
    System.out.println("start to select...");
    selector.select();
    // 換句話說, 有連線過來了, 就會繼續往下走

    // 通過 selectedKeys 包含了所有發生的事件, 可能會包含 READ 或者 WRITE
    Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
    while (iterator.hasNext()) {
      SelectionKey key = iterator.next();
      System.out.printf("selected key %s\n", key);

      // 這裡需要進行事件區分
      if (key.isAcceptable()) {
        System.out.println("get acceptable event");

        // 觸發此次事件的 channel, 拿到事件一定要處理, 否則會進入非阻塞模式, 空轉佔用 CPU
        // 例如你可以使用 key.cancel()
        ServerSocketChannel channel = (ServerSocketChannel) key.channel();
        SocketChannel socketChannel = channel.accept();
        socketChannel.configureBlocking(false);

        // 這個 socketChannel 也需要註冊到 selector 上, 相當於把控制權交給 selector
        SelectionKey socketChannelKey = socketChannel.register(selector, 0);
        socketChannelKey.interestOps(SelectionKey.OP_READ);
        System.out.printf("get socketChannel %s\n", socketChannel);
      } else if (key.isReadable()) {
        System.out.println("get readable event");

        SocketChannel channel = (SocketChannel) key.channel();
        ByteBuffer buf = ByteBuffer.allocate(16);
        channel.read(buf);
        buf.flip();
        ByteBufferUtil.debugRead(buf);
        key.cancel();
      }
      
      iterator.remove();
    }
  }
}

看起來有點多,但相應的註釋都寫了,可以先看看。其實這裡的很多程式碼跟之前的玩轉 Channel 的程式碼差不多的,這裡抽一些我認為值得講的解釋一下。

首先就是 Selector.open(),跟 Channel 的 open 方法類似,可以理解為建立一個 selector。

其次就是 SelectionKey serverSocketChannelKey = serverSocketChannel.register(selector, 0); 了,我們呼叫了 serverSocketChannel 的註冊方法之後,返回了一個 SelectionKey,這是個什麼概念呢?

說簡單點,你可以把 SelectionKey 理解為你去商場寄存櫃存東西,那個機器吐給你的提取憑證

換句話說,這個 SelectionKey 就是當前這個 serverSocketChannel 註冊到 selector 上的憑證。selector 會維護一個 SelectionKey 的集合,用於統一管理。

selectionkey 集合
selectionkey 集合

上圖中的每個 Key 都代表了一個具體的 Channel。

而至於 register 的第二個引數,我們傳入的是 0,代表了當前 Selector 需要關注這個 Channel 的哪些 IO 事件。0 代表不關注任何事件,我們這裡是通過 serverSocketChannelKey.interestOps(SelectionKey.OP_ACCEPT); 來告訴 Selector,對這個 Channel 只關注 OP_ACCEPT 事件。

IO 事件有 4 個,如果你想要同時監聽多個 IO 事件怎麼辦呢?答案是通過或運算子。

serverSocketChannelKey.interestOps(SelectionKey.OP_ACCEPT | SelectionKey.OP_READ);

上面說過,NIO 雖然不阻塞,但會一直輪詢佔用 CPU 的資源,而 Selector 解決了這個問題。在呼叫完 selector.select(); 之後,執行緒會在這裡阻塞,而不會像 NIO 一樣瘋狂輪詢,把 CPU 拉滿。所以 Selector 只會在有事件處理的時候才執行,其餘時間都會阻塞,極大的減少了 CPU 資源的佔用。

當客戶端呼叫 connect 發起連線之後,Channel 就會處於 OP_CONNECT 就緒狀態,selector.select(); 就不會再阻塞,會繼續往下執行,即:

Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();

其中 selectedKeys 這個名字也能看出來,表示被選出來的 SelectionKey。上面我們已經討論過 Selector 維護的一種集合 —— SelectionKey 集合,接下來我們再討論另外一種集合 —— SelectedKey 集合。

selectedkey 集合
selectedkey 集合

當 Channel 有就緒 IO 事件之後,對應的 Key 就會被加入到 SelectedKey 集合中,然後這一次 While 迴圈會依次處理被選擇出來的所有 Key。

但被選擇出來的 Key 可能觸發的是不同的 IO 事件,所以我們需要對 Key 進行區分。程式碼裡區分了 OP_ACCEPT 和 OP_READ,分別討論一下。

ServerSocketChannel 一開始 register 的時候只設定關注 OP_ACCEPT 事件,所以第一次迴圈只會進入 IsAcceptable 分支裡,所以這裡通過 iterator.next() 迭代器拿到的 SelectionKey 就是 serverSocketChannel 註冊之後返回的 Key,同理拿到的 channel 的就是最開始呼叫 ServerSocketChannel.open(); 建立的 channel。

拿到了 ServerSocketChannel 我們就可以呼叫其 accept() 方法來處理建立連線的請求了,這裡值得注意的是,建立連線之後,這個 SocketChannel 也需要註冊到 Selector 上去,因為這些 SocketChannel 也需要將控制權交給 Selector,這樣後續有就緒 IO 事件才能通過 Selector 處理。這裡我們對這個 SocketChannel 只關注 OP_READ 事件。相當於把後續進來的所有的連線和 Selector 就關聯上了。

Accept 事件處理成功之後,伺服器這邊會繼續迴圈,然後再次在 selector.select(); 處阻塞住。

客戶端這邊會繼續呼叫 write 方法向 channel 寫入資料,資料 Ready 之後就會觸發 OP_READ 事件,然後繼續往下走,這次由於事件是 OP_READ 所以會進入 key.isReadable() 這個分支。進入這個分支之後會獲取到對應的 SocketChannel,並從其中讀取客戶端發來的資料。

而另一個值得關注的是 iterator.remove();,每次迭代都需要把當前處理的 SelectedKey 移除,這是為什麼呢?

因為對應的 Key 進入了 SelectedKey 集合之後,不會被 NIO 裡的機制給移除。如果我們不去移除,那麼下一次呼叫 selector.selectedKeys().iterator(); 會發現,上次處理的有 OP_ACCEPT 事件的 SelectionKey 還在,而這會導致上面的服務端程式丟擲空指標異常。

大家可以自行將 iterator.remove(); 註釋掉再試試

客戶端的程式碼很簡單,就直接給出來了:

public static void main(String[] args) throws IOException {
  SocketChannel socketChannel = SocketChannel.open();
  socketChannel.connect(new InetSocketAddress("localhost"8080));

  ByteBuffer buffer = ByteBuffer.allocate(16);
  buffer.put("test".getBytes(StandardCharsets.UTF_8));

  buffer.flip();
  socketChannel.write(buffer);
}

如果不去移除的話,服務端會在下面這行 NPE。

socketChannel.configureBlocking(false);

為啥呢?因為此時 SelectionKey 雖然還在,ServerSocketChannel 也能拿到,但呼叫 channel.accept(); 的時候,並沒有客戶端真正在發起連線(上一個迴圈已經處理過真正的連線請求了,只是沒有將這個 Key 從 SelectedKey 中移除)。所以 channel.accept(); 會返回一個 null,我們再對 null 呼叫 configureBlocking 方法,自然而然就 NPE 了。

相關文章