選擇器 Selector 是 I/O 多路複用的核心元件,它可以監控實現了 SelectableChannel 的通道的就緒情況。有了多路複用(multiplexing) I/O 模型,使得單執行緒的 Java 程式在極端情況下能夠處理數萬個連線,極大提高了程式的併發數。
1. 多路複用 I/O 模型
I/O 多路複用模型是作業系統提供給應用程式的一種進行 I/O 操作的模型。應用程式通過 select/poll 系統呼叫監控多個 I/O 裝置,一旦某個或者多個 I/O 裝置的處於就緒狀態(例如:可讀)則返回,應用程式隨後可對就緒的裝置進行操作。
大致流程如下:
1)應用程式向核心發起 select 系統呼叫,該呼叫會阻塞應用程式。
2)核心等待資料到達。資料可能由 DMA 複製到核心緩衝區,也有可能是 CPU 進行復制。
3)資料準備完畢,select 呼叫返回。select 返回的是一個集和,可能有多個連線都已經就緒。
4)應用程式發起 read 系統呼叫。
5)作業系統將資料有核心緩衝區複製到使用者緩衝區。
6)read 呼叫返回。
I/O 多路複用模型本質上是一種阻塞 I/O,進行讀操作的 read 系統呼叫是阻塞的,select 的時候也是阻塞的。不過 I/O 多路複用模型的優勢在於阻塞時可以等待多路 I/O 就緒,然後一併處理。與多執行緒處理多路 I/O 相比,它是單執行緒的,沒有執行緒切換的開銷,單位時間內能夠處理多的連線數。
2. 選擇器與通道關係
在 Java 中,通道 Channel 可以表示 I/O 連線,而選擇器可以監控某些 I/O 事件就緒的通道,選擇通道中就緒的 I/O 事件。這裡的通道必須是實現了 SelectableChannel 介面的通道,例如:SocketChannel, DatagramChannel 等;而檔案通道 FileChannel 沒有實現該介面,所以不支援選擇器。
3. 選擇鍵 SelectionKey
選擇器 Selector 監控通道時監控的是通道中的事件,選擇鍵 SelectionKey 就代表著 I/O 事件。程式通過呼叫 Selector.select() 方法來選中選擇器所監控的通道中的就緒的 I/O 事件的集合,然後遍歷集合,對事件作出相應的處理。
選擇鍵 SelectionKey 可以表示 4 種事件,這 4 種事件使用 int 型別的常量來表示。
1)SelectionKey.OP_ACCEPT 表示 accept 事件就緒。例如:對於 ServerSocketChannel 來說,該事件就緒表示可以呼叫 accept() 方法來獲得與客戶端連線的通道 SocketChannel。
2)SelectionKey.OP_CONNECT 表示客戶端與服務端連線成功。
3)SelectionKey.OP_READ 表示通道中已經有了可讀資料,可以呼叫 read() 方法從通道中讀取資料。
4)SelectionKey.OP_WRITE 表示寫事件就緒,可以呼叫 write() 方法往通道中寫入資料。
不同的通道所能夠支援的 I/O 事件不同,例如:ServerSocketChannel 只支援 accept 事件,而 DatagramChannel 只支援 read 和 write 事件。要檢視通道所支援的事件,可以檢視通道的 javadoc 文件,或者呼叫通道的 validOps() 方法來進行判斷。例如:channel.validOps() & SelectionKey.OP_READ > 0 表示 channel 支援讀事件。
4. 選擇器使用步驟
4.1 獲取選擇器
與通道和緩衝區的獲取類似,選擇器的獲取也是通過靜態工廠方法 open() 來得到的。
Selector selector = Selector.open(); // 獲取一個選擇器例項
4.2 獲取可選擇通道
能夠被選擇器監控的通道必須實現了 SelectableChannel 介面,並且需要將通道配置成非阻塞模式,否則後續的註冊步驟會丟擲 IllegalBlockingModeException。
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("localhost", 9090)); // 開啟 SocketChannel 並連線到本機 9090 埠
socketChannel.configureBlocking(false); // 配置通道為非阻塞模式
4.3 將通道註冊到選擇器
通道在被指定的選擇器監控之前,應該先告訴選擇器,並且告知監控的事件,即:將通道註冊到選擇器。
通道的註冊通過 SelectableChannel.register(Selector selector, int ops) 來完成,ops 表示關注的事件,如果需要關注該通道的多個 I/O 事件,可以傳入這些事件型別或運算之後的結果。這些事件必須是通道所支援的,否則丟擲 IllegalArgumentException。
socketChannel.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE); // 將套接字通過到註冊到選擇器,關注 read 和 write 事件
4.4 輪詢 select 就緒事件
通過呼叫選擇器的 Selector.select() 方法可以獲取就緒事件,該方法會將就緒事件放到一個 SelectionKey 集合中,然後返回就緒的事件的個數。這個方法對映多路複用 I/O 模型中的 select 系統呼叫,它是一個阻塞方法。正常情況下,直到至少有一個就緒事件,或者其它執行緒呼叫了當前 Selector 物件的 wakeup() 方法,或者當前執行緒被中斷時返回。
while (selector.select() > 0){ // 輪詢,且返回時有就緒事件
Set<SelectionKey> keys = selector.selectedKeys(); // 獲取就緒事件集合
.......
}
有 3 種方式可以 select 就緒事件:
1)select() 阻塞方法,有一個就緒事件,或者其它執行緒呼叫了 wakeup() 或者當前執行緒被中斷時返回。
2)select(long timeout) 阻塞方法,有一個就緒事件,或者其它執行緒呼叫了 wakeup(),或者當前執行緒被中斷,或者阻塞時長達到了 timeout 時返回。不丟擲超時異常。
3)selectNode() 不阻塞,如果無就緒事件,則返回 0;如果有就緒事件,則將就緒事件放到一個集合,返回就緒事件的數量。
4.5 處理就緒事件
每次可以 select 出一批就緒的事件,所以需要對這些事件進行迭代。從一個 SelectionKey 物件可以得到:1)就緒事件的對應的通道;2)就緒的事件。通過這些資訊,就可以很方便地進行 I/O 操作。
for(SelectionKey key : keys){
if(key.isWritable()){ // 可寫事件
if("Bye".equals( (line = scanner.nextLine()) )){
socketChannel.shutdownOutput();
socketChannel.close();
break;
}
buf.put(line.getBytes());
buf.flip();
socketChannel.write(buf);
buf.compact();
}
}
keys.clear(); // 清除選擇鍵(事件)集,避免下次迴圈的時候重複處理。
需要注意的是,處理完 I/O 事件之後,需要清除選擇鍵集和,避免下一輪迴圈的時候對同一事件重複處理。
5. 完整示例
下面給出一個完整的例項,例項中包含 TCP 客戶端 TcpClient, UDP 客戶端 UdpClient 和服務端 EchoServer。服務端 EchoServer 可以同時處理 UDP 請求和 TCP 請求,使用者可以在客戶端控制檯輸入內容,按回車傳送給服務端,服務端列印客戶端傳送過來的內容。完整程式碼:https://github.com/Robothy/java-experiments/tree/main/nio/Selector
5.1 服務端
public class EchoServer {
public static void main(String[] args) throws IOException {
Selector selector = Selector.open(); // 獲取選擇器
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); // 開啟伺服器通道
serverSocketChannel.configureBlocking(false); // 伺服器通道配置為非阻塞模式
serverSocketChannel.bind(new InetSocketAddress(9090)); // 繫結 TCP 埠 9090
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); // 將伺服器通道註冊到選擇器 selector 中,註冊事件為 ACCEPT
DatagramChannel datagramChannel = DatagramChannel.open(); // 開啟套接字通道
datagramChannel.configureBlocking(false); // 配置通道為非阻塞模式
datagramChannel.bind(new InetSocketAddress(9090)); // 繫結 UDP 埠 9090
datagramChannel.register(selector, SelectionKey.OP_READ); // 將通道註冊到選擇器 selector 中,註冊事件為讀取資料
ByteBuffer buf = ByteBuffer.allocate(1024); // 分配一個 1024 位元組的堆位元組緩衝區
while (selector.select() > 0){ // 輪詢已經就緒的註冊的通道的 I/O 事件
Set<SelectionKey> keys = selector.selectedKeys(); // 獲取就緒的 I/O 事件,即選擇器鍵集合
for (SelectionKey key : keys){ // 遍歷選擇鍵,處理就緒事件
if(key.isAcceptable()){ // 選擇鍵的事件的是 I/O 連線事件
SocketChannel socketChannel = serverSocketChannel.accept(); // 執行 I/O 操作,獲取套接字連線通道
socketChannel.configureBlocking(false); // 配置為套接字通道為非阻塞模式
socketChannel.register(selector, SelectionKey.OP_READ); // 將套接字通過到註冊到選擇器,關注 READ 事件
}else if(key.isReadable()){ // 選擇鍵的事件是 READ
StringBuilder sb = new StringBuilder();
if(key.channel() instanceof DatagramChannel){ // 選擇的通道為資料包通道,客戶端是通過 UDP 連線過來的
sb.append("UDP Client: ");
datagramChannel.receive(buf); // 最多讀取 1024 位元組,資料包多出的部分自動丟棄
buf.flip();
while(buf.position() < buf.limit()) {
sb.append((char)buf.get());
}
buf.clear();
}else{ // 選擇的通道為套接字通道,客戶端時通過 TCP 連線過來的
sb.append("TCP Client: ");
ReadableByteChannel channel = (ReadableByteChannel) key.channel(); // 獲取通道
int size;
while ( (size = channel.read(buf))>0){
buf.flip();
while (buf.position() < buf.limit()) {
sb.append((char)buf.get());
}
buf.clear();
}
if (size == -1) {
sb.append("Exit");
channel.close();
}
}
System.out.println(sb);
}
}
keys.clear(); // 將選擇鍵清空,防止下次迴圈時被重複處理
}
}
}
5.2 TCP 客戶端
public class TcpClient {
public static void main(String[] args) throws IOException {
Selector selector = Selector.open();
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("localhost", 9090));
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_WRITE);
Scanner scanner = new Scanner(System.in);
String line;
ByteBuffer buf = ByteBuffer.allocate(1024);
while (selector.select() > 0){
Set<SelectionKey> keys = selector.selectedKeys();
for(SelectionKey key : keys){
if(key.isWritable()){
if("Bye".equals( (line = scanner.nextLine()) )){
socketChannel.shutdownOutput();
socketChannel.close();
break;
}
buf.put(line.getBytes());
buf.flip();
socketChannel.write(buf);
buf.compact();
}
}
keys.clear();
if(!socketChannel.isOpen()) break;
}
}
}
5.3 UDP 客戶端
public class UdpClient {
public static void main(String[] args) throws IOException {
Selector selector = Selector.open(); // 獲取選擇器
DatagramChannel datagramChannel = DatagramChannel.open(); // 開啟一個資料包通道
datagramChannel.configureBlocking(false); // 配置通道為非阻塞模式
datagramChannel.register(selector, SelectionKey.OP_WRITE); // 將通道的寫事件註冊到選擇器
ByteBuffer buff = ByteBuffer.allocate(1024); // 分配位元組緩衝區
Scanner scanner = new Scanner(System.in); // 建立掃描器,掃描控制檯輸入流
InetSocketAddress server = new InetSocketAddress("localhost", 9090);
while (selector.select() > 0){ // 有就緒事件
Set<SelectionKey> keys = selector.selectedKeys(); // 獲取選擇鍵,即就緒的事件
for(SelectionKey key : keys){ // 遍歷選擇鍵
if(key.isWritable()){ // 如果當前選擇鍵是讀就緒
String line;
if("Bye".equals( line = scanner.nextLine() )) { // 從控制檯獲取 1 行輸入,並檢查輸入的是不是 Bye
System.exit(0); // 正常退出
}
buff.put(line.getBytes()); // 放入緩衝區
buff.flip(); // 將緩衝區置為讀狀態
datagramChannel.send(buff, server); // 往 I/O 寫資料
buff.compact(); // 壓縮緩衝區,保留沒傳送完的資料
}
}
keys.clear();
}
}
}
6. 小結
Selector 作為多路複用 I/O 模型的核心元件,能夠同時監控多路 I/O 通道。選擇器在 select 就緒事件地時候會阻塞,在處理 I/O 事件的時候也會阻塞,它的優勢在於在阻塞的時候可以等待多路 I/O 就緒,是一種非同步阻塞 I/O 模型。與多執行緒處理多路 I/O 相比,多路複用模型只需要單個執行緒即可處理萬級連線,沒有執行緒切換的開銷。